From 89383b37361f301135c500c1dfe91fe55855a695 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 11:51:21 -0700 Subject: [PATCH 01/25] chore: scaffold lists/messages/documents feature modules Empty :feature:lists, :feature:messages, :feature:documents library modules registered in settings.gradle with the full compose/hilt/room/paging/coil dependency set, ready for per-domain build-out. Verified each configures and assembles empty. Co-Authored-By: Claude Opus 4.8 (1M context) --- feature/documents/build.gradle.kts | 81 ++++++++++++++++++++++++++++++ feature/lists/build.gradle.kts | 81 ++++++++++++++++++++++++++++++ feature/messages/build.gradle.kts | 81 ++++++++++++++++++++++++++++++ settings.gradle.kts | 3 ++ 4 files changed, 246 insertions(+) create mode 100644 feature/documents/build.gradle.kts create mode 100644 feature/lists/build.gradle.kts create mode 100644 feature/messages/build.gradle.kts diff --git a/feature/documents/build.gradle.kts b/feature/documents/build.gradle.kts new file mode 100644 index 0000000..3420c31 --- /dev/null +++ b/feature/documents/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.documents" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + // DI + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + // Networking (Retrofit annotations + serialization for DTOs) + implementation(libs.retrofit.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + // Feature-local Room cache (offline-first) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + implementation(libs.room.paging) + ksp(libs.room.compiler) + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + // Images + implementation(libs.coil.compose) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(libs.okhttp.mockwebserver) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/lists/build.gradle.kts b/feature/lists/build.gradle.kts new file mode 100644 index 0000000..05dc647 --- /dev/null +++ b/feature/lists/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.lists" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + // DI + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + // Networking (Retrofit annotations + serialization for DTOs) + implementation(libs.retrofit.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + // Feature-local Room cache (offline-first) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + implementation(libs.room.paging) + ksp(libs.room.compiler) + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + // Images + implementation(libs.coil.compose) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(libs.okhttp.mockwebserver) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/messages/build.gradle.kts b/feature/messages/build.gradle.kts new file mode 100644 index 0000000..d5b7c95 --- /dev/null +++ b/feature/messages/build.gradle.kts @@ -0,0 +1,81 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.messages" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + + // DI + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + // Networking (Retrofit annotations + serialization for DTOs) + implementation(libs.retrofit.core) + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + + // Feature-local Room cache (offline-first) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + implementation(libs.room.paging) + ksp(libs.room.compiler) + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) + + // Images + implementation(libs.coil.compose) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(libs.okhttp.mockwebserver) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b565ee8..795aaba 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,3 +35,6 @@ include(":core:datastore") // Feature modules (added per roadmap phase) include(":feature:auth") +include(":feature:lists") +include(":feature:messages") +include(":feature:documents") From afc9799721bcf7588489367b8ea864720273589e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 12:18:55 -0700 Subject: [PATCH 02/25] feat: integrate lists, messages & documents feature modules Brings the three core-domain feature modules (:feature:lists, :feature:messages, :feature:documents) into the app and wires them into a post-login bottom-navigation shell (Lists / Messages / Documents / Account) with detail routes for list detail, message detail, and the document editor. Each module is a self-contained offline-first vertical slice (own Retrofit API off the shared authenticated client, own Room cache, repository, ViewModels, Compose UI, Hilt DI) built to the live OpenAPI contract, mirroring the :feature:auth pattern. - lists: index, create, schema-driven detail, row CRUD, delete, folders, search (32 unit tests) - messages: feed, compose, detail+replies, dig/undig, delete, search (36 unit tests) - documents: index, create, markdown editor+preview, delete, folders, templates, search (37 unit tests) Verified: :app:assembleDebug + full testDebugUnitTest green; installed on emulator and confirmed all three tabs render live against production (documents shows real data). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 6 + app/build.gradle.kts | 4 + .../navigation/InterlinedListNavHost.kt | 160 +++++++- feature/documents/build.gradle.kts | 29 +- .../src/androidTest/AndroidManifest.xml | 2 + .../documents/ui/DocumentsScreenTest.kt | 99 +++++ .../documents/src/main/AndroidManifest.xml | 2 + .../data/DefaultDocumentsRepository.kt | 256 +++++++++++++ .../documents/data/DocumentsRepository.kt | 68 ++++ .../documents/data/local/DocumentDao.kt | 46 +++ .../documents/data/local/DocumentEntity.kt | 53 +++ .../documents/data/local/DocumentsDatabase.kt | 19 + .../feature/documents/data/local/FolderDao.kt | 22 ++ .../documents/data/local/FolderEntity.kt | 27 ++ .../documents/data/mapper/DocumentMappers.kt | 53 +++ .../documents/data/remote/DocumentsApi.kt | 71 ++++ .../documents/data/remote/dto/DocumentDto.kt | 23 ++ .../data/remote/dto/DocumentRequests.kt | 27 ++ .../data/remote/dto/DocumentResponses.kt | 62 +++ .../documents/data/remote/dto/FolderDto.kt | 39 ++ .../feature/documents/di/DocumentsModule.kt | 56 +++ .../feature/documents/domain/Document.kt | 30 ++ .../documents/domain/DocumentFolder.kt | 8 + .../documents/domain/DocumentTemplate.kt | 8 + .../feature/documents/domain/Pagination.kt | 33 ++ .../ui/common/DocumentErrorMessages.kt | 17 + .../feature/documents/ui/common/Markdown.kt | 69 ++++ .../documents/ui/common/MarkdownText.kt | 68 ++++ .../ui/editor/DocumentEditorScreen.kt | 223 +++++++++++ .../ui/editor/DocumentEditorViewModel.kt | 155 ++++++++ .../documents/ui/index/DocumentsScreen.kt | 321 ++++++++++++++++ .../documents/ui/index/DocumentsViewModel.kt | 191 ++++++++++ .../data/DefaultDocumentsRepositoryTest.kt | 219 +++++++++++ .../documents/data/DocumentMappersTest.kt | 90 +++++ .../feature/documents/data/FakeDaos.kt | 74 ++++ .../ui/DocumentEditorViewModelTest.kt | 134 +++++++ .../documents/ui/DocumentsViewModelTest.kt | 146 +++++++ .../documents/ui/FakeDocumentsRepository.kt | 112 ++++++ .../feature/documents/ui/MarkdownTest.kt | 52 +++ feature/lists/build.gradle.kts | 13 +- .../lists/src/androidTest/AndroidManifest.xml | 2 + .../lists/ui/detail/ListDetailScreenTest.kt | 84 +++++ .../feature/lists/ui/list/ListsScreenTest.kt | 81 ++++ feature/lists/src/main/AndroidManifest.xml | 2 + .../lists/data/DefaultListsRepository.kt | 188 +++++++++ .../android/feature/lists/data/ListMapper.kt | 51 +++ .../feature/lists/data/ListsRepository.kt | 52 +++ .../android/feature/lists/data/RowMapper.kt | 33 ++ .../feature/lists/data/SchemaMapper.kt | 112 ++++++ .../lists/data/local/CachedListEntity.kt | 19 + .../feature/lists/data/local/ListDao.kt | 37 ++ .../feature/lists/data/local/ListsDatabase.kt | 18 + .../feature/lists/data/remote/ListsApi.kt | 85 +++++ .../lists/data/remote/dto/FolderDtos.kt | 27 ++ .../feature/lists/data/remote/dto/ListDtos.kt | 81 ++++ .../feature/lists/data/remote/dto/RowDtos.kt | 42 +++ .../android/feature/lists/di/ListsModule.kt | 53 +++ .../feature/lists/domain/ListFolder.kt | 19 + .../android/feature/lists/domain/ListRow.kt | 22 ++ .../feature/lists/domain/ListSchema.kt | 59 +++ .../feature/lists/domain/ListSummary.kt | 16 + .../feature/lists/ui/ListsErrorMessages.kt | 17 + .../lists/ui/detail/ListDetailScreen.kt | 327 ++++++++++++++++ .../lists/ui/detail/ListDetailViewModel.kt | 142 +++++++ .../feature/lists/ui/detail/RowEditor.kt | 149 ++++++++ .../feature/lists/ui/list/ListsScreen.kt | 294 +++++++++++++++ .../feature/lists/ui/list/ListsViewModel.kt | 154 ++++++++ .../feature/lists/FakeListsRepository.kt | 90 +++++ .../lists/data/DefaultListsRepositoryTest.kt | 193 ++++++++++ .../feature/lists/data/ListMapperTest.kt | 35 ++ .../feature/lists/data/RowMapperTest.kt | 54 +++ .../feature/lists/data/SchemaMapperTest.kt | 96 +++++ .../ui/detail/ListDetailViewModelTest.kt | 136 +++++++ .../lists/ui/list/ListsViewModelTest.kt | 164 ++++++++ feature/messages/build.gradle.kts | 17 +- .../src/androidTest/AndroidManifest.xml | 3 + .../ui/feed/MessagesFeedScreenTest.kt | 89 +++++ .../data/DefaultMessagesRepository.kt | 185 +++++++++ .../messages/data/MessagesRepository.kt | 55 +++ .../feature/messages/data/local/MessageDao.kt | 41 ++ .../messages/data/local/MessageEntity.kt | 59 +++ .../messages/data/local/MessagesDatabase.kt | 17 + .../messages/data/remote/MessagesApi.kt | 58 +++ .../messages/data/remote/dto/MessageDto.kt | 54 +++ .../data/remote/dto/MessagesResponse.kt | 39 ++ .../feature/messages/di/MessagesModule.kt | 57 +++ .../feature/messages/domain/Message.kt | 29 ++ .../messages/ui/MessagesErrorMessages.kt | 18 + .../feature/messages/ui/RelativeTime.kt | 27 ++ .../messages/ui/components/MessageCard.kt | 198 ++++++++++ .../messages/ui/detail/MessageDetailScreen.kt | 283 ++++++++++++++ .../ui/detail/MessageDetailViewModel.kt | 132 +++++++ .../messages/ui/feed/MessagesFeedScreen.kt | 357 ++++++++++++++++++ .../messages/ui/feed/MessagesFeedViewModel.kt | 162 ++++++++ .../data/DefaultMessagesRepositoryTest.kt | 239 ++++++++++++ .../feature/messages/data/FakeMessageDao.kt | 53 +++ .../feature/messages/data/TestDoubles.kt | 81 ++++ .../data/remote/dto/MessageDtoMapperTest.kt | 73 ++++ .../messages/ui/FakeMessagesRepository.kt | 107 ++++++ .../feature/messages/ui/RelativeTimeTest.kt | 39 ++ .../ui/detail/MessageDetailViewModelTest.kt | 127 +++++++ .../ui/feed/MessagesFeedViewModelTest.kt | 179 +++++++++ 102 files changed, 8754 insertions(+), 45 deletions(-) create mode 100644 feature/documents/src/androidTest/AndroidManifest.xml create mode 100644 feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt create mode 100644 feature/documents/src/main/AndroidManifest.xml create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentTemplate.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/Markdown.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/MarkdownText.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/MarkdownTest.kt create mode 100644 feature/lists/src/androidTest/AndroidManifest.xml create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt create mode 100644 feature/lists/src/main/AndroidManifest.xml create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListDao.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFolder.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListMapperTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt create mode 100644 feature/messages/src/androidTest/AndroidManifest.xml create mode 100644 feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/di/MessagesModule.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MessagesErrorMessages.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTime.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TestDoubles.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTimeTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt diff --git a/.gitignore b/.gitignore index ee59126..939f556 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,9 @@ google-services.json # Android Profiling *.hprof + +# Agent git worktrees (temporary, isolated builds) +.claude/worktrees/ + +# Stale IDE/compiler bin outputs +**/bin/ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b03b7ea..ede4960 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -49,11 +49,15 @@ dependencies { // Features implementation(project(":feature:auth")) + implementation(project(":feature:lists")) + implementation(project(":feature:messages")) + implementation(project(":feature:documents")) // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.ui.tooling.preview) debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 7d32c50..4e5ef7f 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -1,46 +1,188 @@ package com.interlinedlist.android.navigation +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Forum +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument import com.interlinedlist.android.feature.auth.ui.LoginRoute +import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute +import com.interlinedlist.android.feature.documents.ui.index.DocumentsRoute +import com.interlinedlist.android.feature.lists.ui.detail.ListDetailRoute +import com.interlinedlist.android.feature.lists.ui.list.ListsRoute +import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute +import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.ui.home.HomeScreen -/** Navigation route keys. Expanded as feature phases add destinations. */ +/** Navigation route keys. */ object Routes { const val LOGIN = "login" - const val HOME = "home" + const val MAIN = "main" + + // Top-level tabs (bottom navigation). + const val LISTS = "lists" + const val MESSAGES = "messages" + const val DOCUMENTS = "documents" + const val ACCOUNT = "account" + + // Detail destinations. + const val LIST_DETAIL = "lists/{listId}" + const val MESSAGE_DETAIL = "messageDetail/{messageId}" + const val DOCUMENT_EDITOR = "documents/editor/{documentId}" + + fun listDetail(id: String) = "lists/$id" + fun messageDetail(id: String) = "messageDetail/$id" + fun documentEditor(id: String) = "documents/editor/$id" +} + +/** The four post-login home tabs shown in the bottom navigation bar. */ +private enum class HomeTab(val route: String, val label: String, val icon: ImageVector) { + Lists(Routes.LISTS, "Lists", Icons.AutoMirrored.Filled.List), + Messages(Routes.MESSAGES, "Messages", Icons.Filled.Forum), + Documents(Routes.DOCUMENTS, "Documents", Icons.Filled.Description), + Account(Routes.ACCOUNT, "Account", Icons.Filled.AccountCircle), } /** - * Top-level navigation host. Starts on the home screen when a session already - * exists, otherwise on login. Successful login replaces login in the back stack. + * Top-level navigation host. Starts on the signed-in shell when a session + * already exists, otherwise on login. Successful login replaces login in the + * back stack; sign-out returns to login. */ @Composable fun InterlinedListNavHost(startLoggedIn: Boolean) { val navController = rememberNavController() NavHost( navController = navController, - startDestination = if (startLoggedIn) Routes.HOME else Routes.LOGIN, + startDestination = if (startLoggedIn) Routes.MAIN else Routes.LOGIN, ) { composable(Routes.LOGIN) { LoginRoute( onLoggedIn = { - navController.navigate(Routes.HOME) { + navController.navigate(Routes.MAIN) { popUpTo(Routes.LOGIN) { inclusive = true } } }, ) } - composable(Routes.HOME) { - HomeScreen( + composable(Routes.MAIN) { + MainShell( onLoggedOut = { navController.navigate(Routes.LOGIN) { - popUpTo(Routes.HOME) { inclusive = true } + popUpTo(Routes.MAIN) { inclusive = true } } }, ) } } } + +/** + * Signed-in shell: a bottom navigation bar over the feature surfaces. The bar + * is shown on the four tab roots and hidden on detail screens, which carry + * their own back navigation. + */ +@Composable +private fun MainShell(onLoggedOut: () -> Unit) { + val tabNav = rememberNavController() + val backStackEntry by tabNav.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val onTabRoot = HomeTab.entries.any { it.route == currentRoute } + + Scaffold( + bottomBar = { + if (onTabRoot) { + NavigationBar { + val hierarchy = backStackEntry?.destination?.hierarchy + HomeTab.entries.forEach { tab -> + NavigationBarItem( + selected = hierarchy?.any { it.route == tab.route } == true, + onClick = { + tabNav.navigate(tab.route) { + // Reselecting a tab returns to its root and keeps + // per-tab state, mirroring standard bottom-nav UX. + popUpTo(tabNav.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(tab.icon, contentDescription = tab.label) }, + label = { Text(tab.label) }, + ) + } + } + } + }, + ) { padding -> + NavHost( + navController = tabNav, + startDestination = Routes.LISTS, + modifier = Modifier.padding(padding), + ) { + composable(Routes.LISTS) { + ListsRoute(onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }) + } + composable( + Routes.LIST_DETAIL, + arguments = listOf(navArgument("listId") { type = NavType.StringType }), + ) { + ListDetailRoute( + onBack = { tabNav.popBackStack() }, + onListDeleted = { tabNav.popBackStack() }, + ) + } + + composable(Routes.MESSAGES) { + MessagesRoute(onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }) + } + composable( + Routes.MESSAGE_DETAIL, + arguments = listOf(navArgument("messageId") { type = NavType.StringType }), + ) { + MessageDetailRoute( + onBack = { tabNav.popBackStack() }, + onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + ) + } + + composable(Routes.DOCUMENTS) { + DocumentsRoute( + onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, + onSearch = { /* Dedicated search screen deferred; see roadmap. */ }, + ) + } + composable( + Routes.DOCUMENT_EDITOR, + arguments = listOf(navArgument("documentId") { type = NavType.StringType }), + ) { + DocumentEditorRoute( + onBack = { tabNav.popBackStack() }, + onDeleted = { tabNav.popBackStack() }, + ) + } + + composable(Routes.ACCOUNT) { + HomeScreen(onLoggedOut = onLoggedOut) + } + } + } +} diff --git a/feature/documents/build.gradle.kts b/feature/documents/build.gradle.kts index 3420c31..c22ee03 100644 --- a/feature/documents/build.gradle.kts +++ b/feature/documents/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(project(":core:network")) implementation(project(":core:datastore")) - // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) @@ -41,35 +40,33 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.compose) - // DI + // This module owns its own Room cache (see DocumentsDatabase) — it must not + // reuse the shared :core:database, so it pulls Room in directly. + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) - // Networking (Retrofit annotations + serialization for DTOs) + implementation(libs.coil.compose) + implementation(libs.retrofit.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.coroutines.core) - - // Feature-local Room cache (offline-first) - implementation(libs.room.runtime) - implementation(libs.room.ktx) - implementation(libs.room.paging) - ksp(libs.room.compiler) - implementation(libs.androidx.paging.runtime) - implementation(libs.androidx.paging.compose) - - // Images - implementation(libs.coil.compose) // Unit tests testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) // Instrumented / UI tests androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/feature/documents/src/androidTest/AndroidManifest.xml b/feature/documents/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/documents/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt new file mode 100644 index 0000000..0328b14 --- /dev/null +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt @@ -0,0 +1,99 @@ +package com.interlinedlist.android.feature.documents.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.ui.index.DocumentsScreen +import com.interlinedlist.android.feature.documents.ui.index.DocumentsTestTags +import com.interlinedlist.android.feature.documents.ui.index.DocumentsUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DocumentsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent( + state: DocumentsUiState, + onOpenDocument: (String) -> Unit = {}, + onCreateDocument: () -> Unit = {}, + onSelectFolder: (String?) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + DocumentsScreen( + state = state, + onSelectFolder = onSelectFolder, + onOpenDocument = onOpenDocument, + onCreateDocument = onCreateDocument, + onLoadMore = {}, + onSearch = {}, + ) + } + } + } + + @Test + fun emptyState_isShown_whenNoDocuments() { + setContent(DocumentsUiState(isLoading = false)) + composeRule.onNodeWithTag(DocumentsTestTags.EMPTY).assertIsDisplayed() + } + + @Test + fun documentRows_areRendered_andClickable() { + var openedId: String? = null + setContent( + state = DocumentsUiState( + documents = listOf( + Document("1", "Grocery list", null, "Milk, eggs", null, null, false, null), + ), + ), + onOpenDocument = { openedId = it }, + ) + + composeRule.onNodeWithTag(DocumentsTestTags.row("1")).assertIsDisplayed().performClick() + assert(openedId == "1") + } + + @Test + fun createFab_invokesCallback() { + var created = false + setContent(state = DocumentsUiState(isLoading = false), onCreateDocument = { created = true }) + composeRule.onNodeWithTag(DocumentsTestTags.CREATE_FAB).performClick() + assert(created) + } + + @Test + fun folderChip_selectsFolder() { + var selected: String? = "sentinel" + setContent( + state = DocumentsUiState( + documents = listOf(Document("1", "Doc", null, "", "f1", "Work", false, null)), + folders = listOf(DocumentFolder("f1", "Work", null)), + ), + onSelectFolder = { selected = it }, + ) + composeRule.onNodeWithTag(DocumentsTestTags.folderChip("f1")).performClick() + assert(selected == "f1") + } + + @Test + fun subscriptionGate_isShown_whenRequired() { + setContent( + DocumentsUiState( + isLoading = false, + subscriptionRequired = true, + errorMessage = "Documents require an active subscription.", + ), + ) + composeRule.onNodeWithTag(DocumentsTestTags.SUBSCRIPTION_GATE).assertIsDisplayed() + } +} diff --git a/feature/documents/src/main/AndroidManifest.xml b/feature/documents/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/documents/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt new file mode 100644 index 0000000..947fc66 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt @@ -0,0 +1,256 @@ +package com.interlinedlist.android.feature.documents.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.documents.data.local.DocumentDao +import com.interlinedlist.android.feature.documents.data.local.FolderDao +import com.interlinedlist.android.feature.documents.data.local.toDomain +import com.interlinedlist.android.feature.documents.data.local.toEntity +import com.interlinedlist.android.feature.documents.data.mapper.toDomain +import com.interlinedlist.android.feature.documents.data.mapper.toPaginationDomain +import com.interlinedlist.android.feature.documents.data.mapper.toTemplate +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.domain.Pagination +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +/** + * Room-backed, offline-first implementation. Reads observe Room; refreshes and + * mutations call the API and write through to Room so the UI updates reactively. + */ +class DefaultDocumentsRepository @Inject constructor( + private val api: DocumentsApi, + private val documentDao: DocumentDao, + private val folderDao: FolderDao, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : DocumentsRepository { + + override fun observeDocuments(folderId: String?): Flow> { + val source = if (folderId == null) { + documentDao.observeRootDocuments() + } else { + documentDao.observeDocumentsInFolder(folderId) + } + return source.map { rows -> rows.map { it.toDomain() } } + } + + override fun observeDocument(id: String): Flow = + documentDao.observeDocument(id).map { it?.toDomain() } + + override fun observeFolders(): Flow> = + folderDao.observeFolders().map { rows -> rows.map { it.toDomain() } } + + override suspend fun refreshDocuments(folderId: String?): ApiResult = + withContext(dispatchers.io) { + val result = safeApiCall(json) { + if (folderId == null) { + api.getDocuments(limit = Pagination.DEFAULT_LIMIT, offset = 0) + } else { + api.getFolderDocuments(folderId, limit = Pagination.DEFAULT_LIMIT, offset = 0) + } + } + when (result) { + is ApiResult.Success -> { + // Replace the listing for this scope so server-side deletions drop out. + if (folderId == null) documentDao.clearRoot() else documentDao.clearFolder(folderId) + ApiResult.Success(cachePage(result.data, folderId, startOrder = 0)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun loadMore( + folderId: String?, + pagination: Pagination, + ): ApiResult = withContext(dispatchers.io) { + if (!pagination.hasMore) return@withContext ApiResult.Success(pagination) + val nextOffset = pagination.nextOffset + val result = safeApiCall(json) { + if (folderId == null) { + api.getDocuments(limit = pagination.limit, offset = nextOffset) + } else { + api.getFolderDocuments(folderId, limit = pagination.limit, offset = nextOffset) + } + } + when (result) { + is ApiResult.Success -> + ApiResult.Success(cachePage(result.data, folderId, startOrder = documentDao.maxSortOrder() + 1)) + is ApiResult.Failure -> result + } + } + + override suspend fun refreshDocument(id: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getDocument(id).documentOrSelf }) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.NotFound("Document not found")) + val domain = dto.toDomain() + documentDao.upsert(domain.toEntity(sortOrder = existingOrder(id))) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun createDocument( + title: String, + content: String, + isPublic: Boolean, + ): ApiResult = withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.createDocument(CreateDocumentRequest(title, content, isPublic)).documentOrSelf + } + when (result) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.Unknown("Document create returned no body")) + val domain = dto.toDomain() + documentDao.upsert(domain.toEntity(sortOrder = documentDao.maxSortOrder() + 1)) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun updateDocument( + id: String, + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + ): ApiResult = withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.updateDocument( + id, + UpdateDocumentRequest(title = title, content = content, isPublic = isPublic, folderId = folderId), + ).documentOrSelf + } + when (result) { + is ApiResult.Success -> { + // Fall back to the locally-known values if the server echoes a thin body. + val domain = result.data?.toDomain()?.let { + it.copy(content = it.content ?: content) + } ?: Document( + id = id, + title = title, + content = content, + snippet = Document.snippetFrom(content), + folderId = folderId, + folderName = null, + isPublic = isPublic, + updatedAt = null, + ) + documentDao.upsert(domain.toEntity(sortOrder = existingOrder(id))) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun deleteDocument(id: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.deleteDocument(id) }) { + is ApiResult.Success -> { + documentDao.deleteById(id) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun refreshFolders(): ApiResult> = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getFolders() }) { + is ApiResult.Success -> { + val folders = result.data.foldersOrEmpty.map { it.toDomain() } + folderDao.clear() + folderDao.upsertAll(folders.mapIndexed { i, f -> f.toEntity(sortOrder = i) }) + ApiResult.Success(folders) + } + is ApiResult.Failure -> result + } + } + + override suspend fun createFolder(name: String, parentId: String?): ApiResult = + withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.createFolder(CreateFolderRequest(name, parentId)).folderOrSelf + } + when (result) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.Unknown("Folder create returned no body")) + val domain = dto.toDomain() + folderDao.upsert(domain.toEntity(sortOrder = Int.MAX_VALUE)) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun getTemplates(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getTemplates() } + .map { response -> response.documentsOrEmpty.map { it.toTemplate() } } + } + + override suspend fun createFromTemplate( + templateId: String, + targetFolderId: String?, + ): ApiResult = withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.createFromTemplate(FromTemplateRequest(templateId, targetFolderId)).documentOrSelf + } + when (result) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.Unknown("Template create returned no body")) + val domain = dto.toDomain() + documentDao.upsert(domain.toEntity(sortOrder = documentDao.maxSortOrder() + 1)) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun searchDocuments(query: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.searchDocuments(query) } + .map { response -> response.documentsOrEmpty.map { it.toDomain() } } + } + + /** Upserts a page of documents starting at [startOrder]; returns its paging metadata. */ + private suspend fun cachePage( + response: DocumentListResponse, + folderId: String?, + startOrder: Int, + ): Pagination { + val documents = response.documentsOrEmpty.map { it.toDomain() } + val entities = documents.mapIndexed { i, doc -> + // Root refreshes clear the table, so folderId on a root doc is honoured as-is. + doc.copy(folderId = doc.folderId ?: folderId) + .toEntity(sortOrder = startOrder + i) + } + documentDao.upsertAll(entities) + return response.pagination.toPaginationDomain(fallbackCount = documents.size) + } + + private suspend fun existingOrder(id: String): Int = + documentDao.getDocument(id)?.sortOrder ?: (documentDao.maxSortOrder() + 1) +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt new file mode 100644 index 0000000..f06377a --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt @@ -0,0 +1,68 @@ +package com.interlinedlist.android.feature.documents.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.domain.Pagination +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to documents and folders. List/detail reads are served as + * [Flow]s from Room (the source of truth); [refreshDocuments]/[loadMore] pull from + * the API and upsert into the cache. Mutations write through to the API and update + * the cache so the observing UI reflects the change immediately. + */ +interface DocumentsRepository { + + /** Root-level documents (no folder), or a folder's contents when [folderId] is set. */ + fun observeDocuments(folderId: String?): Flow> + + /** A single cached document (null until first loaded). */ + fun observeDocument(id: String): Flow + + /** All cached folders. */ + fun observeFolders(): Flow> + + /** + * Fetches the first page for [folderId] from the API and replaces the cached + * listing for that scope. Returns paging metadata for load-more. + */ + suspend fun refreshDocuments(folderId: String?): ApiResult + + /** Appends the next page for [folderId] into the cache. */ + suspend fun loadMore(folderId: String?, pagination: Pagination): ApiResult + + /** Fetches a document detail (with body) and caches it. */ + suspend fun refreshDocument(id: String): ApiResult + + suspend fun createDocument( + title: String, + content: String, + isPublic: Boolean, + ): ApiResult + + suspend fun updateDocument( + id: String, + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + ): ApiResult + + suspend fun deleteDocument(id: String): ApiResult + + suspend fun refreshFolders(): ApiResult> + + suspend fun createFolder(name: String, parentId: String?): ApiResult + + suspend fun getTemplates(): ApiResult> + + suspend fun createFromTemplate( + templateId: String, + targetFolderId: String?, + ): ApiResult + + /** One-shot search against the API (not cached). */ + suspend fun searchDocuments(query: String): ApiResult> +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt new file mode 100644 index 0000000..144bd7a --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt @@ -0,0 +1,46 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface DocumentDao { + + /** Emits root-level documents (no folder), ordered by their server sequence. */ + @Query("SELECT * FROM document WHERE folderId IS NULL ORDER BY sortOrder ASC") + fun observeRootDocuments(): Flow> + + /** Emits documents in a given folder, ordered by their server sequence. */ + @Query("SELECT * FROM document WHERE folderId = :folderId ORDER BY sortOrder ASC") + fun observeDocumentsInFolder(folderId: String): Flow> + + /** Emits a single document (or null) and re-emits on every change. */ + @Query("SELECT * FROM document WHERE id = :id") + fun observeDocument(id: String): Flow + + @Query("SELECT * FROM document WHERE id = :id") + suspend fun getDocument(id: String): DocumentEntity? + + @Query("SELECT COALESCE(MAX(sortOrder), -1) FROM document") + suspend fun maxSortOrder(): Int + + @Upsert + suspend fun upsertAll(documents: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(document: DocumentEntity) + + @Query("DELETE FROM document WHERE id = :id") + suspend fun deleteById(id: String) + + /** Clears the root listing before a full refresh so removals propagate. */ + @Query("DELETE FROM document WHERE folderId IS NULL") + suspend fun clearRoot() + + @Query("DELETE FROM document WHERE folderId = :folderId") + suspend fun clearFolder(folderId: String) +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt new file mode 100644 index 0000000..b53303c --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.interlinedlist.android.feature.documents.domain.Document + +/** + * Locally cached document. This module's Room database is the source of truth for + * the index; [content] is null for rows only ever seen in a list response and is + * populated once the detail is fetched. [sortOrder] preserves server ordering so + * the index renders in the same sequence the API returned. + */ +@Entity(tableName = "document") +data class DocumentEntity( + @PrimaryKey val id: String, + val title: String, + val content: String?, + val snippet: String, + val folderId: String?, + val folderName: String?, + val isPublic: Boolean, + val updatedAt: String?, + val sortOrder: Int, +) + +fun DocumentEntity.toDomain(): Document = Document( + id = id, + title = title, + content = content, + snippet = snippet, + folderId = folderId, + folderName = folderName, + isPublic = isPublic, + updatedAt = updatedAt, +) + +/** + * Maps a domain document to its cache row. Preserves an existing cached body when + * the incoming document (e.g. from a list refresh) has none, so we never drop a + * body we already fetched. + */ +fun Document.toEntity(sortOrder: Int, existingContent: String? = null): DocumentEntity = + DocumentEntity( + id = id, + title = title, + content = content ?: existingContent, + snippet = snippet, + folderId = folderId, + folderName = folderName, + isPublic = isPublic, + updatedAt = updatedAt, + sortOrder = sortOrder, + ) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt new file mode 100644 index 0000000..1696f26 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * This feature's own Room cache — kept separate from the shared + * `InterlinedListDatabase` so the module stays self-contained (see the + * engineering brief). Disposable during development via destructive migration. + */ +@Database( + entities = [DocumentEntity::class, FolderEntity::class], + version = 1, + exportSchema = false, +) +abstract class DocumentsDatabase : RoomDatabase() { + abstract fun documentDao(): DocumentDao + abstract fun folderDao(): FolderDao +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt new file mode 100644 index 0000000..d1edbb8 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface FolderDao { + + @Query("SELECT * FROM folder ORDER BY sortOrder ASC") + fun observeFolders(): Flow> + + @Upsert + suspend fun upsertAll(folders: List) + + @Upsert + suspend fun upsert(folder: FolderEntity) + + @Query("DELETE FROM folder") + suspend fun clear() +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt new file mode 100644 index 0000000..03c1b53 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.interlinedlist.android.feature.documents.domain.DocumentFolder + +/** Locally cached document folder. */ +@Entity(tableName = "folder") +data class FolderEntity( + @PrimaryKey val id: String, + val name: String, + val parentId: String?, + val sortOrder: Int, +) + +fun FolderEntity.toDomain(): DocumentFolder = DocumentFolder( + id = id, + name = name, + parentId = parentId, +) + +fun DocumentFolder.toEntity(sortOrder: Int): FolderEntity = FolderEntity( + id = id, + name = name, + parentId = parentId, + sortOrder = sortOrder, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt new file mode 100644 index 0000000..415be4b --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.documents.data.mapper + +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentDto +import com.interlinedlist.android.feature.documents.data.remote.dto.FolderDto +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.domain.Pagination +import com.interlinedlist.android.feature.documents.data.remote.dto.PaginationDto + +/** + * Maps a document wire model into the domain [Document]. The snippet prefers a + * server-provided preview, then any inline body, so index rows still show context + * even when the list response omits the full [content]. + */ +fun DocumentDto.toDomain(): Document { + val body = content + val preview = snippet?.takeIf { it.isNotBlank() } + ?: excerpt?.takeIf { it.isNotBlank() } + ?: Document.snippetFrom(body) + return Document( + id = id, + title = title?.takeIf { it.isNotBlank() } ?: "Untitled", + content = body, + snippet = preview, + folderId = folderId, + folderName = folderName, + isPublic = isPublic, + updatedAt = updatedAt ?: createdAt, + ) +} + +/** Maps a document into a lightweight [DocumentTemplate] for the picker. */ +fun DocumentDto.toTemplate(): DocumentTemplate = DocumentTemplate( + id = id, + title = title?.takeIf { it.isNotBlank() } ?: "Untitled template", + snippet = snippet?.takeIf { it.isNotBlank() } + ?: excerpt?.takeIf { it.isNotBlank() } + ?: Document.snippetFrom(content), +) + +fun FolderDto.toDomain(): DocumentFolder = DocumentFolder( + id = id, + name = name?.takeIf { it.isNotBlank() } ?: "Untitled folder", + parentId = parentId, +) + +fun PaginationDto?.toPaginationDomain(fallbackCount: Int): Pagination = + if (this == null) { + Pagination.single(fallbackCount) + } else { + Pagination(total = total, limit = limit, offset = offset, hasMore = hasMore) + } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt new file mode 100644 index 0000000..36ab608 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt @@ -0,0 +1,71 @@ +package com.interlinedlist.android.feature.documents.data.remote + +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.FolderListResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.FolderResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the Documents endpoints. The shared Retrofit instance + * already carries the base URL and Bearer token, so these calls are authed. + */ +interface DocumentsApi { + + @GET("api/documents") + suspend fun getDocuments( + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): DocumentListResponse + + @POST("api/documents") + suspend fun createDocument(@Body body: CreateDocumentRequest): DocumentResponse + + @GET("api/documents/{id}") + suspend fun getDocument(@Path("id") id: String): DocumentResponse + + @PUT("api/documents/{id}") + suspend fun updateDocument( + @Path("id") id: String, + @Body body: UpdateDocumentRequest, + ): DocumentResponse + + @DELETE("api/documents/{id}") + suspend fun deleteDocument(@Path("id") id: String) + + @GET("api/documents/search") + suspend fun searchDocuments( + @Query("q") query: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): DocumentListResponse + + @GET("api/documents/folders") + suspend fun getFolders(): FolderListResponse + + @POST("api/documents/folders") + suspend fun createFolder(@Body body: CreateFolderRequest): FolderResponse + + @GET("api/documents/folders/{id}/documents") + suspend fun getFolderDocuments( + @Path("id") folderId: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): DocumentListResponse + + @GET("api/documents/templates") + suspend fun getTemplates(): DocumentListResponse + + @POST("api/documents/from-template") + suspend fun createFromTemplate(@Body body: FromTemplateRequest): DocumentResponse +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt new file mode 100644 index 0000000..1cc95f1 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire model for a document. The list endpoints may omit [content]; the detail + * endpoint includes it. All fields beyond [id] are defaulted so the DTO tolerates + * the shape variation across list/detail responses (with `ignoreUnknownKeys`). + */ +@Serializable +data class DocumentDto( + val id: String, + val title: String? = null, + val content: String? = null, + // Some responses expose a server-computed preview; we fall back to content. + val snippet: String? = null, + val excerpt: String? = null, + val folderId: String? = null, + val folderName: String? = null, + val isPublic: Boolean = false, + val updatedAt: String? = null, + val createdAt: String? = null, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt new file mode 100644 index 0000000..440f732 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** Body for `POST /api/documents` and `POST /api/documents/folders/{id}/documents`. */ +@Serializable +data class CreateDocumentRequest( + val title: String, + val content: String, + val isPublic: Boolean = false, +) + +/** Body for `PUT`/`PATCH /api/documents/{id}`. Null fields are left unchanged. */ +@Serializable +data class UpdateDocumentRequest( + val title: String? = null, + val content: String? = null, + val isPublic: Boolean? = null, + val folderId: String? = null, +) + +/** Body for `POST /api/documents/from-template`. */ +@Serializable +data class FromTemplateRequest( + val templateDocumentId: String, + val targetFolderId: String? = null, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt new file mode 100644 index 0000000..6d14ad8 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt @@ -0,0 +1,62 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** Offset/limit paging envelope shared by the list responses. */ +@Serializable +data class PaginationDto( + val total: Int = 0, + val limit: Int = 20, + val offset: Int = 0, + val hasMore: Boolean = false, +) + +/** + * `GET /api/documents` (and folder listings / search). Documents may arrive under + * `data` (the documented list envelope) or, in some responses, `documents`; both + * are accepted and merged by [documentsOrEmpty]. + */ +@Serializable +data class DocumentListResponse( + val data: List? = null, + val documents: List? = null, + val pagination: PaginationDto? = null, +) { + val documentsOrEmpty: List get() = data ?: documents ?: emptyList() +} + +/** + * A single document, returned either bare or wrapped in `{ "document": ... }`. + * [documentOrSelf] resolves whichever form the endpoint used. + */ +@Serializable +data class DocumentResponse( + val document: DocumentDto? = null, + val id: String? = null, + val title: String? = null, + val content: String? = null, + val snippet: String? = null, + val excerpt: String? = null, + val folderId: String? = null, + val folderName: String? = null, + val isPublic: Boolean = false, + val updatedAt: String? = null, + val createdAt: String? = null, +) { + /** The document payload, whether wrapped or inlined at the top level. */ + val documentOrSelf: DocumentDto? + get() = document ?: id?.let { + DocumentDto( + id = it, + title = title, + content = content, + snippet = snippet, + excerpt = excerpt, + folderId = folderId, + folderName = folderName, + isPublic = isPublic, + updatedAt = updatedAt, + createdAt = createdAt, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt new file mode 100644 index 0000000..3772966 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** Wire model for a document folder. */ +@Serializable +data class FolderDto( + val id: String, + val name: String? = null, + val parentId: String? = null, +) + +/** `GET /api/documents/folders`; folders may arrive under `data` or `folders`. */ +@Serializable +data class FolderListResponse( + val data: List? = null, + val folders: List? = null, +) { + val foldersOrEmpty: List get() = data ?: folders ?: emptyList() +} + +/** A single folder, returned either bare or wrapped in `{ "folder": ... }`. */ +@Serializable +data class FolderResponse( + val folder: FolderDto? = null, + val id: String? = null, + val name: String? = null, + val parentId: String? = null, +) { + val folderOrSelf: FolderDto? + get() = folder ?: id?.let { FolderDto(id = it, name = name, parentId = parentId) } +} + +/** Body for `POST /api/documents/folders`. */ +@Serializable +data class CreateFolderRequest( + val name: String, + val parentId: String? = null, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt new file mode 100644 index 0000000..d9f0505 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt @@ -0,0 +1,56 @@ +package com.interlinedlist.android.feature.documents.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.feature.documents.data.DefaultDocumentsRepository +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.data.local.DocumentDao +import com.interlinedlist.android.feature.documents.data.local.DocumentsDatabase +import com.interlinedlist.android.feature.documents.data.local.FolderDao +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its default implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DocumentsRepositoryModule { + + @Binds + @Singleton + abstract fun bindDocumentsRepository(impl: DefaultDocumentsRepository): DocumentsRepository +} + +/** Provides this feature's API, its own Room database, and DAOs. */ +@Module +@InstallIn(SingletonComponent::class) +object DocumentsDataModule { + + @Provides + @Singleton + fun provideDocumentsApi(retrofit: Retrofit): DocumentsApi = + retrofit.create(DocumentsApi::class.java) + + @Provides + @Singleton + fun provideDocumentsDatabase(@ApplicationContext context: Context): DocumentsDatabase = + Room.databaseBuilder( + context, + DocumentsDatabase::class.java, + "interlinedlist-documents.db", + ) + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideDocumentDao(db: DocumentsDatabase): DocumentDao = db.documentDao() + + @Provides + fun provideFolderDao(db: DocumentsDatabase): FolderDao = db.folderDao() +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt new file mode 100644 index 0000000..24f68a1 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * A markdown document owned by the current user. [snippet] is a short preview of + * [content] shown in the index; when a document is loaded from a list endpoint + * (which omits the body) [content] is null until the detail is fetched. + */ +data class Document( + val id: String, + val title: String, + val content: String?, + val snippet: String, + val folderId: String?, + val folderName: String?, + val isPublic: Boolean, + val updatedAt: String?, +) { + companion object { + /** Longest preview we keep for the index snippet, in characters. */ + const val SNIPPET_MAX = 140 + + /** Derives a plain-text-ish snippet from a markdown body. */ + fun snippetFrom(content: String?): String { + if (content.isNullOrBlank()) return "" + val collapsed = content.replace(Regex("\\s+"), " ").trim() + return if (collapsed.length <= SNIPPET_MAX) collapsed + else collapsed.take(SNIPPET_MAX).trimEnd() + "…" + } + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt new file mode 100644 index 0000000..6a712b7 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt @@ -0,0 +1,8 @@ +package com.interlinedlist.android.feature.documents.domain + +/** A folder used to organise documents. Root folders have a null [parentId]. */ +data class DocumentFolder( + val id: String, + val name: String, + val parentId: String?, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentTemplate.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentTemplate.kt new file mode 100644 index 0000000..3cdffd3 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentTemplate.kt @@ -0,0 +1,8 @@ +package com.interlinedlist.android.feature.documents.domain + +/** A document that lives in the `_templates` folder and can seed a new document. */ +data class DocumentTemplate( + val id: String, + val title: String, + val snippet: String, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt new file mode 100644 index 0000000..60e6189 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * Offset/limit paging metadata returned alongside list responses. [hasMore] + * drives the index's load-more affordance. + */ +data class Pagination( + val total: Int, + val limit: Int, + val offset: Int, + val hasMore: Boolean, +) { + /** Offset to request for the next page. */ + val nextOffset: Int get() = offset + limit + + companion object { + const val DEFAULT_LIMIT = 20 + + /** A single-page result covering [count] items (used for local-only reads). */ + fun single(count: Int) = Pagination( + total = count, + limit = if (count == 0) DEFAULT_LIMIT else count, + offset = 0, + hasMore = false, + ) + } +} + +/** A page of items plus its paging metadata. */ +data class Page( + val items: List, + val pagination: Pagination, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt new file mode 100644 index 0000000..30e8bd3 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/DocumentErrorMessages.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.documents.ui.common + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the docs UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.SubscriptionRequired -> message ?: "Documents require an active subscription." + is AppError.NotFound -> message ?: "That document could not be found." + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} + +/** Whether the error is the subscriber-only gate, so the UI can show an upsell. */ +val AppError.isSubscriptionGate: Boolean get() = this is AppError.SubscriptionRequired diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/Markdown.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/Markdown.kt new file mode 100644 index 0000000..8d9aa9c --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/Markdown.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.documents.ui.common + +/** + * A tiny, dependency-free markdown model covering the subset we render in the + * preview: headings (`#`..`###`), unordered (`-`/`*`) and ordered (`1.`) list + * items, and paragraphs — each with inline `**bold**` spans. Parsing is a pure + * function so it can be unit-tested without Compose. + */ +sealed interface MarkdownBlock { + /** [level] is 1..3 for `#`..`###`. */ + data class Heading(val level: Int, val spans: List) : MarkdownBlock + data class BulletItem(val spans: List) : MarkdownBlock + data class NumberedItem(val number: Int, val spans: List) : MarkdownBlock + data class Paragraph(val spans: List) : MarkdownBlock +} + +/** An inline run of text, optionally bold. */ +data class InlineSpan(val text: String, val bold: Boolean = false) + +/** Parses [markdown] into a flat list of blocks. Blank lines are skipped. */ +fun parseMarkdown(markdown: String): List { + val blocks = mutableListOf() + for (rawLine in markdown.lines()) { + val line = rawLine.trimEnd() + if (line.isBlank()) continue + + val heading = Regex("^(#{1,3})\\s+(.*)$").find(line) + if (heading != null) { + val level = heading.groupValues[1].length + blocks += MarkdownBlock.Heading(level, parseInline(heading.groupValues[2])) + continue + } + + val bullet = Regex("^\\s*[-*]\\s+(.*)$").find(line) + if (bullet != null) { + blocks += MarkdownBlock.BulletItem(parseInline(bullet.groupValues[1])) + continue + } + + val numbered = Regex("^\\s*(\\d+)\\.\\s+(.*)$").find(line) + if (numbered != null) { + val number = numbered.groupValues[1].toIntOrNull() ?: 1 + blocks += MarkdownBlock.NumberedItem(number, parseInline(numbered.groupValues[2])) + continue + } + + blocks += MarkdownBlock.Paragraph(parseInline(line)) + } + return blocks +} + +/** Splits a line into alternating normal/bold runs on `**...**` markers. */ +internal fun parseInline(text: String): List { + if (!text.contains("**")) return listOf(InlineSpan(text)) + val spans = mutableListOf() + val matcher = Regex("\\*\\*(.+?)\\*\\*") + var lastEnd = 0 + for (match in matcher.findAll(text)) { + if (match.range.first > lastEnd) { + spans += InlineSpan(text.substring(lastEnd, match.range.first)) + } + spans += InlineSpan(match.groupValues[1], bold = true) + lastEnd = match.range.last + 1 + } + if (lastEnd < text.length) { + spans += InlineSpan(text.substring(lastEnd)) + } + return spans.ifEmpty { listOf(InlineSpan(text)) } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/MarkdownText.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/MarkdownText.kt new file mode 100644 index 0000000..e0dcc34 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/common/MarkdownText.kt @@ -0,0 +1,68 @@ +package com.interlinedlist.android.feature.documents.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp + +/** Renders the [parseMarkdown] block model into simple styled Compose text. */ +@Composable +fun MarkdownText( + markdown: String, + modifier: Modifier = Modifier, +) { + val blocks = parseMarkdown(markdown) + Column( + modifier = modifier.verticalScroll(rememberScrollState()), + ) { + blocks.forEach { block -> + when (block) { + is MarkdownBlock.Heading -> Text( + text = block.spans.toAnnotated(), + style = when (block.level) { + 1 -> MaterialTheme.typography.headlineSmall + 2 -> MaterialTheme.typography.titleLarge + else -> MaterialTheme.typography.titleMedium + }, + modifier = Modifier.padding(vertical = 4.dp), + ) + + is MarkdownBlock.BulletItem -> Row(modifier = Modifier.padding(vertical = 2.dp)) { + Text("• ", style = MaterialTheme.typography.bodyLarge) + Text(block.spans.toAnnotated(), style = MaterialTheme.typography.bodyLarge) + } + + is MarkdownBlock.NumberedItem -> Row(modifier = Modifier.padding(vertical = 2.dp)) { + Text("${block.number}. ", style = MaterialTheme.typography.bodyLarge) + Text(block.spans.toAnnotated(), style = MaterialTheme.typography.bodyLarge) + } + + is MarkdownBlock.Paragraph -> Text( + text = block.spans.toAnnotated(), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(vertical = 4.dp), + ) + } + } + } +} + +private fun List.toAnnotated() = buildAnnotatedString { + this@toAnnotated.forEach { span -> + if (span.bold) { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(span.text) } + } else { + append(span.text) + } + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt new file mode 100644 index 0000000..084033e --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt @@ -0,0 +1,223 @@ +package com.interlinedlist.android.feature.documents.ui.editor + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.ui.common.MarkdownText + +/** Stable test tags for the editor. */ +object DocumentEditorTestTags { + const val TITLE = "editorTitle" + const val BODY = "editorBody" + const val PREVIEW = "editorPreview" + const val SAVE = "editorSave" + const val DELETE = "editorDelete" + const val TOGGLE_PREVIEW = "editorTogglePreview" + const val PROGRESS = "editorProgress" + const val ERROR = "editorError" +} + +/** + * Hilt-wired editor entry. Reads its target document id from the `documentId` nav + * arg via SavedStateHandle. [onBack] navigates up; [onDeleted] navigates back to + * the index after a successful delete. + */ +@Composable +fun DocumentEditorRoute( + onBack: () -> Unit, + onDeleted: () -> Unit, + modifier: Modifier = Modifier, + viewModel: DocumentEditorViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + DocumentEditorScreen( + state = state, + onTitleChange = viewModel::onTitleChange, + onContentChange = viewModel::onContentChange, + onTogglePreview = viewModel::togglePreview, + onSave = { viewModel.save() }, + onDelete = { viewModel.delete(onDeleted) }, + onBack = onBack, + modifier = modifier, + ) +} + +/** Stateless editor: a title field, a markdown body field, and a preview toggle. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun DocumentEditorScreen( + state: DocumentEditorUiState, + onTitleChange: (String) -> Unit, + onContentChange: (String) -> Unit, + onTogglePreview: () -> Unit, + onSave: () -> Unit, + onDelete: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = state.title.ifBlank { "Untitled" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton( + onClick = onTogglePreview, + modifier = Modifier.testTag(DocumentEditorTestTags.TOGGLE_PREVIEW), + ) { + if (state.isPreview) { + Icon(Icons.Default.Edit, contentDescription = "Edit") + } else { + Icon(Icons.Default.Visibility, contentDescription = "Preview") + } + } + IconButton( + onClick = onDelete, + enabled = !state.isSaving, + modifier = Modifier.testTag(DocumentEditorTestTags.DELETE), + ) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + TextButton( + onClick = onSave, + enabled = state.canSave, + modifier = Modifier.testTag(DocumentEditorTestTags.SAVE), + ) { + if (state.isSaving) { + CircularProgressIndicator( + Modifier.size(18.dp).testTag(DocumentEditorTestTags.PROGRESS), + strokeWidth = 2.dp, + ) + } else { + Text("Save") + } + } + }, + ) + }, + ) { padding -> + if (state.isLoading && state.content.isBlank() && state.title.isBlank()) { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return@Scaffold + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .padding(horizontal = 16.dp), + ) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .testTag(DocumentEditorTestTags.ERROR), + ) + } + + OutlinedTextField( + value = state.title, + onValueChange = onTitleChange, + label = { Text("Title") }, + singleLine = true, + enabled = !state.isPreview, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .testTag(DocumentEditorTestTags.TITLE), + ) + + if (state.isPreview) { + MarkdownText( + markdown = state.content.ifBlank { "_Nothing to preview yet._" }, + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp) + .testTag(DocumentEditorTestTags.PREVIEW), + ) + } else { + OutlinedTextField( + value = state.content, + onValueChange = onContentChange, + label = { Text("Markdown") }, + modifier = Modifier + .fillMaxSize() + .padding(vertical = 8.dp) + .verticalScroll(rememberScrollState()) + .testTag(DocumentEditorTestTags.BODY), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentEditorScreenPreview() { + InterlinedListTheme { + DocumentEditorScreen( + state = DocumentEditorUiState( + documentId = "1", + title = "Meeting notes", + content = "# Agenda\n- Roadmap\n- **Budget**\n\nMore details here.", + isLoading = false, + hasUnsavedChanges = true, + ), + onTitleChange = {}, + onContentChange = {}, + onTogglePreview = {}, + onSave = {}, + onDelete = {}, + onBack = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt new file mode 100644 index 0000000..9163053 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt @@ -0,0 +1,155 @@ +package com.interlinedlist.android.feature.documents.ui.editor + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the document detail / editor. */ +data class DocumentEditorUiState( + val documentId: String = "", + val title: String = "", + val content: String = "", + val isPublic: Boolean = false, + val folderId: String? = null, + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val isPreview: Boolean = false, + val hasUnsavedChanges: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, +) { + val canSave: Boolean get() = hasUnsavedChanges && !isSaving && !isLoading +} + +/** Nav arg key the editor reads its target document id from. */ +const val DOCUMENT_ID_ARG = "documentId" + +@HiltViewModel +class DocumentEditorViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val documentId: String = checkNotNull(savedStateHandle[DOCUMENT_ID_ARG]) { + "DocumentEditorViewModel requires a '$DOCUMENT_ID_ARG' nav arg" + } + + private val _uiState = MutableStateFlow(DocumentEditorUiState(documentId = documentId)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + observeCached() + refresh() + } + + /** Seeds the editor from the Room cache so it renders instantly offline. */ + private fun observeCached() { + viewModelScope.launch { + repository.observeDocument(documentId).collect { cached -> + if (cached != null && !_uiState.value.hasUnsavedChanges) { + _uiState.update { + it.copy( + title = cached.title, + content = cached.content ?: it.content, + isPublic = cached.isPublic, + folderId = cached.folderId, + ) + } + } + } + } + } + + /** Fetches the full document (with body) from the API. */ + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshDocument(documentId)) { + is ApiResult.Success -> _uiState.update { + // Don't clobber in-progress edits with the server copy. + if (it.hasUnsavedChanges) { + it.copy(isLoading = false) + } else { + it.copy( + title = result.data.title, + content = result.data.content ?: "", + isPublic = result.data.isPublic, + folderId = result.data.folderId, + isLoading = false, + subscriptionRequired = false, + ) + } + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoading = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun onTitleChange(value: String) = + _uiState.update { it.copy(title = value, hasUnsavedChanges = true, errorMessage = null) } + + fun onContentChange(value: String) = + _uiState.update { it.copy(content = value, hasUnsavedChanges = true, errorMessage = null) } + + fun togglePreview() = _uiState.update { it.copy(isPreview = !it.isPreview) } + + /** Persists edits; invokes [onSaved] on success. */ + fun save(onSaved: () -> Unit = {}) { + val state = _uiState.value + if (!state.canSave) return + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + val result = repository.updateDocument( + id = documentId, + title = state.title.trim().ifBlank { "Untitled" }, + content = state.content, + isPublic = state.isPublic, + folderId = state.folderId, + ) + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false, hasUnsavedChanges = false) } + onSaved() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Deletes the document; invokes [onDeleted] on success. */ + fun delete(onDeleted: () -> Unit) { + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.deleteDocument(documentId)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false) } + onDeleted() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt new file mode 100644 index 0000000..c18629f --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt @@ -0,0 +1,321 @@ +package com.interlinedlist.android.feature.documents.ui.index + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder + +/** Stable test tags for the documents index. */ +object DocumentsTestTags { + const val LIST = "documentsList" + const val CREATE_FAB = "documentsCreateFab" + const val SEARCH = "documentsSearch" + const val EMPTY = "documentsEmpty" + const val PROGRESS = "documentsProgress" + const val ERROR = "documentsError" + const val SUBSCRIPTION_GATE = "documentsSubscriptionGate" + fun row(id: String) = "documentRow_$id" + fun folderChip(id: String?) = "folderChip_${id ?: "root"}" +} + +/** + * Hilt-wired index entry. [onOpenDocument] navigates to the editor for a document + * id; [onCreateDocument] is invoked with the id of a freshly created document so + * the caller can open it; [onSearch] opens the search screen. + */ +@Composable +fun DocumentsRoute( + onOpenDocument: (String) -> Unit, + onSearch: () -> Unit, + modifier: Modifier = Modifier, + viewModel: DocumentsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + DocumentsScreen( + state = state, + onSelectFolder = viewModel::selectFolder, + onOpenDocument = onOpenDocument, + onCreateDocument = { viewModel.createDocument(title = "Untitled", onCreated = onOpenDocument) }, + onLoadMore = viewModel::loadMore, + onSearch = onSearch, + modifier = modifier, + ) +} + +/** Stateless documents index — folder filter chips + a scrollable list. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun DocumentsScreen( + state: DocumentsUiState, + onSelectFolder: (String?) -> Unit, + onOpenDocument: (String) -> Unit, + onCreateDocument: () -> Unit, + onLoadMore: () -> Unit, + onSearch: () -> Unit, + modifier: Modifier = Modifier, +) { + val listState = rememberLazyListState() + + // Trigger load-more when the last item scrolls into view. + LaunchedEffect(listState, state.hasMore, state.documents.size) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { lastVisible -> + if (state.hasMore && !state.isLoadingMore && + lastVisible != null && lastVisible >= state.documents.size - 1 + ) { + onLoadMore() + } + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Documents") }, + actions = { + IconButton(onClick = onSearch, modifier = Modifier.testTag(DocumentsTestTags.SEARCH)) { + Icon(Icons.Default.Search, contentDescription = "Search documents") + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onCreateDocument, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("New") }, + modifier = Modifier.testTag(DocumentsTestTags.CREATE_FAB), + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> SubscriptionGate( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(DocumentsTestTags.PROGRESS)) + } + + else -> DocumentsContent( + state = state, + listState = listState, + onSelectFolder = onSelectFolder, + onOpenDocument = onOpenDocument, + contentPadding = padding, + ) + } + } +} + +@Composable +private fun DocumentsContent( + state: DocumentsUiState, + listState: androidx.compose.foundation.lazy.LazyListState, + onSelectFolder: (String?) -> Unit, + onOpenDocument: (String) -> Unit, + contentPadding: PaddingValues, +) { + Column(Modifier.fillMaxSize().padding(contentPadding)) { + if (state.folders.isNotEmpty()) { + FolderChips( + folders = state.folders, + selectedFolderId = state.selectedFolderId, + onSelectFolder = onSelectFolder, + ) + } + + if (state.errorMessage != null && !state.subscriptionRequired) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(DocumentsTestTags.ERROR), + ) + } + + if (state.isEmpty) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "No documents yet. Tap New to create one.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(DocumentsTestTags.EMPTY), + ) + } + } else { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize().testTag(DocumentsTestTags.LIST), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + items(state.documents, key = { it.id }) { document -> + DocumentRow(document = document, onClick = { onOpenDocument(document.id) }) + } + if (state.isLoadingMore) { + item { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.size(24.dp)) + } + } + } + } + } + } +} + +@Composable +private fun FolderChips( + folders: List, + selectedFolderId: String?, + onSelectFolder: (String?) -> Unit, +) { + androidx.compose.foundation.lazy.LazyRow( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + ) { + item { + FilterChip( + selected = selectedFolderId == null, + onClick = { onSelectFolder(null) }, + label = { Text("All") }, + modifier = Modifier.testTag(DocumentsTestTags.folderChip(null)), + ) + } + items(folders, key = { it.id }) { folder -> + FilterChip( + selected = selectedFolderId == folder.id, + onClick = { onSelectFolder(folder.id) }, + label = { Text(folder.name) }, + modifier = Modifier.testTag(DocumentsTestTags.folderChip(folder.id)), + ) + } + } +} + +@Composable +private fun DocumentRow(document: Document, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentsTestTags.row(document.id)) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = document.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (document.snippet.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = document.snippet, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (document.folderName != null || document.updatedAt != null) { + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + document.folderName?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + } + document.updatedAt?.let { + Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } +} + +@Composable +private fun SubscriptionGate(message: String?, modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp).testTag(DocumentsTestTags.SUBSCRIPTION_GATE), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Subscriber feature", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Documents require an active subscription.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentsScreenPreview() { + InterlinedListTheme { + DocumentsScreen( + state = DocumentsUiState( + documents = listOf( + Document("1", "Grocery list", null, "Milk, eggs, bread", null, null, false, "2h ago"), + Document("2", "Meeting notes", null, "Discussed Q3 roadmap", "f1", "Work", false, "1d ago"), + ), + folders = listOf(DocumentFolder("f1", "Work", null)), + ), + onSelectFolder = {}, + onOpenDocument = {}, + onCreateDocument = {}, + onLoadMore = {}, + onSearch = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt new file mode 100644 index 0000000..957664a --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt @@ -0,0 +1,191 @@ +package com.interlinedlist.android.feature.documents.ui.index + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.domain.Pagination +import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the documents index. */ +data class DocumentsUiState( + val documents: List = emptyList(), + val folders: List = emptyList(), + val selectedFolderId: String? = null, + val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, + val isRefreshing: Boolean = false, + val hasMore: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + // Template picker state. + val templates: List = emptyList(), + val isLoadingTemplates: Boolean = false, +) { + val isEmpty: Boolean get() = documents.isEmpty() && !isLoading +} + +@HiltViewModel +class DocumentsViewModel @Inject constructor( + private val repository: DocumentsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(DocumentsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + /** Paging cursor for the current folder scope; advanced by [loadMore]. */ + private var pagination: Pagination = Pagination.single(0) + private var observeJob: Job? = null + + init { + observeDocuments(folderId = null) + observeFolders() + refresh() + } + + /** Re-points the Room observer at the given scope (root when null). */ + private fun observeDocuments(folderId: String?) { + observeJob?.cancel() + observeJob = viewModelScope.launch { + repository.observeDocuments(folderId).collect { docs -> + _uiState.update { it.copy(documents = docs) } + } + } + } + + private fun observeFolders() { + viewModelScope.launch { + repository.observeFolders().collect { folders -> + _uiState.update { it.copy(folders = folders) } + } + } + } + + /** Switches the visible folder and refreshes it from the API. */ + fun selectFolder(folderId: String?) { + if (folderId == _uiState.value.selectedFolderId) return + _uiState.update { it.copy(selectedFolderId = folderId, documents = emptyList()) } + observeDocuments(folderId) + refresh() + } + + /** Pulls the first page for the current scope from the API. */ + fun refresh() { + val folderId = _uiState.value.selectedFolderId + _uiState.update { it.copy(isRefreshing = true, isLoading = it.documents.isEmpty(), errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshDocuments(folderId)) { + is ApiResult.Success -> { + pagination = result.data + _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + hasMore = result.data.hasMore, + subscriptionRequired = false, + ) + } + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + refreshFolders() + } + } + + private suspend fun refreshFolders() { + // Best-effort; folder failures don't block the document list. + repository.refreshFolders() + } + + /** Appends the next page when [DocumentsUiState.hasMore]. */ + fun loadMore() { + val state = _uiState.value + if (!state.hasMore || state.isLoadingMore) return + _uiState.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + when (val result = repository.loadMore(state.selectedFolderId, pagination)) { + is ApiResult.Success -> { + pagination = result.data + _uiState.update { it.copy(isLoadingMore = false, hasMore = result.data.hasMore) } + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingMore = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Creates a document in the current scope; invokes [onCreated] with its id. */ + fun createDocument(title: String, onCreated: (String) -> Unit) { + val trimmed = title.trim().ifBlank { "Untitled" } + viewModelScope.launch { + when (val result = repository.createDocument(trimmed, content = "", isPublic = false)) { + is ApiResult.Success -> onCreated(result.data.id) + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Loads templates for the picker (lazily, when the sheet opens). */ + fun loadTemplates() { + _uiState.update { it.copy(isLoadingTemplates = true) } + viewModelScope.launch { + when (val result = repository.getTemplates()) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingTemplates = false, templates = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingTemplates = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun createFromTemplate(templateId: String, onCreated: (String) -> Unit) { + val targetFolderId = _uiState.value.selectedFolderId + viewModelScope.launch { + when (val result = repository.createFromTemplate(templateId, targetFolderId)) { + is ApiResult.Success -> onCreated(result.data.id) + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun createFolder(name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + when (val result = repository.createFolder(trimmed, parentId = null)) { + is ApiResult.Success -> Unit // Observed folders flow updates the UI. + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt new file mode 100644 index 0000000..2679c1b --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt @@ -0,0 +1,219 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDocumentsRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: DocumentsApi + private lateinit var documentDao: FakeDocumentDao + private lateinit var folderDao: FakeFolderDao + private lateinit var repository: DefaultDocumentsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DocumentsApi::class.java) + documentDao = FakeDocumentDao() + folderDao = FakeFolderDao() + repository = DefaultDocumentsRepository(api, documentDao, folderDao, json, dispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `refreshDocuments caches the page and reports pagination`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "data": [ + { "id": "1", "title": "First", "content": "hello world", "isPublic": false }, + { "id": "2", "title": "Second", "content": "more text" } + ], + "pagination": { "total": 40, "limit": 20, "offset": 0, "hasMore": true } + } + """.trimIndent(), + ), + ) + + val result = repository.refreshDocuments(folderId = null) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val pagination = (result as ApiResult.Success).data + assertThat(pagination.hasMore).isTrue() + assertThat(pagination.total).isEqualTo(40) + + val cached = repository.observeDocuments(null).first() + assertThat(cached.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(cached.first().title).isEqualTo("First") + } + + @Test + fun `refreshDocuments maps a 403 subscription error to SubscriptionRequired`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription." }"""), + ) + + val result = repository.refreshDocuments(folderId = null) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(com.interlinedlist.android.core.common.result.AppError.SubscriptionRequired::class.java) + } + + @Test + fun `createDocument posts the body and caches the created document`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "document": { "id": "new1", "title": "Fresh", "content": "body", "isPublic": false } }""", + ), + ) + + val result = repository.createDocument("Fresh", "body", isPublic = false) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.id).isEqualTo("new1") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents") + assertThat(recorded.body.readUtf8()).contains("\"title\":\"Fresh\"") + + assertThat(documentDao.getDocument("new1")).isNotNull() + } + + @Test + fun `getDocument detail parses a bare body and caches it`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "id": "d9", "title": "Detail", "content": "# Heading\n- item" }""", + ), + ) + + val result = repository.refreshDocument("d9") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val doc = (result as ApiResult.Success).data + assertThat(doc.title).isEqualTo("Detail") + assertThat(doc.content).contains("# Heading") + assertThat(documentDao.getDocument("d9")?.content).contains("# Heading") + } + + @Test + fun `updateDocument issues a PUT and updates the cache`() = runTest(testDispatcher) { + // Seed a cached copy first. + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "id": "d1", "title": "Old", "content": "old" }""")) + repository.refreshDocument("d1") + server.takeRequest() + + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{ "id": "d1", "title": "New", "content": "new body" }"""), + ) + + val result = repository.updateDocument("d1", "New", "new body", isPublic = true, folderId = null) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PUT") + assertThat(recorded.path).isEqualTo("/api/documents/d1") + assertThat(documentDao.getDocument("d1")?.title).isEqualTo("New") + assertThat(documentDao.getDocument("d1")?.content).isEqualTo("new body") + } + + @Test + fun `deleteDocument issues a DELETE and removes the cached row`() = runTest(testDispatcher) { + // Seed the cache directly through a create. + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "id": "gone", "title": "T", "content": "c" }""")) + repository.createDocument("T", "c", isPublic = false) + server.takeRequest() + assertThat(documentDao.getDocument("gone")).isNotNull() + + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + val result = repository.deleteDocument("gone") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(documentDao.getDocument("gone")).isNull() + } + + @Test + fun `refreshFolders caches folders from the data envelope`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "data": [ { "id": "f1", "name": "Work" }, { "id": "f2", "name": "Personal" } ] }""", + ), + ) + + val result = repository.refreshFolders() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(folderDao.snapshot().map { it.name }).containsExactly("Work", "Personal").inOrder() + } + + @Test + fun `searchDocuments passes the query and maps results`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "data": [ { "id": "s1", "title": "Match", "content": "found" } ] }""", + ), + ) + + val result = repository.searchDocuments("found") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.single().title).isEqualTo("Match") + val recorded = server.takeRequest() + assertThat(recorded.path).isEqualTo("/api/documents/search?q=found") + } + + @Test + fun `getTemplates maps template documents`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "data": [ { "id": "t1", "title": "Recipe", "content": "Ingredients" } ] }""", + ), + ) + + val result = repository.getTemplates() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.single().title).isEqualTo("Recipe") + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt new file mode 100644 index 0000000..ad10a0e --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt @@ -0,0 +1,90 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.documents.data.mapper.toDomain +import com.interlinedlist.android.feature.documents.data.mapper.toPaginationDomain +import com.interlinedlist.android.feature.documents.data.mapper.toTemplate +import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentDto +import com.interlinedlist.android.feature.documents.data.remote.dto.FolderDto +import com.interlinedlist.android.feature.documents.data.remote.dto.PaginationDto +import com.interlinedlist.android.feature.documents.domain.Document +import org.junit.Test + +class DocumentMappersTest { + + @Test + fun `document maps fields and derives snippet from content when none supplied`() { + val dto = DocumentDto( + id = "d1", + title = "Notes", + content = "Line one\nLine two", + folderId = "f1", + folderName = "Work", + isPublic = true, + updatedAt = "2026-07-18", + ) + + val doc = dto.toDomain() + + assertThat(doc.id).isEqualTo("d1") + assertThat(doc.title).isEqualTo("Notes") + assertThat(doc.content).isEqualTo("Line one\nLine two") + assertThat(doc.snippet).isEqualTo("Line one Line two") + assertThat(doc.folderId).isEqualTo("f1") + assertThat(doc.folderName).isEqualTo("Work") + assertThat(doc.isPublic).isTrue() + assertThat(doc.updatedAt).isEqualTo("2026-07-18") + } + + @Test + fun `document prefers server snippet over derived preview`() { + val dto = DocumentDto(id = "d1", title = "T", content = "long body", snippet = "server preview") + assertThat(dto.toDomain().snippet).isEqualTo("server preview") + } + + @Test + fun `blank title falls back to Untitled and missing updatedAt uses createdAt`() { + val dto = DocumentDto(id = "d1", title = " ", createdAt = "2026-01-01") + val doc = dto.toDomain() + assertThat(doc.title).isEqualTo("Untitled") + assertThat(doc.updatedAt).isEqualTo("2026-01-01") + } + + @Test + fun `snippet truncates long content with an ellipsis`() { + val long = "x".repeat(Document.SNIPPET_MAX + 50) + val snippet = Document.snippetFrom(long) + assertThat(snippet.length).isEqualTo(Document.SNIPPET_MAX + 1) // +1 for the ellipsis char + assertThat(snippet.endsWith("…")).isTrue() + } + + @Test + fun `template maps title and snippet`() { + val dto = DocumentDto(id = "t1", title = "Recipe", content = "Ingredients...") + val template = dto.toTemplate() + assertThat(template.id).isEqualTo("t1") + assertThat(template.title).isEqualTo("Recipe") + assertThat(template.snippet).isEqualTo("Ingredients...") + } + + @Test + fun `folder maps fields with fallback name`() { + assertThat(FolderDto(id = "f1", name = "Work", parentId = "p1").toDomain().name).isEqualTo("Work") + assertThat(FolderDto(id = "f1", name = null).toDomain().name).isEqualTo("Untitled folder") + } + + @Test + fun `null pagination falls back to a single page over the item count`() { + val page = (null as PaginationDto?).toPaginationDomain(fallbackCount = 3) + assertThat(page.hasMore).isFalse() + assertThat(page.total).isEqualTo(3) + assertThat(page.offset).isEqualTo(0) + } + + @Test + fun `pagination maps through and computes next offset`() { + val page = PaginationDto(total = 40, limit = 20, offset = 0, hasMore = true).toPaginationDomain(0) + assertThat(page.hasMore).isTrue() + assertThat(page.nextOffset).isEqualTo(20) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt new file mode 100644 index 0000000..707576a --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt @@ -0,0 +1,74 @@ +package com.interlinedlist.android.feature.documents.data + +import com.interlinedlist.android.feature.documents.data.local.DocumentDao +import com.interlinedlist.android.feature.documents.data.local.DocumentEntity +import com.interlinedlist.android.feature.documents.data.local.FolderDao +import com.interlinedlist.android.feature.documents.data.local.FolderEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory [DocumentDao] mirroring the real DAO's query semantics, so repository + * tests can assert cache writes without Room/Robolectric. + */ +class FakeDocumentDao : DocumentDao { + private val rows = MutableStateFlow>(emptyList()) + + fun snapshot(): List = rows.value.sortedBy { it.sortOrder } + + override fun observeRootDocuments(): Flow> = + rows.map { list -> list.filter { it.folderId == null }.sortedBy { it.sortOrder } } + + override fun observeDocumentsInFolder(folderId: String): Flow> = + rows.map { list -> list.filter { it.folderId == folderId }.sortedBy { it.sortOrder } } + + override fun observeDocument(id: String): Flow = + rows.map { list -> list.firstOrNull { it.id == id } } + + override suspend fun getDocument(id: String): DocumentEntity? = + rows.value.firstOrNull { it.id == id } + + override suspend fun maxSortOrder(): Int = rows.value.maxOfOrNull { it.sortOrder } ?: -1 + + override suspend fun upsertAll(documents: List) { + documents.forEach { upsert(it) } + } + + override suspend fun upsert(document: DocumentEntity) { + rows.value = rows.value.filterNot { it.id == document.id } + document + } + + override suspend fun deleteById(id: String) { + rows.value = rows.value.filterNot { it.id == id } + } + + override suspend fun clearRoot() { + rows.value = rows.value.filterNot { it.folderId == null } + } + + override suspend fun clearFolder(folderId: String) { + rows.value = rows.value.filterNot { it.folderId == folderId } + } +} + +class FakeFolderDao : FolderDao { + private val rows = MutableStateFlow>(emptyList()) + + fun snapshot(): List = rows.value.sortedBy { it.sortOrder } + + override fun observeFolders(): Flow> = + rows.map { list -> list.sortedBy { it.sortOrder } } + + override suspend fun upsertAll(folders: List) { + folders.forEach { upsert(it) } + } + + override suspend fun upsert(folder: FolderEntity) { + rows.value = rows.value.filterNot { it.id == folder.id } + folder + } + + override suspend fun clear() { + rows.value = emptyList() + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt new file mode 100644 index 0000000..03589f8 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt @@ -0,0 +1,134 @@ +package com.interlinedlist.android.feature.documents.ui + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.ui.editor.DOCUMENT_ID_ARG +import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentEditorViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeDocumentsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeDocumentsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(id: String = "d1") = + DocumentEditorViewModel(repo, SavedStateHandle(mapOf(DOCUMENT_ID_ARG to id))) + + @Test + fun `loads the document body from the refresh result`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success( + testDocument("d1", title = "Notes", content = "# Body"), + ) + + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.title).isEqualTo("Notes") + assertThat(vm.uiState.value.content).isEqualTo("# Body") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `editing marks unsaved changes and enables save`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "orig")) + val vm = viewModel() + advanceUntilIdle() + assertThat(vm.uiState.value.canSave).isFalse() + + vm.onContentChange("edited body") + + assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() + assertThat(vm.uiState.value.canSave).isTrue() + } + + @Test + fun `save persists edits and clears the unsaved flag`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", title = "T", content = "orig")) + repo.updateResult = ApiResult.Success(testDocument("d1", title = "T", content = "edited")) + val vm = viewModel() + advanceUntilIdle() + + vm.onContentChange("edited") + var saved = false + vm.save { saved = true } + advanceUntilIdle() + + assertThat(saved).isTrue() + assertThat(vm.uiState.value.hasUnsavedChanges).isFalse() + assertThat(repo.lastUpdate?.content).isEqualTo("edited") + } + + @Test + fun `save failure surfaces an error and keeps the unsaved flag`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "orig")) + repo.updateResult = ApiResult.Failure(AppError.Server("boom")) + val vm = viewModel() + advanceUntilIdle() + + vm.onContentChange("edited") + vm.save() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("InterlinedList is having trouble right now. Try again shortly.") + assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() + } + + @Test + fun `delete invokes onDeleted on success`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1")) + repo.deleteResult = ApiResult.Success(Unit) + val vm = viewModel() + advanceUntilIdle() + + var deleted = false + vm.delete { deleted = true } + advanceUntilIdle() + + assertThat(deleted).isTrue() + } + + @Test + fun `toggle preview flips the preview flag`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1")) + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isPreview).isFalse() + vm.togglePreview() + assertThat(vm.uiState.value.isPreview).isTrue() + } + + @Test + fun `refresh does not overwrite in-progress edits`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "server")) + val vm = viewModel() + advanceUntilIdle() + + vm.onContentChange("my local edit") + vm.refresh() + advanceUntilIdle() + + assertThat(vm.uiState.value.content).isEqualTo("my local edit") + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt new file mode 100644 index 0000000..caf8f1b --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt @@ -0,0 +1,146 @@ +package com.interlinedlist.android.feature.documents.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.Pagination +import com.interlinedlist.android.feature.documents.ui.index.DocumentsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeDocumentsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeDocumentsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `emits cached documents from the room flow`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(Pagination.single(1)) + repo.rootDocuments.value = listOf(testDocument("1"), testDocument("2")) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.documents.map { it.id }).containsExactly("1", "2") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + assertThat(vm.uiState.value.subscriptionRequired).isFalse() + } + + @Test + fun `subscription gate is flagged on a subscription-required failure`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribe to use documents")) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.errorMessage).isEqualTo("Subscribe to use documents") + } + + @Test + fun `selecting a folder switches the observed source and refreshes it`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(Pagination.single(0)) + repo.rootDocuments.value = listOf(testDocument("root")) + repo.folderDocuments.value = listOf(testDocument("infolder", folderId = "f1")) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + assertThat(vm.uiState.value.documents.map { it.id }).containsExactly("root") + + vm.selectFolder("f1") + advanceUntilIdle() + + assertThat(vm.uiState.value.selectedFolderId).isEqualTo("f1") + assertThat(vm.uiState.value.documents.map { it.id }).containsExactly("infolder") + assertThat(repo.lastSelectedFolderId).isEqualTo("f1") + } + + @Test + fun `hasMore drives load-more which appends the next page`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(Pagination(total = 40, limit = 20, offset = 0, hasMore = true)) + repo.loadMoreResult = ApiResult.Success(Pagination(total = 40, limit = 20, offset = 20, hasMore = false)) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + assertThat(vm.uiState.value.hasMore).isTrue() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(1) + assertThat(vm.uiState.value.hasMore).isFalse() + assertThat(vm.uiState.value.isLoadingMore).isFalse() + } + + @Test + fun `load-more is skipped when there is no next page`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(Pagination.single(2)) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(0) + } + + @Test + fun `create document invokes onCreated with the new id`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(Pagination.single(0)) + repo.createResult = ApiResult.Success(testDocument("new-id", title = "Untitled")) + + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + + var createdId: String? = null + vm.createDocument(title = "Untitled") { createdId = it } + advanceUntilIdle() + + assertThat(createdId).isEqualTo("new-id") + assertThat(repo.lastCreateTitle).isEqualTo("Untitled") + } + + @Test + fun `folders flow is reflected in state via Turbine`() = runTest(dispatcher) { + repo.refreshResult = ApiResult.Success(Pagination.single(0)) + val vm = DocumentsViewModel(repo) + advanceUntilIdle() + + vm.uiState.test { + assertThat(awaitItem().folders).isEmpty() + repo.foldersFlow.value = listOf(DocumentFolder("f1", "Work", null)) + assertThat(awaitItem().folders.single().name).isEqualTo("Work") + } + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt new file mode 100644 index 0000000..61939a3 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt @@ -0,0 +1,112 @@ +package com.interlinedlist.android.feature.documents.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.domain.Pagination +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory [DocumentsRepository] for ViewModel tests. Backed by simple + * StateFlows so tests can observe the same reactive behaviour as Room without a + * device. Failure modes are injectable per operation. + */ +class FakeDocumentsRepository : DocumentsRepository { + + val rootDocuments = MutableStateFlow>(emptyList()) + val folderDocuments = MutableStateFlow>(emptyList()) + val foldersFlow = MutableStateFlow>(emptyList()) + val documentFlow = MutableStateFlow(null) + + var refreshResult: ApiResult = ApiResult.Success(Pagination.single(0)) + var loadMoreResult: ApiResult = ApiResult.Success(Pagination.single(0)) + var refreshDocumentResult: ApiResult? = null + var createResult: ApiResult? = null + var updateResult: ApiResult? = null + var deleteResult: ApiResult = ApiResult.Success(Unit) + var templatesResult: ApiResult> = ApiResult.Success(emptyList()) + var fromTemplateResult: ApiResult? = null + var createFolderResult: ApiResult? = null + var searchResult: ApiResult> = ApiResult.Success(emptyList()) + + var refreshCount = 0 + var loadMoreCount = 0 + var lastSelectedFolderId: String? = null + var lastCreateTitle: String? = null + var lastUpdate: Update? = null + + data class Update(val id: String, val title: String, val content: String, val isPublic: Boolean, val folderId: String?) + + override fun observeDocuments(folderId: String?) = + if (folderId == null) rootDocuments.map { it } else folderDocuments.map { it } + + override fun observeDocument(id: String) = documentFlow.map { it } + + override fun observeFolders() = foldersFlow.map { it } + + override suspend fun refreshDocuments(folderId: String?): ApiResult { + refreshCount++ + lastSelectedFolderId = folderId + return refreshResult + } + + override suspend fun loadMore(folderId: String?, pagination: Pagination): ApiResult { + loadMoreCount++ + return loadMoreResult + } + + override suspend fun refreshDocument(id: String): ApiResult = + refreshDocumentResult ?: ApiResult.Failure(AppError.NotFound("not set")) + + override suspend fun createDocument(title: String, content: String, isPublic: Boolean): ApiResult { + lastCreateTitle = title + return createResult ?: ApiResult.Failure(AppError.Unknown("not set")) + } + + override suspend fun updateDocument( + id: String, + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + ): ApiResult { + lastUpdate = Update(id, title, content, isPublic, folderId) + return updateResult ?: ApiResult.Failure(AppError.Unknown("not set")) + } + + override suspend fun deleteDocument(id: String): ApiResult = deleteResult + + override suspend fun refreshFolders(): ApiResult> = + ApiResult.Success(foldersFlow.value) + + override suspend fun createFolder(name: String, parentId: String?): ApiResult = + createFolderResult ?: ApiResult.Failure(AppError.Unknown("not set")) + + override suspend fun getTemplates(): ApiResult> = templatesResult + + override suspend fun createFromTemplate(templateId: String, targetFolderId: String?): ApiResult = + fromTemplateResult ?: ApiResult.Failure(AppError.Unknown("not set")) + + override suspend fun searchDocuments(query: String): ApiResult> = searchResult +} + +/** Shorthand for building a domain document in tests. */ +fun testDocument( + id: String, + title: String = "Doc $id", + content: String? = null, + folderId: String? = null, +) = Document( + id = id, + title = title, + content = content, + snippet = content?.take(20) ?: "", + folderId = folderId, + folderName = null, + isPublic = false, + updatedAt = null, +) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/MarkdownTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/MarkdownTest.kt new file mode 100644 index 0000000..f0fe2ce --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/MarkdownTest.kt @@ -0,0 +1,52 @@ +package com.interlinedlist.android.feature.documents.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.documents.ui.common.MarkdownBlock +import com.interlinedlist.android.feature.documents.ui.common.parseInline +import com.interlinedlist.android.feature.documents.ui.common.parseMarkdown +import org.junit.Test + +class MarkdownTest { + + @Test + fun `parses headings at levels one through three`() { + val blocks = parseMarkdown("# H1\n## H2\n### H3") + assertThat(blocks).hasSize(3) + assertThat((blocks[0] as MarkdownBlock.Heading).level).isEqualTo(1) + assertThat((blocks[1] as MarkdownBlock.Heading).level).isEqualTo(2) + assertThat((blocks[2] as MarkdownBlock.Heading).level).isEqualTo(3) + } + + @Test + fun `parses bullet and numbered list items`() { + val blocks = parseMarkdown("- first\n* second\n1. third") + assertThat(blocks[0]).isInstanceOf(MarkdownBlock.BulletItem::class.java) + assertThat(blocks[1]).isInstanceOf(MarkdownBlock.BulletItem::class.java) + val numbered = blocks[2] as MarkdownBlock.NumberedItem + assertThat(numbered.number).isEqualTo(1) + } + + @Test + fun `blank lines are skipped and plain lines become paragraphs`() { + val blocks = parseMarkdown("hello\n\n\nworld") + assertThat(blocks).hasSize(2) + assertThat(blocks[0]).isInstanceOf(MarkdownBlock.Paragraph::class.java) + } + + @Test + fun `inline parsing splits bold runs from surrounding text`() { + val spans = parseInline("a **bold** c") + assertThat(spans).hasSize(3) + assertThat(spans[0].bold).isFalse() + assertThat(spans[1].bold).isTrue() + assertThat(spans[1].text).isEqualTo("bold") + assertThat(spans[2].bold).isFalse() + } + + @Test + fun `inline parsing returns a single span when there is no bold`() { + val spans = parseInline("plain text") + assertThat(spans).hasSize(1) + assertThat(spans[0].bold).isFalse() + } +} diff --git a/feature/lists/build.gradle.kts b/feature/lists/build.gradle.kts index 05dc647..d8dab42 100644 --- a/feature/lists/build.gradle.kts +++ b/feature/lists/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(project(":core:network")) implementation(project(":core:datastore")) - // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) @@ -41,27 +40,20 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.compose) - // DI implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) - // Networking (Retrofit annotations + serialization for DTOs) + // Self-contained data layer: this module owns its Retrofit interface + Room DB. implementation(libs.retrofit.core) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.core) - - // Feature-local Room cache (offline-first) implementation(libs.room.runtime) implementation(libs.room.ktx) - implementation(libs.room.paging) ksp(libs.room.compiler) - implementation(libs.androidx.paging.runtime) - implementation(libs.androidx.paging.compose) - // Images + // Images (schema fields may carry image URLs). implementation(libs.coil.compose) // Unit tests @@ -70,6 +62,7 @@ dependencies { testImplementation(libs.turbine) testImplementation(libs.truth) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.kotlinx.serialization) // Instrumented / UI tests androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/feature/lists/src/androidTest/AndroidManifest.xml b/feature/lists/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/lists/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt new file mode 100644 index 0000000..2abcb4f --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt @@ -0,0 +1,84 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.SchemaField +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies the detail screen renders rows generically against the schema: the + * column labels and cell values come from the (dynamic) schema + row data, not + * from hardcoded columns. + */ +@RunWith(AndroidJUnit4::class) +class ListDetailScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private val schema = ListSchema( + listOf( + SchemaField("title", "Title", FieldType.TEXT), + SchemaField("pages", "Pages", FieldType.NUMBER), + ), + ) + + private fun setScreen(state: ListDetailUiState) { + composeRule.setContent { + InterlinedListTheme { + ListDetailScreen( + state = state, + onBack = {}, + onAddRow = {}, + onEditRow = {}, + onDeleteRow = {}, + onDeleteList = {}, + ) + } + } + } + + @Test + fun rendersSchemaColumnsAndRowValues() { + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 1, null, false, null), + schema = schema, + rows = listOf(ListRow("r1", mapOf("title" to "Dune", "pages" to "412"))), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ListDetailTestTags.row("r1")).assertIsDisplayed() + // Column labels are derived from the schema. + composeRule.onNodeWithText("Title").assertIsDisplayed() + composeRule.onNodeWithText("Pages").assertIsDisplayed() + // Values are projected from the dynamic row data. + composeRule.onNodeWithText("Dune").assertIsDisplayed() + composeRule.onNodeWithText("412").assertIsDisplayed() + } + + @Test + fun showsEmptyState_whenNoRows() { + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ListDetailTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt new file mode 100644 index 0000000..20c2ad5 --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt @@ -0,0 +1,81 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListSummary +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Compose UI coverage for the stateless [ListsScreen]. Runs on-device; the + * orchestrator executes instrumented tests after merge (no emulator in the + * worktree), so this is written to compile and be correct. + */ +@RunWith(AndroidJUnit4::class) +class ListsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen( + state: ListsUiState, + onOpenList: (String) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + ListsScreen( + state = state, + onOpenList = onOpenList, + onSearchQueryChange = {}, + onLoadMore = {}, + onCreateList = {}, + ) + } + } + } + + @Test + fun rendersCards_andOpensListOnTap() { + var opened: String? = null + setScreen( + state = ListsUiState( + lists = listOf( + ListSummary("1", "Reading list", "Books", 3, null, false, null), + ListSummary("2", "Trips", null, 0, null, true, null), + ), + isRefreshing = false, + ), + onOpenList = { opened = it }, + ) + + composeRule.onNodeWithTag(ListsTestTags.row("1")).assertIsDisplayed() + composeRule.onNodeWithTag(ListsTestTags.row("2")).assertIsDisplayed() + + composeRule.onNodeWithTag(ListsTestTags.row("1")).performClick() + assert(opened == "1") + } + + @Test + fun showsEmptyState_whenNoLists() { + setScreen(state = ListsUiState(lists = emptyList(), isRefreshing = false)) + + composeRule.onNodeWithTag(ListsTestTags.EMPTY).assertIsDisplayed() + } + + @Test + fun showsSubscriptionGate_whenRequired() { + setScreen( + state = ListsUiState( + subscriptionRequired = true, + errorMessage = "Lists require an active subscription", + ), + ) + + composeRule.onNodeWithTag(ListsTestTags.SUBSCRIPTION).assertIsDisplayed() + } +} diff --git a/feature/lists/src/main/AndroidManifest.xml b/feature/lists/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/lists/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt new file mode 100644 index 0000000..22cd931 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -0,0 +1,188 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto +import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto +import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import com.interlinedlist.android.feature.lists.domain.ListDetail +import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.Paged +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import javax.inject.Inject + +/** + * Offline-first [ListsRepository]. The index is served from Room and refreshed + * from the API (Room stays the source of truth); mutations write through to the + * API and then update the cache. Failures are normalised to [ApiResult] via + * [safeApiCall], which maps a subscription 403 to `AppError.SubscriptionRequired`. + */ +class DefaultListsRepository @Inject constructor( + private val api: ListsApi, + private val listDao: ListDao, + private val json: kotlinx.serialization.json.Json, + private val dispatchers: DispatcherProvider, +) : ListsRepository { + + override fun observeLists(): Flow> = + listDao.observeLists().map { entities -> entities.map(ListMapper::summaryFromEntity) } + + override suspend fun refreshLists(limit: Int): ApiResult> = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getLists(limit = limit, offset = 0) }) { + is ApiResult.Success -> { + val summaries = result.data.items.map(ListMapper::summaryFromDto) + // First page → replace so server-side deletions are reflected. + listDao.replaceAll(summaries.map(ListMapper::summaryToEntity)) + ApiResult.Success(result.data.toPaged(summaries, offset = 0, limit = limit)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun loadMoreLists(offset: Int, limit: Int): ApiResult> = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getLists(limit = limit, offset = offset) }) { + is ApiResult.Success -> { + val summaries = result.data.items.map(ListMapper::summaryFromDto) + listDao.upsertAll(summaries.map(ListMapper::summaryToEntity)) + ApiResult.Success(result.data.toPaged(summaries, offset = offset, limit = limit)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun searchLists(query: String, limit: Int): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.searchLists(query = query, limit = limit, offset = 0) } + .map { response -> response.items.map(ListMapper::summaryFromDto) } + } + + override suspend fun createList( + title: String, + description: String?, + isPublic: Boolean, + ): ApiResult = withContext(dispatchers.io) { + val body = CreateListRequest(title = title, description = description, isPublic = isPublic) + when (val result = safeApiCall(json) { api.createList(body) }) { + is ApiResult.Success -> { + val dto = result.data.list ?: result.data.data + ?: return@withContext ApiResult.Success( + ListSummary( + id = "", title = title, description = description, + itemCount = 0, folderId = null, isPublic = isPublic, updatedAt = null, + ), + ) + val summary = ListMapper.summaryFromDto(dto) + listDao.upsert(ListMapper.summaryToEntity(summary)) + ApiResult.Success(summary) + } + is ApiResult.Failure -> result + } + } + + override suspend fun deleteList(id: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.deleteList(id) }) { + is ApiResult.Success -> { + listDao.deleteById(id) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun getListDetail(id: String, rowLimit: Int): ApiResult = + withContext(dispatchers.io) { + // 1) Metadata. + val listResult = safeApiCall(json) { api.getList(id) } + val listDto: ListDto = when (listResult) { + is ApiResult.Success -> listResult.data.list ?: listResult.data.data + ?: return@withContext ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.NotFound("List not found"), + ) + is ApiResult.Failure -> return@withContext listResult + } + + // 2) Schema (dynamic DSL). Prefer the dedicated endpoint; fall back to + // any schema inlined on the list payload. + val schema: ListSchema = when (val schemaResult = safeApiCall(json) { api.getSchema(id) }) { + is ApiResult.Success -> SchemaMapper.fromJson(schemaResult.data) + is ApiResult.Failure -> SchemaMapper.fromJson(listDto.schema) + } + + // 3) First page of rows. + val rowsResult = safeApiCall(json) { api.getRows(id, limit = rowLimit, offset = 0) } + val rows: List = when (rowsResult) { + is ApiResult.Success -> rowsResult.data.items.map(RowMapper::fromDto) + is ApiResult.Failure -> return@withContext rowsResult + } + + val summary = ListMapper.summaryFromDto(listDto) + listDao.upsert(ListMapper.summaryToEntity(summary)) + ApiResult.Success(ListDetail(summary = summary, schema = schema, rows = rows)) + } + + override suspend fun addRow(listId: String, values: Map): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.createRow(listId, RowWriteRequest(values.toJsonData())) } + .map { it.row ?: it.data ?: RowDto(id = "", data = kotlinx.serialization.json.JsonObject(emptyMap())) } + .map(RowMapper::fromDto) + } + + override suspend fun updateRow( + listId: String, + rowId: String, + values: Map, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { api.updateRow(listId, rowId, RowWriteRequest(values.toJsonData())) } + .map { it.row ?: it.data ?: RowDto(id = rowId, data = kotlinx.serialization.json.JsonObject(emptyMap())) } + .map(RowMapper::fromDto) + } + + override suspend fun deleteRow(listId: String, rowId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.deleteRow(listId, rowId) }.map { } + } + + override suspend fun getFolders(): ApiResult> = withContext(dispatchers.io) { + safeApiCall(json) { api.getFolders() } + .map { response -> response.items.map(ListMapper::folderFromDto) } + } + + override suspend fun createFolder(name: String, parentId: String?): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.createFolder(CreateFolderRequest(name = name, parentId = parentId)) } + .map(ListMapper::folderFromDto) + } + + /** Blank form fields are dropped so we don't overwrite server values with empty strings. */ + private fun Map.toJsonData(): Map = + filterValues { it.isNotBlank() } + .mapValues { (_, value) -> JsonPrimitive(value) as JsonElement } +} + +/** Builds a [Paged] from the response's pagination block, tolerating its absence. */ +private fun com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse.toPaged( + items: List, + offset: Int, + limit: Int, +): Paged { + val page = pagination + val nextOffset = offset + items.size + val hasMore = page?.hasMore ?: (page?.let { nextOffset < it.total } ?: (items.size >= limit)) + val total = page?.total ?: nextOffset + return Paged(items = items, hasMore = hasMore, total = total, offset = nextOffset) +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt new file mode 100644 index 0000000..6ce6bc2 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListMapper.kt @@ -0,0 +1,51 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.remote.dto.FolderDto +import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto +import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListSummary + +/** DTO/entity ↔ domain mapping for list summaries and folders. */ +object ListMapper { + + /** Resolves the item count across the API's several count field names. */ + private fun ListDto.resolveItemCount(): Int = + itemCount ?: rowCount ?: count ?: 0 + + fun summaryFromDto(dto: ListDto): ListSummary = ListSummary( + id = dto.id, + title = dto.title, + description = dto.description, + itemCount = dto.resolveItemCount(), + folderId = dto.folderId, + isPublic = dto.isPublic, + updatedAt = dto.updatedAt, + ) + + fun summaryToEntity(summary: ListSummary): CachedListEntity = CachedListEntity( + id = summary.id, + title = summary.title, + description = summary.description, + itemCount = summary.itemCount, + folderId = summary.folderId, + isPublic = summary.isPublic, + updatedAt = summary.updatedAt, + ) + + fun summaryFromEntity(entity: CachedListEntity): ListSummary = ListSummary( + id = entity.id, + title = entity.title, + description = entity.description, + itemCount = entity.itemCount, + folderId = entity.folderId, + isPublic = entity.isPublic, + updatedAt = entity.updatedAt, + ) + + fun folderFromDto(dto: FolderDto): ListFolder = ListFolder( + id = dto.id, + name = dto.name, + parentId = dto.parentId, + ) +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt new file mode 100644 index 0000000..aba1f01 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -0,0 +1,52 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.domain.ListDetail +import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.Paged +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to the Lists domain. The index streams from Room (the + * source of truth) via [observeLists]; refresh/load-more calls update the cache + * and report the pagination state so the UI knows whether more pages remain. + */ +interface ListsRepository { + + /** Cached lists, emitted from Room and re-emitted on every local change. */ + fun observeLists(): Flow> + + /** Fetches the first page from the API and replaces the cache. Returns pagination. */ + suspend fun refreshLists(limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Fetches a further page and appends it to the cache. */ + suspend fun loadMoreLists(offset: Int, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Server-side search (not cached) by title/description. */ + suspend fun searchLists(query: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Creates a list; caches the result and returns its summary. */ + suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult + + /** Deletes a list and evicts it from the cache. */ + suspend fun deleteList(id: String): ApiResult + + /** Loads a list's metadata, schema, and first page of rows for the detail screen. */ + suspend fun getListDetail(id: String, rowLimit: Int = DEFAULT_PAGE_SIZE): ApiResult + + suspend fun addRow(listId: String, values: Map): ApiResult + + suspend fun updateRow(listId: String, rowId: String, values: Map): ApiResult + + suspend fun deleteRow(listId: String, rowId: String): ApiResult + + suspend fun getFolders(): ApiResult> + + suspend fun createFolder(name: String, parentId: String?): ApiResult + + companion object { + const val DEFAULT_PAGE_SIZE = 20 + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt new file mode 100644 index 0000000..b456a5e --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/RowMapper.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto +import com.interlinedlist.android.feature.lists.domain.ListRow +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Projects a dynamic row `data` object into a `key → display string` map. + * + * Rows are keyed by schema field keys but the client never assumes value types: + * strings, numbers, booleans, nulls, and nested arrays/objects are all coerced to + * a readable string so any schema renders in the generic table. Nulls become + * empty strings so absent cells stay blank rather than showing "null". + */ +object RowMapper { + + fun fromDto(dto: RowDto): ListRow = + ListRow( + id = dto.id, + values = dto.data.mapValues { (_, value) -> displayString(value) }, + ) + + /** Coerces any JSON value to a human-readable string. */ + fun displayString(value: kotlinx.serialization.json.JsonElement): String = when (value) { + is JsonNull -> "" + is JsonPrimitive -> value.content + is JsonArray -> value.joinToString(", ") { displayString(it) } + is JsonObject -> value.entries.joinToString(", ") { (k, v) -> "$k: ${displayString(v)}" } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt new file mode 100644 index 0000000..26d214b --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt @@ -0,0 +1,112 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.SchemaField +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.contentOrNull + +/** + * Interprets a list's user-defined schema DSL into a typed [ListSchema]. + * + * The DSL is dynamic and reaches the client in a few shapes across API versions; + * this mapper accepts all of them so column rendering never depends on one exact + * wire form: + * + * 1. An **array** of field objects: `[{ "key": "name", "type": "text", ... }]` + * 2. An **object** keyed by field key: `{ "name": { "type": "text" }, ... }` + * 3. A **wrapper object** whose `properties`/`fields` holds one of the above. + * + * Within a field object, the key may be under `key`/`name`/`id`, the label under + * `label`/`title`/`name`, and the type under `type`/`fieldType`. Unknown types + * fall back to [FieldType.TEXT] (see [FieldType.fromDsl]) so no column is lost. + */ +object SchemaMapper { + + /** Parses the (possibly null) schema element; returns [ListSchema.EMPTY] if unusable. */ + fun fromJson(element: JsonElement?): ListSchema { + val root = unwrap(element) ?: return ListSchema.EMPTY + val fields = when (root) { + is JsonArray -> root.mapNotNull { fieldFromObject(it, keyHint = null) } + is JsonObject -> root.entries.mapNotNull { (key, value) -> + fieldFromObject(value, keyHint = key) + } + else -> emptyList() + } + return ListSchema(fields) + } + + /** Unwraps a `{ properties: ... }` / `{ fields: ... }` container to its payload. */ + private fun unwrap(element: JsonElement?): JsonElement? { + // A bare primitive can't describe a schema. + if (element == null || element is JsonPrimitive) return null + val obj = element as? JsonObject ?: return element + // A container object exposes the actual field set under a known key. + (obj["properties"] ?: obj["fields"] ?: obj["schema"] ?: obj["columns"])?.let { + return it + } + return obj + } + + /** + * Builds a [SchemaField] from a value that is either a field-descriptor object + * or (when the schema is a bare object of key→type strings) a type primitive. + */ + private fun fieldFromObject(value: JsonElement, keyHint: String?): SchemaField? { + // Shape: { "name": "text" } — the value is just the type string. + if (value is JsonPrimitive) { + val key = keyHint ?: return null + return SchemaField( + key = key, + label = humanize(key), + type = FieldType.fromDsl(value.contentOrNull), + ) + } + + val obj = value as? JsonObject ?: return null + val key = keyHint + ?: obj.string("key") + ?: obj.string("name") + ?: obj.string("id") + ?: return null + + val label = obj.string("label") + ?: obj.string("title") + ?: obj.string("name")?.takeIf { keyHint != null } + ?: humanize(key) + + val type = FieldType.fromDsl(obj.string("type") ?: obj.string("fieldType")) + val required = obj["required"]?.let { (it as? JsonPrimitive)?.booleanOrNull } ?: false + val options = (obj["options"] as? JsonArray) + ?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull } + ?: emptyList() + + return SchemaField( + key = key, + label = label, + type = type, + required = required, + options = options, + ) + } + + private fun JsonObject.string(name: String): String? = + (this[name] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + + /** Turns a raw key like `first_name`/`firstName` into a readable `First Name`. */ + private fun humanize(key: String): String { + val spaced = key + .replace('_', ' ') + .replace('-', ' ') + .replace(Regex("([a-z])([A-Z])"), "$1 $2") + .trim() + return spaced.split(' ') + .filter { it.isNotBlank() } + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } + .ifBlank { key } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt new file mode 100644 index 0000000..ccdb761 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/CachedListEntity.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.lists.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Locally cached list summary — the offline-first source of truth for the index. + * Schema and data rows are not cached here; they are loaded per-list on demand. + */ +@Entity(tableName = "cached_list") +data class CachedListEntity( + @PrimaryKey val id: String, + val title: String, + val description: String?, + val itemCount: Int, + val folderId: String?, + val isPublic: Boolean, + val updatedAt: String?, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListDao.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListDao.kt new file mode 100644 index 0000000..5dd3746 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListDao.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.lists.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ListDao { + + /** Emits all cached lists, newest-updated first, re-emitting on every change. */ + @Query("SELECT * FROM cached_list ORDER BY updatedAt DESC") + fun observeLists(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(lists: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(list: CachedListEntity) + + @Query("DELETE FROM cached_list WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM cached_list") + suspend fun clear() + + /** + * Replaces the whole cache with [lists] in one transaction — used when a full + * first page is fetched so removals on the server are reflected locally. + */ + @androidx.room.Transaction + suspend fun replaceAll(lists: List) { + clear() + upsertAll(lists) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt new file mode 100644 index 0000000..772b9d3 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/local/ListsDatabase.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.lists.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * Room database owned by the Lists feature module. Kept separate from the shared + * `InterlinedListDatabase` so the feature stays self-contained (the module must + * not touch `:core:*`). + */ +@Database( + entities = [CachedListEntity::class], + version = 1, + exportSchema = false, +) +abstract class ListsDatabase : RoomDatabase() { + abstract fun listDao(): ListDao +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt new file mode 100644 index 0000000..ba197ce --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt @@ -0,0 +1,85 @@ +package com.interlinedlist.android.feature.lists.data.remote + +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.FolderDto +import com.interlinedlist.android.feature.lists.data.remote.dto.FoldersResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.ListEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.RowEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.RowsResponse +import kotlinx.serialization.json.JsonElement +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the InterlinedList Lists API used by this module. The + * shared Retrofit singleton supplies the base URL and Bearer auth, so these calls + * are authenticated. + */ +interface ListsApi { + + @GET("api/lists") + suspend fun getLists( + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): ListsResponse + + @GET("api/lists/search") + suspend fun searchLists( + @Query("q") query: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): ListsResponse + + @POST("api/lists") + suspend fun createList(@Body body: CreateListRequest): ListEnvelope + + @GET("api/lists/{id}") + suspend fun getList(@Path("id") id: String): ListEnvelope + + @DELETE("api/lists/{id}") + suspend fun deleteList(@Path("id") id: String) + + /** The schema DSL — shape is dynamic, so it is received as a raw element. */ + @GET("api/lists/{id}/schema") + suspend fun getSchema(@Path("id") id: String): JsonElement + + @GET("api/lists/{id}/data") + suspend fun getRows( + @Path("id") id: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): RowsResponse + + @POST("api/lists/{id}/data") + suspend fun createRow( + @Path("id") id: String, + @Body body: RowWriteRequest, + ): RowEnvelope + + @PUT("api/lists/{id}/data/{rowId}") + suspend fun updateRow( + @Path("id") id: String, + @Path("rowId") rowId: String, + @Body body: RowWriteRequest, + ): RowEnvelope + + @DELETE("api/lists/{id}/data/{rowId}") + suspend fun deleteRow( + @Path("id") id: String, + @Path("rowId") rowId: String, + ) + + @GET("api/folders") + suspend fun getFolders(): FoldersResponse + + @POST("api/folders") + suspend fun createFolder(@Body body: CreateFolderRequest): FolderDto +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt new file mode 100644 index 0000000..464b066 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** A list folder as returned by `GET /api/folders`. */ +@Serializable +data class FolderDto( + val id: String, + val name: String = "", + val parentId: String? = null, +) + +/** Envelope for `GET /api/folders`; folders may be wrapped or bare. */ +@Serializable +data class FoldersResponse( + val data: List? = null, + val folders: List? = null, +) { + val items: List get() = data ?: folders ?: emptyList() +} + +/** Body for `POST /api/folders`. */ +@Serializable +data class CreateFolderRequest( + val name: String, + val parentId: String? = null, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt new file mode 100644 index 0000000..c4bd4ea --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ListDtos.kt @@ -0,0 +1,81 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Wire models for the Lists API. Field names follow the InterlinedList REST + * contract; the shared [kotlinx.serialization.json.Json] is configured with + * `ignoreUnknownKeys`, so extra server fields are tolerated and only the columns + * we render need to be declared here. + * + * The list `schema` and each row's `data` are intentionally left as raw + * [JsonElement]: they are user-defined and dynamic, so they are interpreted by + * the mappers ([com.interlinedlist.android.feature.lists.data.SchemaMapper] / + * [com.interlinedlist.android.feature.lists.data.RowMapper]) rather than by fixed + * @Serializable shapes. + */ + +/** A list envelope as returned by index and detail endpoints. */ +@Serializable +data class ListDto( + val id: String, + val title: String = "", + val description: String? = null, + val itemCount: Int? = null, + val rowCount: Int? = null, + val count: Int? = null, + val folderId: String? = null, + val isPublic: Boolean = false, + val updatedAt: String? = null, + // Detail responses may inline the schema; the mapper handles either shape. + val schema: JsonElement? = null, +) + +/** Pagination block shared by list endpoints. */ +@Serializable +data class PaginationDto( + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, + val hasMore: Boolean = false, +) + +/** + * Envelope for `GET /api/lists` and `GET /api/lists/search`. + * The payload may either be `{ data: [...], pagination: {...} }` or (for some + * builds) `{ lists: [...] }`; both list keys are accepted. + */ +@Serializable +data class ListsResponse( + val data: List? = null, + val lists: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: lists ?: emptyList() +} + +/** Envelope for `GET /api/lists/{id}`; the list may be wrapped or bare. */ +@Serializable +data class ListEnvelope( + val list: ListDto? = null, + val data: ListDto? = null, +) + +/** Body for `POST /api/lists`. `schema` is a serialised DSL string per the API. */ +@Serializable +data class CreateListRequest( + val title: String, + val description: String? = null, + val schema: String? = null, + val isPublic: Boolean = false, +) + +/** Body for `PUT /api/lists/{id}` — partial metadata updates. */ +@Serializable +data class UpdateListRequest( + val title: String? = null, + val description: String? = null, + val folderId: String? = null, + val isPublic: Boolean? = null, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt new file mode 100644 index 0000000..e5c940f --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RowDtos.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +/** + * A single data row. `data` is the dynamic key→value map keyed by schema field + * keys; it is kept as a [JsonObject] and projected to display strings by the + * mapper, so any schema is supported without a fixed shape. + */ +@Serializable +data class RowDto( + val id: String, + val data: JsonObject = JsonObject(emptyMap()), +) + +/** Envelope for `GET /api/lists/{id}/data`. */ +@Serializable +data class RowsResponse( + val data: List? = null, + val rows: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: rows ?: emptyList() +} + +/** Envelope for a single-row create/get; the row may be wrapped or bare. */ +@Serializable +data class RowEnvelope( + val row: RowDto? = null, + val data: RowDto? = null, +) + +/** + * Body for `POST`/`PUT` of a row. Per the API the `data` property carries the + * row's field map (as a nested JSON object). + */ +@Serializable +data class RowWriteRequest( + val data: Map, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt new file mode 100644 index 0000000..734bf3b --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/di/ListsModule.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.lists.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.feature.lists.data.DefaultListsRepository +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.local.ListsDatabase +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class ListsRepositoryModule { + + @Binds + @Singleton + abstract fun bindListsRepository(impl: DefaultListsRepository): ListsRepository +} + +/** Provides this module's Retrofit API and its own Room database + DAO. */ +@Module +@InstallIn(SingletonComponent::class) +object ListsDataModule { + + @Provides + @Singleton + fun provideListsApi(retrofit: Retrofit): ListsApi = + retrofit.create(ListsApi::class.java) + + @Provides + @Singleton + fun provideListsDatabase(@ApplicationContext context: Context): ListsDatabase = + Room.databaseBuilder( + context, + ListsDatabase::class.java, + "interlinedlist-lists.db", + ) + // Disposable cache during early development; the cache is re-fetched. + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideListDao(db: ListsDatabase): ListDao = db.listDao() +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFolder.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFolder.kt new file mode 100644 index 0000000..6748958 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListFolder.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.lists.domain + +/** A folder used to organise lists. */ +data class ListFolder( + val id: String, + val name: String, + val parentId: String?, +) + +/** + * A page of results plus whether more remain, so the UI can offer load-more + * without knowing the wire pagination shape. + */ +data class Paged( + val items: List, + val hasMore: Boolean, + val total: Int, + val offset: Int, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt new file mode 100644 index 0000000..ad2c65c --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListRow.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * One data row of a list. [values] is a dynamic map keyed by [SchemaField.key]; + * values are normalised to display strings so the table can render any schema + * without knowing field types at compile time. The raw typed access lives in the + * mapper — the UI only needs the string projection to show and edit cells. + */ +data class ListRow( + val id: String, + val values: Map, +) { + /** Value for [key], or empty string when the row omits that field. */ + fun valueFor(key: String): String = values[key].orEmpty() +} + +/** A full list ready for the detail screen: metadata + schema + the loaded rows. */ +data class ListDetail( + val summary: ListSummary, + val schema: ListSchema, + val rows: List, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt new file mode 100644 index 0000000..ecbe23a --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSchema.kt @@ -0,0 +1,59 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * The user-defined shape of a list. A list's rows are dynamic key→value maps; the + * [fields] here describe which keys exist, how to label them, and how to render + * and edit their values. The UI renders columns and the add/edit form generically + * from these fields — nothing about a list's columns is hardcoded. + */ +data class ListSchema( + val fields: List, +) { + val isEmpty: Boolean get() = fields.isEmpty() + + companion object { + val EMPTY = ListSchema(emptyList()) + } +} + +/** + * A single column/property in a list's schema. + * + * @param key the stable key used in a row's data map (e.g. `"name"`). + * @param label human-friendly column header; falls back to [key] when absent. + * @param type controls rendering and the form input used to edit the value. + * @param required whether the add/edit form should treat the field as mandatory. + * @param options selectable values for [FieldType.SELECT] fields. + */ +data class SchemaField( + val key: String, + val label: String, + val type: FieldType, + val required: Boolean = false, + val options: List = emptyList(), +) + +/** + * Supported schema field types. Unknown/absent DSL types map to [TEXT] so the row + * still renders and stays editable rather than being dropped. + */ +enum class FieldType { + TEXT, + NUMBER, + BOOLEAN, + DATE, + URL, + SELECT; + + companion object { + /** Maps a DSL type string (case-insensitive) to a [FieldType], defaulting to [TEXT]. */ + fun fromDsl(raw: String?): FieldType = when (raw?.trim()?.lowercase()) { + "number", "int", "integer", "float", "decimal" -> NUMBER + "boolean", "bool", "checkbox" -> BOOLEAN + "date", "datetime", "timestamp" -> DATE + "url", "link" -> URL + "select", "enum", "option", "options" -> SELECT + else -> TEXT + } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt new file mode 100644 index 0000000..0e40a9c --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListSummary.kt @@ -0,0 +1,16 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A user's list as shown in the index/feed: enough to render a card and open the + * detail screen. The full schema and data rows are loaded lazily on the detail + * screen, so this stays lightweight for the (potentially long) index. + */ +data class ListSummary( + val id: String, + val title: String, + val description: String?, + val itemCount: Int, + val folderId: String?, + val isPublic: Boolean, + val updatedAt: String?, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt new file mode 100644 index 0000000..101111e --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/ListsErrorMessages.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.lists.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the Lists UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Showing what's saved on this device." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.NotFound -> message ?: "That list could not be found." + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.SubscriptionRequired -> message ?: "Lists require an active subscription." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} + +/** True when the error is the subscriber-only gate, so the UI can show an upsell. */ +val AppError.isSubscriptionGate: Boolean get() = this is AppError.SubscriptionRequired diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt new file mode 100644 index 0000000..db47d9b --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -0,0 +1,327 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.SchemaField + +/** Stable test tags for the list detail screen. */ +object ListDetailTestTags { + const val TABLE = "listDetailTable" + const val ADD_ROW_FAB = "listDetailAddRow" + const val EMPTY = "listDetailEmpty" + const val PROGRESS = "listDetailProgress" + const val ERROR = "listDetailError" + const val SUBSCRIPTION = "listDetailSubscription" + const val DELETE_LIST = "listDetailDeleteList" + fun row(id: String) = "listDetailRow_$id" +} + +/** + * Hilt-wired entry for a single list. Reads its `listId` from the nav + * SavedStateHandle (see [LIST_ID_ARG]); [onBack] and [onListDeleted] let the app + * pop navigation. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListDetailRoute( + onBack: () -> Unit, + onListDeleted: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ListDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + var editing by remember { mutableStateOf(null) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ListDetailScreen( + state = state, + onBack = onBack, + onAddRow = { editing = EditorTarget.New }, + onEditRow = { editing = EditorTarget.Existing(it) }, + onDeleteRow = viewModel::deleteRow, + onDeleteList = { viewModel.deleteList(onListDeleted) }, + modifier = modifier, + ) + + val target = editing + if (target != null) { + ModalBottomSheet(onDismissRequest = { editing = null }, sheetState = sheetState) { + RowEditor( + schema = state.schema, + row = (target as? EditorTarget.Existing)?.row, + isSaving = state.isSaving, + onSave = { values -> + when (target) { + EditorTarget.New -> viewModel.addRow(values) { editing = null } + is EditorTarget.Existing -> viewModel.updateRow(target.row.id, values) { editing = null } + } + }, + onCancel = { editing = null }, + ) + } + } +} + +private sealed interface EditorTarget { + data object New : EditorTarget + data class Existing(val row: ListRow) : EditorTarget +} + +/** Stateless list detail — schema-driven table with loading / empty / error states. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListDetailScreen( + state: ListDetailUiState, + onBack: () -> Unit, + onAddRow: () -> Unit, + onEditRow: (ListRow) -> Unit, + onDeleteRow: (String) -> Unit, + onDeleteList: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(state.title.ifBlank { "List" }, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton(onClick = onDeleteList, modifier = Modifier.testTag(ListDetailTestTags.DELETE_LIST)) { + Icon(Icons.Default.Delete, contentDescription = "Delete list") + } + }, + ) + }, + floatingActionButton = { + if (!state.subscriptionRequired && !state.schema.isEmpty) { + FloatingActionButton( + onClick = onAddRow, + modifier = Modifier.testTag(ListDetailTestTags.ADD_ROW_FAB), + ) { Icon(Icons.Default.Add, contentDescription = "Add row") } + } + }, + ) { padding -> + when { + state.subscriptionRequired -> Centered(Modifier.padding(padding).testTag(ListDetailTestTags.SUBSCRIPTION)) { + Text("Subscribers only", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text(state.errorMessage ?: "Lists require an active subscription.") + } + + state.isLoading -> Centered(Modifier.padding(padding)) { + CircularProgressIndicator(Modifier.testTag(ListDetailTestTags.PROGRESS)) + } + + state.errorMessage != null && state.summary == null -> Centered( + Modifier.padding(padding).testTag(ListDetailTestTags.ERROR), + ) { Text(state.errorMessage) } + + else -> Column(Modifier.padding(padding)) { + if (!state.summary?.description.isNullOrBlank()) { + Text( + text = state.summary!!.description!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + SchemaTable( + schema = state.schema, + rows = state.rows, + isEmpty = state.isEmpty, + onEditRow = onEditRow, + onDeleteRow = onDeleteRow, + ) + } + } + } +} + +/** + * Renders rows generically against the schema: a header of field labels and one + * card per row projecting each field's value. Nothing about the columns is + * hardcoded — an empty schema still shows raw key/value pairs from the rows. + */ +@Composable +private fun SchemaTable( + schema: ListSchema, + rows: List, + isEmpty: Boolean, + onEditRow: (ListRow) -> Unit, + onDeleteRow: (String) -> Unit, +) { + if (isEmpty) { + Centered(Modifier.testTag(ListDetailTestTags.EMPTY)) { + Text("No rows yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Use the + button to add the first row.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + + // Columns come from the schema; fall back to the union of row keys if absent. + val columns: List = schema.fields.ifEmpty { + rows.flatMap { it.values.keys }.distinct().map { key -> + SchemaField(key = key, label = key, type = com.interlinedlist.android.feature.lists.domain.FieldType.TEXT) + } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(ListDetailTestTags.TABLE), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(rows, key = { it.id }) { row -> + RowCard( + columns = columns, + row = row, + onClick = { onEditRow(row) }, + onDelete = { onDeleteRow(row.id) }, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RowCard( + columns: List, + row: ListRow, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .testTag(ListDetailTestTags.row(row.id)), + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Column(Modifier.weight(1f).horizontalScroll(rememberScrollState())) { + columns.forEach { field -> + RowField(label = field.label, value = row.valueFor(field.key)) + } + } + IconButton(onClick = onDelete) { + Icon(Icons.Default.Delete, contentDescription = "Delete row") + } + } + } + } +} + +@Composable +private fun RowField(label: String, value: String) { + Row(Modifier.padding(vertical = 2.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.width(120.dp), + ) + Text( + text = value.ifBlank { "—" }, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun Centered(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally, content = { content() }) + } +} + +@Preview(showBackground = true) +@Composable +private fun ListDetailScreenPreview() { + InterlinedListTheme { + ListDetailScreen( + state = ListDetailUiState( + summary = ListSummary("1", "Reading list", "Books", 2, null, false, null), + schema = ListSchema( + listOf( + SchemaField("title", "Title", com.interlinedlist.android.feature.lists.domain.FieldType.TEXT), + SchemaField("done", "Done", com.interlinedlist.android.feature.lists.domain.FieldType.BOOLEAN), + ), + ), + rows = listOf( + ListRow("r1", mapOf("title" to "Dune", "done" to "true")), + ListRow("r2", mapOf("title" to "Hyperion", "done" to "false")), + ), + isLoading = false, + ), + onBack = {}, + onAddRow = {}, + onEditRow = {}, + onDeleteRow = {}, + onDeleteList = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt new file mode 100644 index 0000000..6927013 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt @@ -0,0 +1,142 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the list detail screen (schema-driven table). */ +data class ListDetailUiState( + val summary: ListSummary? = null, + val schema: ListSchema = ListSchema.EMPTY, + val rows: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val isSaving: Boolean = false, + val deleted: Boolean = false, +) { + val title: String get() = summary?.title.orEmpty() + val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null +} + +/** The nav argument key the detail route reads its list id from. */ +const val LIST_ID_ARG = "listId" + +@HiltViewModel +class ListDetailViewModel @Inject constructor( + private val repository: ListsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val listId: String = requireNotNull(savedStateHandle[LIST_ID_ARG]) { + "ListDetailViewModel requires a '$LIST_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(ListDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.getListDetail(listId)) { + is ApiResult.Success -> _uiState.update { + it.copy( + summary = result.data.summary, + schema = result.data.schema, + rows = result.data.rows, + isLoading = false, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoading = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun addRow(values: Map, onDone: () -> Unit = {}) { + _uiState.update { it.copy(isSaving = true) } + viewModelScope.launch { + when (val result = repository.addRow(listId, values)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false, rows = it.rows + result.data) } + onDone() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun updateRow(rowId: String, values: Map, onDone: () -> Unit = {}) { + _uiState.update { it.copy(isSaving = true) } + viewModelScope.launch { + when (val result = repository.updateRow(listId, rowId, values)) { + is ApiResult.Success -> { + _uiState.update { state -> + state.copy( + isSaving = false, + rows = state.rows.map { if (it.id == rowId) result.data else it }, + ) + } + onDone() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun deleteRow(rowId: String) { + viewModelScope.launch { + when (val result = repository.deleteRow(listId, rowId)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(rows = state.rows.filterNot { it.id == rowId }) + } + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun deleteList(onDeleted: () -> Unit = {}) { + viewModelScope.launch { + when (val result = repository.deleteList(listId)) { + is ApiResult.Success -> { + _uiState.update { it.copy(deleted = true) } + onDeleted() + } + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt new file mode 100644 index 0000000..7007444 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/RowEditor.kt @@ -0,0 +1,149 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.FilterChip +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.SchemaField + +object RowEditorTestTags { + const val SAVE = "rowEditorSave" + const val CANCEL = "rowEditorCancel" + fun field(key: String) = "rowField_$key" +} + +/** + * A form generated entirely from the list [schema]. Each [SchemaField] maps to an + * input appropriate for its [FieldType] (text/number/url text fields, a boolean + * switch, or single-select chips). Editing an existing [row] seeds the fields. + * + * Values are collected as strings keyed by field key and returned to [onSave]; + * the repository serialises them into the row's dynamic `data` map. + */ +@Composable +fun RowEditor( + schema: ListSchema, + row: ListRow?, + isSaving: Boolean, + onSave: (Map) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + // One editable value per schema field, seeded from the row when editing. + val values = remember(row, schema) { + mutableStateMapOf().apply { + schema.fields.forEach { field -> put(field.key, row?.valueFor(field.key).orEmpty()) } + } + } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = if (row == null) "Add row" else "Edit row", + style = androidx.compose.material3.MaterialTheme.typography.titleLarge, + ) + + schema.fields.forEach { field -> + FieldInput( + field = field, + value = values[field.key].orEmpty(), + onValueChange = { values[field.key] = it }, + ) + } + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + onClick = onCancel, + modifier = Modifier.testTag(RowEditorTestTags.CANCEL), + ) { Text("Cancel") } + Button( + onClick = { onSave(values.toMap()) }, + enabled = !isSaving, + modifier = Modifier.testTag(RowEditorTestTags.SAVE), + ) { Text(if (row == null) "Add" else "Save") } + } + } +} + +/** Renders the input control matched to the field's type. */ +@Composable +private fun FieldInput( + field: SchemaField, + value: String, + onValueChange: (String) -> Unit, +) { + val tag = RowEditorTestTags.field(field.key) + when (field.type) { + FieldType.BOOLEAN -> Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(field.label) + Switch( + checked = value.equals("true", ignoreCase = true), + onCheckedChange = { onValueChange(it.toString()) }, + modifier = Modifier.testTag(tag), + ) + } + + FieldType.SELECT -> Column { + Text(field.label) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + field.options.forEach { option -> + FilterChip( + selected = value == option, + onClick = { onValueChange(option) }, + label = { Text(option) }, + ) + } + } + } + + else -> OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(field.label + if (field.required) " *" else "") }, + singleLine = field.type != FieldType.TEXT, + keyboardOptions = KeyboardOptions(keyboardType = field.type.keyboardType()), + modifier = Modifier + .fillMaxWidth() + .testTag(tag), + ) + } +} + +private fun FieldType.keyboardType(): KeyboardType = when (this) { + FieldType.NUMBER -> KeyboardType.Number + FieldType.URL -> KeyboardType.Uri + else -> KeyboardType.Text +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt new file mode 100644 index 0000000..e1b7a1d --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt @@ -0,0 +1,294 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListSummary + +/** Stable test tags for the lists index. */ +object ListsTestTags { + const val LIST = "listsIndex" + const val SEARCH = "listsSearch" + const val CREATE_FAB = "listsCreateFab" + const val ERROR = "listsError" + const val EMPTY = "listsEmpty" + const val PROGRESS = "listsProgress" + const val SUBSCRIPTION = "listsSubscription" + fun row(id: String) = "listRow_$id" +} + +/** + * Hilt-wired entry point for the Lists index. [onOpenList] receives the tapped + * list's id so the app can navigate to the detail route. + */ +@Composable +fun ListsRoute( + onOpenList: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: ListsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ListsScreen( + state = state, + onOpenList = onOpenList, + onSearchQueryChange = viewModel::onSearchQueryChange, + onLoadMore = viewModel::loadMore, + onCreateList = { title -> viewModel.createList(title, description = null, onCreated = { onOpenList(it.id) }) }, + modifier = modifier, + ) +} + +/** Stateless lists index — loading / empty / error / subscription / content states. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListsScreen( + state: ListsUiState, + onOpenList: (String) -> Unit, + onSearchQueryChange: (String) -> Unit, + onLoadMore: () -> Unit, + onCreateList: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { TopAppBar(title = { Text("Lists") }) }, + floatingActionButton = { + if (!state.subscriptionRequired) { + ExtendedFloatingActionButton( + onClick = { onCreateList("New list") }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("New list") }, + modifier = Modifier.testTag(ListsTestTags.CREATE_FAB), + ) + } + }, + ) { padding -> + when { + state.subscriptionRequired -> SubscriptionGate( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + + state.visibleLists.isEmpty() && state.isRefreshing -> Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(ListsTestTags.PROGRESS)) + } + + else -> Column(Modifier.padding(padding)) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("Search lists") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(ListsTestTags.SEARCH), + ) + + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag(ListsTestTags.ERROR), + ) + } + + if (state.isEmpty) { + EmptyState() + } else { + ListsList( + lists = state.visibleLists, + isLoadingMore = state.isLoadingMore, + hasMore = state.hasMore && !state.isSearching, + onOpenList = onOpenList, + onLoadMore = onLoadMore, + ) + } + } + } + } +} + +@Composable +private fun ListsList( + lists: List, + isLoadingMore: Boolean, + hasMore: Boolean, + onOpenList: (String) -> Unit, + onLoadMore: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(ListsTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(lists, key = { it.id }) { list -> + ListCard(list = list, onClick = { onOpenList(list.id) }) + } + if (hasMore) { + item { + // Trigger load-more when the sentinel scrolls into view. + LaunchedLoadMore(onLoadMore) + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + if (isLoadingMore) CircularProgressIndicator(Modifier.height(24.dp)) + } + } + } + } +} + +@Composable +private fun LaunchedLoadMore(onLoadMore: () -> Unit) { + androidx.compose.runtime.LaunchedEffect(Unit) { onLoadMore() } +} + +@Composable +private fun ListCard(list: ListSummary, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .testTag(ListsTestTags.row(list.id)), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = list.title.ifBlank { "Untitled list" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!list.description.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = list.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "${list.itemCount} ${if (list.itemCount == 1) "item" else "items"}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + if (list.isPublic) { + Text( + text = "Public", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(ListsTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No lists yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Tap New list to create your first one.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun SubscriptionGate(message: String?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .testTag(ListsTestTags.SUBSCRIPTION), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp), + ) { + Text("Subscribers only", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Lists require an active subscription.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ListsScreenPreview() { + InterlinedListTheme { + ListsScreen( + state = ListsUiState( + lists = listOf( + ListSummary("1", "Reading list", "Books to read", 12, null, false, null), + ListSummary("2", "Restaurants", null, 4, null, true, null), + ), + ), + onOpenList = {}, + onSearchQueryChange = {}, + onLoadMore = {}, + onCreateList = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt new file mode 100644 index 0000000..bd1ba47 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModel.kt @@ -0,0 +1,154 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the lists index. */ +data class ListsUiState( + val lists: List = emptyList(), + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = false, + val nextOffset: Int = 0, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val searchQuery: String = "", + val searchResults: List? = null, +) { + /** Rows to render: search results when searching, otherwise the cached index. */ + val visibleLists: List get() = searchResults ?: lists + val isSearching: Boolean get() = searchResults != null + val isEmpty: Boolean + get() = visibleLists.isEmpty() && !isRefreshing && errorMessage == null && !subscriptionRequired +} + +@HiltViewModel +class ListsViewModel @Inject constructor( + private val repository: ListsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ListsUiState()) + + /** + * Combines the persisted Room stream (source of truth) with transient UI flags + * so the list stays live as the cache changes while refresh/error state layers + * on top. + */ + val uiState: StateFlow = combine( + repository.observeLists(), + _uiState, + ) { cached, transient -> + transient.copy(lists = cached) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = _uiState.value, + ) + + /** Exposed for tests that assert only the transient flags. */ + val transientState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.refreshLists()) { + is ApiResult.Success -> _uiState.update { + it.copy( + isRefreshing = false, + hasMore = result.data.hasMore, + nextOffset = result.data.offset, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isRefreshing = false, + // Cache still renders via the Room stream; surface the reason. + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun loadMore() { + val current = _uiState.value + if (current.isLoadingMore || !current.hasMore || current.isSearching) return + _uiState.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + when (val result = repository.loadMoreLists(offset = current.nextOffset)) { + is ApiResult.Success -> _uiState.update { + it.copy( + isLoadingMore = false, + hasMore = result.data.hasMore, + nextOffset = result.data.offset, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingMore = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun createList(title: String, description: String?, onCreated: (ListSummary) -> Unit = {}) { + if (title.isBlank()) return + viewModelScope.launch { + when (val result = repository.createList(title.trim(), description?.trim()?.ifBlank { null }, isPublic = false)) { + is ApiResult.Success -> onCreated(result.data) + is ApiResult.Failure -> _uiState.update { + it.copy( + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun deleteList(id: String) { + viewModelScope.launch { + when (val result = repository.deleteList(id)) { + is ApiResult.Success -> Unit // Room stream drops the row. + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun onSearchQueryChange(query: String) { + _uiState.update { it.copy(searchQuery = query) } + if (query.isBlank()) { + _uiState.update { it.copy(searchResults = null) } + return + } + viewModelScope.launch { + when (val result = repository.searchLists(query.trim())) { + is ApiResult.Success -> _uiState.update { it.copy(searchResults = result.data) } + is ApiResult.Failure -> _uiState.update { + it.copy(searchResults = emptyList(), errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt new file mode 100644 index 0000000..31b4679 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -0,0 +1,90 @@ +package com.interlinedlist.android.feature.lists + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListDetail +import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.Paged +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * In-memory [ListsRepository] for ViewModel tests. The cache is a StateFlow so + * tests can assert the offline-first stream, and each operation's result is + * configurable to exercise success / failure / subscription-gate paths. + */ +class FakeListsRepository : ListsRepository { + + val cache = MutableStateFlow>(emptyList()) + + var refreshResult: ApiResult> = + ApiResult.Success(Paged(emptyList(), hasMore = false, total = 0, offset = 0)) + var loadMoreResult: ApiResult> = refreshResult + var searchResult: ApiResult> = ApiResult.Success(emptyList()) + var createResult: ApiResult? = null + var deleteResult: ApiResult = ApiResult.Success(Unit) + var detailResult: ApiResult? = null + var addRowResult: ApiResult? = null + var updateRowResult: ApiResult? = null + var deleteRowResult: ApiResult = ApiResult.Success(Unit) + + var refreshCount = 0 + var loadMoreCount = 0 + + override fun observeLists(): Flow> = cache + + override suspend fun refreshLists(limit: Int): ApiResult> { + refreshCount++ + (refreshResult as? ApiResult.Success)?.let { cache.value = it.data.items } + return refreshResult + } + + override suspend fun loadMoreLists(offset: Int, limit: Int): ApiResult> { + loadMoreCount++ + (loadMoreResult as? ApiResult.Success)?.let { cache.value = cache.value + it.data.items } + return loadMoreResult + } + + override suspend fun searchLists(query: String, limit: Int): ApiResult> = searchResult + + override suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult = + createResult ?: ApiResult.Success( + ListSummary("new", title, description, 0, null, isPublic, null), + ) + + override suspend fun deleteList(id: String): ApiResult { + if (deleteResult is ApiResult.Success) cache.value = cache.value.filterNot { it.id == id } + return deleteResult + } + + override suspend fun getListDetail(id: String, rowLimit: Int): ApiResult = + detailResult ?: ApiResult.Success( + ListDetail( + summary = ListSummary(id, "Untitled", null, 0, null, false, null), + schema = ListSchema.EMPTY, + rows = emptyList(), + ), + ) + + override suspend fun addRow(listId: String, values: Map): ApiResult = + addRowResult ?: ApiResult.Success(ListRow("row-new", values)) + + override suspend fun updateRow(listId: String, rowId: String, values: Map): ApiResult = + updateRowResult ?: ApiResult.Success(ListRow(rowId, values)) + + override suspend fun deleteRow(listId: String, rowId: String): ApiResult = deleteRowResult + + override suspend fun getFolders(): ApiResult> = ApiResult.Success(emptyList()) + + override suspend fun createFolder(name: String, parentId: String?): ApiResult = + ApiResult.Success(ListFolder("f", name, parentId)) + + companion object { + fun subscriptionFailure(): ApiResult.Failure = + ApiResult.Failure(AppError.SubscriptionRequired("Lists require an active subscription")) + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt new file mode 100644 index 0000000..df89d7a --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryTest.kt @@ -0,0 +1,193 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Repository behaviour against a real HTTP stack (Retrofit + OkHttp) driven by + * MockWebServer, with an in-memory DAO standing in for Room. Verifies DTO→domain + * mapping, offline-first caching, error normalisation, and the subscription gate. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var dao: FakeListDao + private lateinit var repository: DefaultListsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ListsApi::class.java) + dao = FakeListDao() + repository = DefaultListsRepository(api, dao, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `refreshLists maps DTOs and replaces the Room cache`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "data": [ + { "id": "1", "title": "Reading", "description": "Books", "itemCount": 3 }, + { "id": "2", "title": "Trips", "rowCount": 5, "isPublic": true } + ], + "pagination": { "total": 2, "limit": 20, "offset": 0, "hasMore": false } + } + """.trimIndent(), + ), + ) + + val result = repository.refreshLists() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val page = (result as ApiResult.Success).data + assertThat(page.items.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(page.items[0].itemCount).isEqualTo(3) + assertThat(page.items[1].itemCount).isEqualTo(5) + assertThat(page.hasMore).isFalse() + // Room is the source of truth: the cache now streams the same two lists. + assertThat(dao.observeLists().first().map { it.id }).containsExactly("1", "2") + } + + @Test + fun `refreshLists maps a subscription 403 to SubscriptionRequired`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription" }"""), + ) + + val result = repository.refreshLists() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `getListDetail composes metadata schema and rows generically`() = runTest(dispatcher) { + // 1) list + server.enqueue( + MockResponse().setBody( + """{ "list": { "id": "L1", "title": "Reading", "itemCount": 1 } }""", + ), + ) + // 2) schema (array DSL) + server.enqueue( + MockResponse().setBody( + """[ { "key": "title", "type": "text" }, { "key": "pages", "type": "number" } ]""", + ), + ) + // 3) rows + server.enqueue( + MockResponse().setBody( + """ + { "data": [ { "id": "r1", "data": { "title": "Dune", "pages": 412 } } ] } + """.trimIndent(), + ), + ) + + val result = repository.getListDetail("L1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val detail = (result as ApiResult.Success).data + assertThat(detail.summary.title).isEqualTo("Reading") + assertThat(detail.schema.fields.map { it.key }).containsExactly("title", "pages").inOrder() + assertThat(detail.rows.single().valueFor("title")).isEqualTo("Dune") + assertThat(detail.rows.single().valueFor("pages")).isEqualTo("412") + } + + @Test + fun `addRow posts the field map under data and returns the created row`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "row": { "id": "r9", "data": { "title": "New" } } }"""), + ) + + val result = repository.addRow("L1", mapOf("title" to "New", "blank" to "")) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.valueFor("title")).isEqualTo("New") + + val request: RecordedRequest = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/L1/data") + val body = request.body.readUtf8() + // The field map is nested under "data"; blanks are dropped. + assertThat(body).contains("\"data\"") + assertThat(body).contains("\"title\":\"New\"") + assertThat(body).doesNotContain("blank") + } + + @Test + fun `deleteList evicts from cache on success`() = runTest(dispatcher) { + dao.upsert(CachedListEntity("gone", "X", null, 0, null, false, null)) + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.deleteList("gone") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(dao.observeLists().first()).isEmpty() + } +} + +/** Minimal in-memory [ListDao] backed by a StateFlow, for JVM repository tests. */ +private class FakeListDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + + override fun observeLists(): Flow> = state + + override suspend fun upsertAll(lists: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + lists.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun upsert(list: CachedListEntity) = upsertAll(listOf(list)) + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListMapperTest.kt new file mode 100644 index 0000000..a698109 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListMapperTest.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto +import org.junit.Test + +class ListMapperTest { + + @Test + fun `summary resolves item count from any of the count fields`() { + assertThat(ListMapper.summaryFromDto(ListDto(id = "1", itemCount = 7)).itemCount).isEqualTo(7) + assertThat(ListMapper.summaryFromDto(ListDto(id = "1", rowCount = 3)).itemCount).isEqualTo(3) + assertThat(ListMapper.summaryFromDto(ListDto(id = "1", count = 9)).itemCount).isEqualTo(9) + assertThat(ListMapper.summaryFromDto(ListDto(id = "1")).itemCount).isEqualTo(0) + } + + @Test + fun `entity round trip preserves summary fields`() { + val summary = ListMapper.summaryFromDto( + ListDto( + id = "42", + title = "Reading", + description = "Books", + itemCount = 5, + folderId = "f1", + isPublic = true, + updatedAt = "2026-01-01", + ), + ) + + val restored = ListMapper.summaryFromEntity(ListMapper.summaryToEntity(summary)) + + assertThat(restored).isEqualTo(summary) + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt new file mode 100644 index 0000000..b79bf78 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/RowMapperTest.kt @@ -0,0 +1,54 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Test + +/** Rows carry dynamic values; the mapper coerces every JSON kind to a display string. */ +class RowMapperTest { + + private val json = Json { ignoreUnknownKeys = true } + + private fun row(dataJson: String): RowDto = RowDto( + id = "r1", + data = json.parseToJsonElement("""{ "data": $dataJson }""").jsonObject["data"]!!.jsonObject, + ) + + @Test + fun `coerces strings numbers and booleans to display strings`() { + val mapped = RowMapper.fromDto( + row("""{ "name": "Dune", "pages": 412, "read": true }"""), + ) + + assertThat(mapped.id).isEqualTo("r1") + assertThat(mapped.valueFor("name")).isEqualTo("Dune") + assertThat(mapped.valueFor("pages")).isEqualTo("412") + assertThat(mapped.valueFor("read")).isEqualTo("true") + } + + @Test + fun `null values become empty strings`() { + val mapped = RowMapper.fromDto(row("""{ "note": null }""")) + + assertThat(mapped.valueFor("note")).isEmpty() + } + + @Test + fun `arrays and nested objects flatten to readable strings`() { + val mapped = RowMapper.fromDto( + row("""{ "tags": ["a", "b"], "meta": { "k": 1 } }"""), + ) + + assertThat(mapped.valueFor("tags")).isEqualTo("a, b") + assertThat(mapped.valueFor("meta")).isEqualTo("k: 1") + } + + @Test + fun `missing key returns empty via valueFor`() { + val mapped = RowMapper.fromDto(row("""{ "present": "yes" }""")) + + assertThat(mapped.valueFor("absent")).isEmpty() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt new file mode 100644 index 0000000..7f60cc9 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.domain.FieldType +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * The list schema DSL reaches the client in several shapes; these tests pin the + * mapper's tolerance so generic column rendering never depends on one wire form. + */ +class SchemaMapperTest { + + private val json = Json { ignoreUnknownKeys = true } + + private fun parse(raw: String) = SchemaMapper.fromJson(json.parseToJsonElement(raw)) + + @Test + fun `parses an array of field descriptors preserving order and types`() { + val schema = parse( + """ + [ + { "key": "name", "label": "Full Name", "type": "text", "required": true }, + { "key": "age", "type": "number" }, + { "key": "active", "type": "boolean" }, + { "key": "site", "type": "url" } + ] + """.trimIndent(), + ) + + assertThat(schema.fields.map { it.key }).containsExactly("name", "age", "active", "site").inOrder() + val name = schema.fields.first() + assertThat(name.label).isEqualTo("Full Name") + assertThat(name.required).isTrue() + assertThat(schema.fields[1].type).isEqualTo(FieldType.NUMBER) + assertThat(schema.fields[2].type).isEqualTo(FieldType.BOOLEAN) + assertThat(schema.fields[3].type).isEqualTo(FieldType.URL) + } + + @Test + fun `parses an object keyed by field key and humanizes labels`() { + val schema = parse( + """ + { "first_name": { "type": "text" }, "signUpDate": { "type": "date" } } + """.trimIndent(), + ) + + val byKey = schema.fields.associateBy { it.key } + assertThat(byKey.keys).containsExactly("first_name", "signUpDate") + // Labels are humanized from the key when none is supplied. + assertThat(byKey["first_name"]!!.label).isEqualTo("First Name") + assertThat(byKey["signUpDate"]!!.label).isEqualTo("Sign Up Date") + assertThat(byKey["signUpDate"]!!.type).isEqualTo(FieldType.DATE) + } + + @Test + fun `parses a bare object of key to type strings`() { + val schema = parse("""{ "title": "text", "count": "number" }""") + + val byKey = schema.fields.associateBy { it.key } + assertThat(byKey["title"]!!.type).isEqualTo(FieldType.TEXT) + assertThat(byKey["count"]!!.type).isEqualTo(FieldType.NUMBER) + } + + @Test + fun `unwraps a properties wrapper object`() { + val schema = parse("""{ "properties": [ { "key": "note", "type": "text" } ] }""") + + assertThat(schema.fields).hasSize(1) + assertThat(schema.fields.first().key).isEqualTo("note") + } + + @Test + fun `reads select options`() { + val schema = parse( + """[ { "key": "status", "type": "select", "options": ["open", "closed"] } ]""", + ) + + val field = schema.fields.first() + assertThat(field.type).isEqualTo(FieldType.SELECT) + assertThat(field.options).containsExactly("open", "closed").inOrder() + } + + @Test + fun `unknown type falls back to text so the column is not dropped`() { + val schema = parse("""[ { "key": "misc", "type": "wormhole" } ]""") + + assertThat(schema.fields.first().type).isEqualTo(FieldType.TEXT) + } + + @Test + fun `null and primitive schemas yield an empty schema`() { + assertThat(SchemaMapper.fromJson(null).isEmpty).isTrue() + assertThat(parse("\"nope\"").isEmpty).isTrue() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt new file mode 100644 index 0000000..7f846c7 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt @@ -0,0 +1,136 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListDetail +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.SchemaField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ListDetailViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val schema = ListSchema( + listOf( + SchemaField("title", "Title", FieldType.TEXT), + SchemaField("done", "Done", FieldType.BOOLEAN), + ), + ) + + private fun detail(rows: List) = ListDetail( + summary = ListSummary("L1", "Reading", "Books", rows.size, null, false, null), + schema = schema, + rows = rows, + ) + + private fun viewModel(repo: FakeListsRepository) = + ListDetailViewModel(repo, SavedStateHandle(mapOf(LIST_ID_ARG to "L1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads schema and rows on init`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(listOf(ListRow("r1", mapOf("title" to "Dune"))))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.schema.fields.map { it.key }).containsExactly("title", "done").inOrder() + assertThat(state.rows).hasSize(1) + assertThat(state.rows.first().valueFor("title")).isEqualTo("Dune") + } + + @Test + fun `addRow appends the new row to state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(emptyList())) + addRowResult = ApiResult.Success(ListRow("r-new", mapOf("title" to "Hyperion"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + var done = false + vm.addRow(mapOf("title" to "Hyperion")) { done = true } + advanceUntilIdle() + + assertThat(done).isTrue() + assertThat(vm.uiState.value.rows.map { it.id }).containsExactly("r-new") + assertThat(vm.uiState.value.isSaving).isFalse() + } + + @Test + fun `updateRow replaces the matching row`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(listOf(ListRow("r1", mapOf("title" to "Old"))))) + updateRowResult = ApiResult.Success(ListRow("r1", mapOf("title" to "New"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.updateRow("r1", mapOf("title" to "New")) + advanceUntilIdle() + + assertThat(vm.uiState.value.rows.single().valueFor("title")).isEqualTo("New") + } + + @Test + fun `deleteRow removes the row from state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success( + detail(listOf(ListRow("r1", emptyMap()), ListRow("r2", emptyMap()))), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.deleteRow("r1") + advanceUntilIdle() + + assertThat(vm.uiState.value.rows.map { it.id }).containsExactly("r2") + } + + @Test + fun `deleteList flags deleted and invokes callback`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(detail(emptyList())) } + val vm = viewModel(repo) + advanceUntilIdle() + + var deleted = false + vm.deleteList { deleted = true } + advanceUntilIdle() + + assertThat(deleted).isTrue() + assertThat(vm.uiState.value.deleted).isTrue() + } + + @Test + fun `subscription gate on load surfaces the upsell flag`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = FakeListsRepository.subscriptionFailure() } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.isLoading).isFalse() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt new file mode 100644 index 0000000..d369a25 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsViewModelTest.kt @@ -0,0 +1,164 @@ +package com.interlinedlist.android.feature.lists.ui.list + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.Paged +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ListsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun summary(id: String) = ListSummary(id, "List $id", null, 0, null, false, null) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `init refreshes and streams cached lists from the repository`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + refreshResult = ApiResult.Success( + Paged(listOf(summary("1"), summary("2")), hasMore = true, total = 5, offset = 2), + ) + } + val vm = ListsViewModel(repo) + + vm.uiState.test { + awaitItem() // initial + advanceUntilIdle() + val loaded = expectMostRecentItem() + assertThat(loaded.lists.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(loaded.isRefreshing).isFalse() + assertThat(loaded.hasMore).isTrue() + assertThat(loaded.nextOffset).isEqualTo(2) + } + assertThat(repo.refreshCount).isEqualTo(1) + } + + @Test + fun `refresh failure surfaces error but keeps cached lists visible`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + cache.value = listOf(summary("cached")) + refreshResult = ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.Network("offline"), + ) + } + val vm = ListsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } // keep the combined flow active + advanceUntilIdle() + + val state = vm.uiState.value + // Offline-first: the Room stream still shows what was cached. + assertThat(state.lists.map { it.id }).containsExactly("cached") + assertThat(state.errorMessage).isNotNull() + assertThat(state.isRefreshing).isFalse() + } + + @Test + fun `subscription gate is flagged for an upsell state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { refreshResult = FakeListsRepository.subscriptionFailure() } + val vm = ListsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + } + + @Test + fun `loadMore appends the next page and updates pagination`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + refreshResult = ApiResult.Success(Paged(listOf(summary("1")), hasMore = true, total = 2, offset = 1)) + loadMoreResult = ApiResult.Success(Paged(listOf(summary("2")), hasMore = false, total = 2, offset = 2)) + } + val vm = ListsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(1) + assertThat(vm.uiState.value.lists.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(vm.uiState.value.hasMore).isFalse() + } + + @Test + fun `loadMore is skipped when no more pages remain`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + refreshResult = ApiResult.Success(Paged(listOf(summary("1")), hasMore = false, total = 1, offset = 1)) + } + val vm = ListsViewModel(repo) + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(0) + } + + @Test + fun `createList reports the created list id via callback`() = runTest(dispatcher) { + val repo = FakeListsRepository() + val vm = ListsViewModel(repo) + advanceUntilIdle() + + var createdId: String? = null + vm.createList("Groceries", null) { createdId = it.id } + advanceUntilIdle() + + assertThat(createdId).isEqualTo("new") + } + + @Test + fun `search shows server results and clearing restores the cached index`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + refreshResult = ApiResult.Success(Paged(listOf(summary("cached")), hasMore = false, total = 1, offset = 1)) + searchResult = ApiResult.Success(listOf(summary("hit"))) + } + val vm = ListsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + vm.onSearchQueryChange("hit") + advanceUntilIdle() + assertThat(vm.uiState.value.visibleLists.map { it.id }).containsExactly("hit") + assertThat(vm.uiState.value.isSearching).isTrue() + + vm.onSearchQueryChange("") + advanceUntilIdle() + assertThat(vm.uiState.value.visibleLists.map { it.id }).containsExactly("cached") + assertThat(vm.uiState.value.isSearching).isFalse() + } + + @Test + fun `deleteList removes the row from the cached stream`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + refreshResult = ApiResult.Success( + Paged(listOf(summary("1"), summary("2")), hasMore = false, total = 2, offset = 2), + ) + } + val vm = ListsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + vm.deleteList("1") + advanceUntilIdle() + + assertThat(vm.uiState.value.lists.map { it.id }).containsExactly("2") + } +} diff --git a/feature/messages/build.gradle.kts b/feature/messages/build.gradle.kts index d5b7c95..c7ff2ba 100644 --- a/feature/messages/build.gradle.kts +++ b/feature/messages/build.gradle.kts @@ -32,7 +32,6 @@ dependencies { implementation(project(":core:network")) implementation(project(":core:datastore")) - // Compose implementation(platform(libs.androidx.compose.bom)) implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.material3) @@ -41,27 +40,21 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.navigation.compose) - // DI implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) - // Networking (Retrofit annotations + serialization for DTOs) + // Networking (DTOs are serialized via the shared Retrofit/Json). implementation(libs.retrofit.core) implementation(libs.kotlinx.serialization.json) - implementation(libs.kotlinx.coroutines.core) - // Feature-local Room cache (offline-first) + // This module owns its own Room cache (does not touch :core:database). implementation(libs.room.runtime) implementation(libs.room.ktx) - implementation(libs.room.paging) ksp(libs.room.compiler) - implementation(libs.androidx.paging.runtime) - implementation(libs.androidx.paging.compose) - // Images + // Avatars / message media thumbnails. implementation(libs.coil.compose) // Unit tests @@ -69,7 +62,11 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.truth) + // Repository tests exercise the real Retrofit stack against a local server. testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) // Instrumented / UI tests androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/feature/messages/src/androidTest/AndroidManifest.xml b/feature/messages/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..cf47aaf --- /dev/null +++ b/feature/messages/src/androidTest/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt new file mode 100644 index 0000000..a8392c6 --- /dev/null +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt @@ -0,0 +1,89 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MessagesFeedScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun message(id: String, body: String) = Message( + id = id, content = body, authorId = "u1", authorUsername = "adron", + authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = false, + ) + + /** Hosts the stateless feed with a tiny in-memory state holder. */ + private fun setFeed( + initial: MessagesFeedUiState, + onOpenMessage: (String) -> Unit = {}, + ) { + composeRule.setContent { + var state by mutableStateOf(initial) + InterlinedListTheme { + MessagesFeedScreen( + state = state, + onRefresh = {}, + onLoadMore = {}, + onOpenMessage = onOpenMessage, + onDig = {}, + onDelete = {}, + onOpenCompose = { state = state.copy(isComposeOpen = true) }, + onDismissCompose = { state = state.copy(isComposeOpen = false) }, + onComposeTextChange = { state = state.copy(composeText = it) }, + onPost = {}, + ) + } + } + } + + @Test + fun emptyState_isShown_whenThereAreNoMessages() { + setFeed(MessagesFeedUiState(messages = emptyList())) + composeRule.onNodeWithTag(MessagesFeedTags.EMPTY).assertIsDisplayed() + } + + @Test + fun messages_areRendered_inTheList() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "First post")))) + composeRule.onNodeWithText("First post").assertIsDisplayed() + } + + @Test + fun tappingMessage_invokesOpenCallback() { + var opened: String? = null + setFeed( + MessagesFeedUiState(messages = listOf(message("42", "Tap me"))), + onOpenMessage = { opened = it }, + ) + composeRule.onNodeWithText("Tap me").performClick() + assert(opened == "42") + } + + @Test + fun subscriptionGate_showsLockedState_andHidesFab() { + setFeed(MessagesFeedUiState(subscriptionRequired = true, errorMessage = "Subscribers only")) + composeRule.onNodeWithTag(MessagesFeedTags.LOCKED).assertIsDisplayed() + } + + @Test + fun fab_opensComposeSheet() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "hi")))) + composeRule.onNodeWithTag(MessagesFeedTags.FAB).performClick() + composeRule.onNodeWithTag(MessagesFeedTags.COMPOSE_INPUT).assertIsDisplayed() + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt new file mode 100644 index 0000000..f654926 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -0,0 +1,185 @@ +package com.interlinedlist.android.feature.messages.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.datastore.SessionStore +import com.interlinedlist.android.feature.messages.data.local.MessageDao +import com.interlinedlist.android.feature.messages.data.local.toDomain +import com.interlinedlist.android.feature.messages.data.local.toEntity +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto +import com.interlinedlist.android.feature.messages.data.remote.dto.toDomain +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.messages.domain.Message +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultMessagesRepository @Inject constructor( + private val api: MessagesApi, + private val messageDao: MessageDao, + private val sessionStore: SessionStore, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : MessagesRepository { + + override fun observeFeed(): Flow> = + messageDao.observeFeed().map { rows -> rows.map { it.toDomain() } } + + override fun observeReplies(messageId: String): Flow> = + messageDao.observeReplies(messageId).map { rows -> rows.map { it.toDomain() } } + + override fun observeMessage(messageId: String): Flow = + messageDao.observeMessage(messageId).map { it?.toDomain() } + + override suspend fun refreshFeed(): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { + is ApiResult.Success -> { + val page = result.data + val entities = page.data.mapIndexed { index, dto -> + dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong()) + } + messageDao.clearFeed() + messageDao.insertAll(entities) + ApiResult.Success(page.pagination.hasMore) + } + is ApiResult.Failure -> result + } + } + + override suspend fun loadMoreFeed(currentCount: Int): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { + api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = currentCount) + }) { + is ApiResult.Success -> { + val page = result.data + val base = (messageDao.maxFeedOrder() ?: -1L) + 1L + val entities = page.data.mapIndexed { index, dto -> + dto.toDomain(currentUserId()).toEntity(feedOrder = base + index) + } + messageDao.insertAll(entities) + ApiResult.Success(page.pagination.hasMore) + } + is ApiResult.Failure -> result + } + } + + override suspend fun createMessage(content: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.createMessage(CreateMessageRequest(content = content)) }) { + is ApiResult.Success -> { + val message = result.data.message.toDomain(currentUserId()) + // Insert at the very top of the feed. + val topOrder = (messageDao.maxFeedOrder() ?: 0L) + messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L)) + ApiResult.Success(message) + } + is ApiResult.Failure -> result + } + } + + override suspend fun fetchMessage(messageId: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.getMessage(messageId) }) { + is ApiResult.Success -> { + val message = result.data.message.toDomain(currentUserId()) + messageDao.upsert(message.toEntity(feedOrder = existingOrderOrTop(messageId))) + ApiResult.Success(message) + } + is ApiResult.Failure -> result + } + } + + override suspend fun refreshReplies(messageId: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.getReplies(messageId) }) { + is ApiResult.Success -> { + val entities = result.data.data.mapIndexed { index, dto -> + dto.toDomain(currentUserId()) + .copy(parentId = messageId) + .toEntity(feedOrder = index.toLong()) + } + messageDao.insertAll(entities) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun postReply(parentId: String, content: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeCall { + api.createMessage(CreateMessageRequest(content = content, parentId = parentId)) + }) { + is ApiResult.Success -> { + val reply = result.data.message.toDomain(currentUserId()).copy(parentId = parentId) + val base = (messageDao.maxFeedOrder() ?: 0L) + 1L + messageDao.upsert(reply.toEntity(feedOrder = base)) + // Reflect the new reply count on the parent if it is cached. + bumpReplyCount(parentId, delta = 1) + ApiResult.Success(reply) + } + is ApiResult.Failure -> result + } + } + + override suspend fun setDug(messageId: String, dug: Boolean): ApiResult = + withContext(dispatchers.io) { + // Optimistically update the cache so the UI reacts immediately. + val previous = currentEntity(messageId) + if (previous != null) { + val delta = if (dug) 1 else -1 + messageDao.upsert( + previous.copy( + dugByMe = dug, + digCount = (previous.digCount + delta).coerceAtLeast(0), + ), + ) + } + val result = safeCall { if (dug) api.dig(messageId) else api.undig(messageId) } + if (result is ApiResult.Failure && previous != null) { + // Roll the optimistic change back on failure. + messageDao.upsert(previous) + } + result + } + + override suspend fun deleteMessage(messageId: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.deleteMessage(messageId) }) { + is ApiResult.Success -> { + messageDao.deleteById(messageId) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun search(query: String): ApiResult> = withContext(dispatchers.io) { + when (val result = safeCall { + api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0) + }) { + is ApiResult.Success -> + ApiResult.Success(result.data.data.map { it.toDomain(currentUserId()) }) + is ApiResult.Failure -> result + } + } + + // --- helpers ----------------------------------------------------------- + + private suspend fun safeCall(block: suspend () -> T): ApiResult = + safeApiCall(json, block) + + private fun currentUserId(): String? = sessionStore.userId + + /** Current cached row for [id], or null. Snapshots the observe Flow. */ + private suspend fun currentEntity(id: String) = messageDao.observeMessage(id).first() + + private suspend fun existingOrderOrTop(id: String): Long = + currentEntity(id)?.feedOrder ?: ((messageDao.maxFeedOrder() ?: 0L) + 1L) + + private suspend fun bumpReplyCount(parentId: String, delta: Int) { + val parent = currentEntity(parentId) ?: return + messageDao.upsert(parent.copy(replyCount = (parent.replyCount + delta).coerceAtLeast(0))) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt new file mode 100644 index 0000000..08b041d --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -0,0 +1,55 @@ +package com.interlinedlist.android.feature.messages.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.messages.domain.Message +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to the social message feed. Room is the source of truth: + * reads are Flows off the cache; network refreshes upsert into Room and let the + * Flows re-emit. Mutations optimistically update the cache where it improves UX. + */ +interface MessagesRepository { + + /** The cached top-level feed, newest-first, re-emitting on every change. */ + fun observeFeed(): Flow> + + /** Cached replies to [messageId], re-emitting on every change. */ + fun observeReplies(messageId: String): Flow> + + /** A single cached message (or null), re-emitting on change. */ + fun observeMessage(messageId: String): Flow + + /** + * Refreshes the first page of the feed from the API and replaces the cached + * feed. Returns whether more pages are available. + */ + suspend fun refreshFeed(): ApiResult + + /** + * Fetches and appends the next feed page after [currentCount] items. + * Returns whether still more pages remain. + */ + suspend fun loadMoreFeed(currentCount: Int): ApiResult + + /** Creates a new top-level message and caches it. */ + suspend fun createMessage(content: String): ApiResult + + /** Fetches a single message and caches it (for the detail screen). */ + suspend fun fetchMessage(messageId: String): ApiResult + + /** Refreshes the replies of [messageId] from the API into the cache. */ + suspend fun refreshReplies(messageId: String): ApiResult + + /** Posts a reply to [parentId] and caches it. */ + suspend fun postReply(parentId: String, content: String): ApiResult + + /** Digs or undigs a message; optimistically updates the cache. */ + suspend fun setDug(messageId: String, dug: Boolean): ApiResult + + /** Deletes one of the caller's own messages, removing it from the cache. */ + suspend fun deleteMessage(messageId: String): ApiResult + + /** Full-text search over top-level messages (does not touch the feed cache). */ + suspend fun search(query: String): ApiResult> +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt new file mode 100644 index 0000000..07beede --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.feature.messages.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + + /** Top-level feed messages in server order; re-emits on every change. */ + @Query("SELECT * FROM message WHERE parentId IS NULL ORDER BY feedOrder ASC") + fun observeFeed(): Flow> + + /** Direct replies to a message in server order. */ + @Query("SELECT * FROM message WHERE parentId = :parentId ORDER BY feedOrder ASC") + fun observeReplies(parentId: String): Flow> + + /** A single cached message (or null), re-emitting on change. */ + @Query("SELECT * FROM message WHERE id = :id") + fun observeMessage(id: String): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(messages: List) + + @Upsert + suspend fun upsert(message: MessageEntity) + + @Query("DELETE FROM message WHERE id = :id") + suspend fun deleteById(id: String) + + /** Clears the top-level feed (used before writing a fresh refresh page). */ + @Query("DELETE FROM message WHERE parentId IS NULL") + suspend fun clearFeed() + + /** Largest feed-order position currently stored (for append/load-more). */ + @Query("SELECT MAX(feedOrder) FROM message WHERE parentId IS NULL") + suspend fun maxFeedOrder(): Long? +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt new file mode 100644 index 0000000..4f1f94c --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt @@ -0,0 +1,59 @@ +package com.interlinedlist.android.feature.messages.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.interlinedlist.android.feature.messages.domain.Message + +/** + * Locally cached message row — this module's own offline-first source of truth + * for the feed and detail screens. Kept flat (author fields inlined) so a single + * table serves both list and detail reads without joins. + */ +@Entity(tableName = "message") +data class MessageEntity( + @PrimaryKey val id: String, + val content: String, + val authorId: String, + val authorUsername: String, + val authorDisplayName: String?, + val authorAvatarUrl: String?, + val createdAt: String?, + val digCount: Int, + val replyCount: Int, + val dugByMe: Boolean, + val parentId: String?, + val mine: Boolean, + /** Server-relative ordering position captured at fetch time (feed order). */ + val feedOrder: Long, +) + +fun MessageEntity.toDomain(): Message = Message( + id = id, + content = content, + authorId = authorId, + authorUsername = authorUsername, + authorDisplayName = authorDisplayName, + authorAvatarUrl = authorAvatarUrl, + createdAt = createdAt, + digCount = digCount, + replyCount = replyCount, + dugByMe = dugByMe, + parentId = parentId, + mine = mine, +) + +fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( + id = id, + content = content, + authorId = authorId, + authorUsername = authorUsername, + authorDisplayName = authorDisplayName, + authorAvatarUrl = authorAvatarUrl, + createdAt = createdAt, + digCount = digCount, + replyCount = replyCount, + dugByMe = dugByMe, + parentId = parentId, + mine = mine, + feedOrder = feedOrder, +) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt new file mode 100644 index 0000000..93f58dc --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.messages.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * This feature module's own Room cache, separate from `:core:database`'s + * `InterlinedListDatabase`. A disposable cache during early development. + */ +@Database( + entities = [MessageEntity::class], + version = 1, + exportSchema = false, +) +abstract class MessagesDatabase : RoomDatabase() { + abstract fun messageDao(): MessageDao +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt new file mode 100644 index 0000000..ce8402b --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -0,0 +1,58 @@ +package com.interlinedlist.android.feature.messages.data.remote + +import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.MessageResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.MessagesResponse +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the Messages endpoints. Provided from the shared, + * already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), + * so every call here is authed. + */ +interface MessagesApi { + + /** Feed of top-level messages, newest first, offset/limit paginated. */ + @GET("api/messages") + suspend fun getMessages( + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): MessagesResponse + + /** Creates a new message (or a reply when `parentId` is set). */ + @POST("api/messages") + suspend fun createMessage(@Body body: CreateMessageRequest): MessageResponse + + /** A single message by id (for the detail screen). */ + @GET("api/messages/{id}") + suspend fun getMessage(@Path("id") id: String): MessageResponse + + /** Direct replies to a message. */ + @GET("api/messages/{id}/replies") + suspend fun getReplies(@Path("id") id: String): MessagesResponse + + /** Digs a message. */ + @POST("api/messages/{id}/dig") + suspend fun dig(@Path("id") id: String) + + /** Removes the caller's dig from a message. */ + @DELETE("api/messages/{id}/dig") + suspend fun undig(@Path("id") id: String) + + /** Deletes one of the caller's own messages. */ + @DELETE("api/messages/{id}") + suspend fun deleteMessage(@Path("id") id: String) + + /** Full-text search over top-level messages. */ + @GET("api/messages/search") + suspend fun search( + @Query("q") query: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): MessagesResponse +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt new file mode 100644 index 0000000..71c122a --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt @@ -0,0 +1,54 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.interlinedlist.android.feature.messages.domain.Message +import kotlinx.serialization.Serializable + +/** + * Wire model for a message returned by the Messages endpoints. + * + * The OpenAPI extract does not pin the response schema, so this mirrors the web + * feed's shape: an author sub-object plus body/timestamp/engagement fields. The + * shared [kotlinx.serialization.json.Json] is configured with `ignoreUnknownKeys`, + * so extra fields (crossposting, metadata, media, …) are tolerated and dropped. + */ +@Serializable +data class MessageDto( + val id: String, + val content: String = "", + val author: MessageAuthorDto? = null, + val createdAt: String? = null, + val digCount: Int = 0, + val replyCount: Int = 0, + val dugByCurrentUser: Boolean = false, + val parentId: String? = null, + /** Present on some payloads; used to flag the message as the caller's own. */ + val isOwn: Boolean = false, +) + +/** Author identity embedded in a message. */ +@Serializable +data class MessageAuthorDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, + val avatar: String? = null, +) + +/** + * Maps the wire model into the domain [Message]. [currentUserId] lets us flag + * the caller's own messages (for delete) even when the API omits `isOwn`. + */ +fun MessageDto.toDomain(currentUserId: String?): Message = Message( + id = id, + content = content, + authorId = author?.id.orEmpty(), + authorUsername = author?.username.orEmpty(), + authorDisplayName = author?.displayName, + authorAvatarUrl = author?.avatar, + createdAt = createdAt, + digCount = digCount, + replyCount = replyCount, + dugByMe = dugByCurrentUser, + parentId = parentId, + mine = isOwn || (currentUserId != null && author?.id == currentUserId), +) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt new file mode 100644 index 0000000..1189652 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Paginated list envelope shared by the feed, replies, and search endpoints: + * `{ data: [...], pagination: { total, limit, offset, hasMore } }`. + */ +@Serializable +data class MessagesResponse( + val data: List = emptyList(), + val pagination: PaginationDto = PaginationDto(), +) + +/** Pagination cursor returned alongside a list of messages. */ +@Serializable +data class PaginationDto( + val total: Int = 0, + val limit: Int = DEFAULT_LIMIT, + val offset: Int = 0, + val hasMore: Boolean = false, +) { + companion object { + const val DEFAULT_LIMIT = 20 + } +} + +/** Single-message envelope: `{ message: { ... } }`. */ +@Serializable +data class MessageResponse( + val message: MessageDto, +) + +/** Request body for creating a message or posting a reply. */ +@Serializable +data class CreateMessageRequest( + val content: String, + val parentId: String? = null, +) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/di/MessagesModule.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/di/MessagesModule.kt new file mode 100644 index 0000000..dcc9434 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/di/MessagesModule.kt @@ -0,0 +1,57 @@ +package com.interlinedlist.android.feature.messages.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.feature.messages.data.DefaultMessagesRepository +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.data.local.MessageDao +import com.interlinedlist.android.feature.messages.data.local.MessagesDatabase +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the Messages repository interface to its implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class MessagesRepositoryModule { + + @Binds + @Singleton + abstract fun bindMessagesRepository(impl: DefaultMessagesRepository): MessagesRepository +} + +/** + * Provides the Messages data layer: a Retrofit API from the shared, authenticated + * [Retrofit] singleton, and this module's own Room cache (a distinct db file from + * `:core:database`). + */ +@Module +@InstallIn(SingletonComponent::class) +object MessagesDataModule { + + @Provides + @Singleton + fun provideMessagesApi(retrofit: Retrofit): MessagesApi = + retrofit.create(MessagesApi::class.java) + + @Provides + @Singleton + fun provideMessagesDatabase(@ApplicationContext context: Context): MessagesDatabase = + Room.databaseBuilder( + context, + MessagesDatabase::class.java, + "interlinedlist-messages.db", + ) + // Disposable cache during early development; real migrations come later. + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideMessageDao(db: MessagesDatabase): MessageDao = db.messageDao() +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt new file mode 100644 index 0000000..52bb355 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt @@ -0,0 +1,29 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * A single message in the social feed, normalised from the API wire model into a + * platform-independent domain type. Mirrors the fields the web feed renders: + * author identity + avatar, body content, a creation timestamp, and the dig / + * reply engagement counts (plus whether the current user has dug it). + */ +data class Message( + val id: String, + val content: String, + val authorId: String, + val authorUsername: String, + val authorDisplayName: String?, + val authorAvatarUrl: String?, + /** ISO-8601 creation instant, used to derive a relative timestamp for display. */ + val createdAt: String?, + val digCount: Int, + val replyCount: Int, + /** Whether the signed-in user has dug this message (drives the dig toggle). */ + val dugByMe: Boolean, + /** Parent message id when this is a reply; null for top-level feed messages. */ + val parentId: String?, + /** True when this message belongs to the signed-in user (enables delete). */ + val mine: Boolean, +) { + /** Best available display label for the author. */ + val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MessagesErrorMessages.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MessagesErrorMessages.kt new file mode 100644 index 0000000..659bb6a --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MessagesErrorMessages.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.messages.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the feed UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> "Your session expired. Please sign in again." + is AppError.SubscriptionRequired -> message ?: "The feed requires an active subscription." + is AppError.NotFound -> "This message is no longer available." + is AppError.RateLimited -> "Slow down a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} + +/** True when the error should render the subscription upsell/locked state. */ +val AppError.isSubscriptionGate: Boolean + get() = this is AppError.SubscriptionRequired diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTime.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTime.kt new file mode 100644 index 0000000..68fb03b --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTime.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.messages.ui + +import java.time.Duration +import java.time.Instant + +/** + * Formats an ISO-8601 instant as a short relative label ("just now", "5m", "3h", + * "2d", "4w"). Falls back to the raw string when it cannot be parsed, and to an + * empty string when null, so the UI never crashes on unexpected timestamps. + * + * [now] is injectable to keep the mapping deterministic in tests. + */ +fun relativeTime(isoTimestamp: String?, now: Instant = Instant.now()): String { + if (isoTimestamp.isNullOrBlank()) return "" + val then = runCatching { Instant.parse(isoTimestamp) }.getOrElse { + return@relativeTime isoTimestamp + } + val seconds = Duration.between(then, now).seconds + if (seconds < 0) return "just now" + return when { + seconds < 60 -> "just now" + seconds < 3_600 -> "${seconds / 60}m" + seconds < 86_400 -> "${seconds / 3_600}h" + seconds < 604_800 -> "${seconds / 86_400}d" + else -> "${seconds / 604_800}w" + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt new file mode 100644 index 0000000..96eb5d5 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt @@ -0,0 +1,198 @@ +package com.interlinedlist.android.feature.messages.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.ChatBubbleOutline +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.relativeTime + +/** Stable test tags for the message card controls. */ +object MessageCardTags { + const val DIG = "messageDig" + const val REPLY = "messageReply" + const val MENU = "messageMenu" + const val DELETE = "messageDelete" + const val BODY = "messageBody" +} + +/** + * One message in a feed or reply list: avatar, author + relative time, body, and + * the dig / reply engagement row. An overflow menu exposes delete for own messages. + */ +@Composable +fun MessageCard( + message: Message, + onClick: () -> Unit, + onDig: () -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Avatar(url = message.authorAvatarUrl, label = message.authorLabel) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = message.authorLabel, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + val time = relativeTime(message.createdAt) + if (time.isNotEmpty()) { + Text( + text = " · $time", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.weight(1f)) + if (message.mine) { + OwnMessageMenu(onDelete = onDelete) + } + } + Spacer(Modifier.size(4.dp)) + Text( + text = message.content, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag(MessageCardTags.BODY), + ) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Engagement( + icon = if (message.dugByMe) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + tint = if (message.dugByMe) MaterialTheme.colorScheme.secondary + else MaterialTheme.colorScheme.onSurfaceVariant, + count = message.digCount, + contentDescription = "Dig", + onClick = onDig, + tag = MessageCardTags.DIG, + ) + Engagement( + icon = Icons.Outlined.ChatBubbleOutline, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + count = message.replyCount, + contentDescription = "Replies", + onClick = onClick, + tag = MessageCardTags.REPLY, + ) + } + } + } +} + +@Composable +private fun OwnMessageMenu(onDelete: () -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { expanded = true }, + modifier = Modifier.testTag(MessageCardTags.MENU), + ) { + Icon(Icons.Filled.MoreVert, contentDescription = "More options") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + expanded = false + onDelete() + }, + modifier = Modifier.testTag(MessageCardTags.DELETE), + ) + } + } +} + +@Composable +private fun Engagement( + icon: androidx.compose.ui.graphics.vector.ImageVector, + tint: Color, + count: Int, + contentDescription: String, + onClick: () -> Unit, + tag: String, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(onClick = onClick) + .testTag(tag), + ) { + Icon(icon, contentDescription = contentDescription, tint = tint, modifier = Modifier.size(18.dp)) + if (count > 0) { + Spacer(Modifier.width(4.dp)) + Text( + text = count.toString(), + style = MaterialTheme.typography.labelMedium, + color = tint, + ) + } + } +} + +@Composable +private fun Avatar(url: String?, label: String) { + val shape = CircleShape + if (url.isNullOrBlank()) { + // Fallback initial monogram when the author has no avatar. + Box( + modifier = Modifier + .size(40.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center, + ) { + Text( + text = label.take(1).uppercase(), + color = MaterialTheme.colorScheme.onPrimary, + style = MaterialTheme.typography.titleMedium, + ) + } + } else { + AsyncImage( + model = url, + contentDescription = "$label avatar", + modifier = Modifier + .size(40.dp) + .clip(shape), + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt new file mode 100644 index 0000000..f26c8f3 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt @@ -0,0 +1,283 @@ +package com.interlinedlist.android.feature.messages.ui.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.components.MessageCard + +/** Stable test tags for the detail screen. */ +object MessageDetailTags { + const val ROOT = "messageDetailRoot" + const val REPLIES = "messageDetailReplies" + const val REPLY_INPUT = "messageDetailReplyInput" + const val REPLY_SEND = "messageDetailReplySend" + const val PROGRESS = "messageDetailProgress" + const val ERROR = "messageDetailError" + const val LOCKED = "messageDetailLocked" +} + +/** + * Hilt-wired detail entry point. Reads its message id from the nav argument + * ([MESSAGE_ID_ARG]) via SavedStateHandle. + * + * @param onBack pops the detail screen off the back stack. + * @param onOpenMessage navigates into a reply (which is itself a message). + */ +@Composable +fun MessageDetailRoute( + onBack: () -> Unit, + onOpenMessage: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: MessageDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + MessageDetailScreen( + state = state, + onBack = onBack, + onOpenMessage = onOpenMessage, + onDig = viewModel::onDig, + onReplyTextChange = viewModel::onReplyTextChange, + onPostReply = viewModel::postReply, + onRetry = viewModel::load, + modifier = modifier, + ) +} + +/** Stateless detail UI: the message, a reply composer, and the reply list. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessageDetailScreen( + state: MessageDetailUiState, + onBack: () -> Unit, + onOpenMessage: (String) -> Unit, + onDig: () -> Unit, + onReplyTextChange: (String) -> Unit, + onPostReply: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier + .fillMaxSize() + .testTag(MessageDetailTags.ROOT), + topBar = { + TopAppBar( + title = { Text("Thread") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> Locked(state.errorMessage, Modifier.padding(padding)) + state.message == null && state.isLoading -> Loading(Modifier.padding(padding)) + state.message == null && state.errorMessage != null -> + ErrorState(state.errorMessage, onRetry, Modifier.padding(padding)) + else -> Content( + state = state, + contentPadding = padding, + onOpenMessage = onOpenMessage, + onDig = onDig, + onReplyTextChange = onReplyTextChange, + onPostReply = onPostReply, + ) + } + } +} + +@Composable +private fun Content( + state: MessageDetailUiState, + contentPadding: androidx.compose.foundation.layout.PaddingValues, + onOpenMessage: (String) -> Unit, + onDig: () -> Unit, + onReplyTextChange: (String) -> Unit, + onPostReply: () -> Unit, +) { + val message = state.message + Column( + Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + LazyColumn( + modifier = Modifier + .weight(1f) + .testTag(MessageDetailTags.REPLIES), + ) { + if (message != null) { + item { + MessageCard( + message = message, + onClick = {}, + onDig = onDig, + onDelete = {}, + ) + HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant) + Text( + text = "Replies", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(16.dp), + ) + } + } + items(state.replies, key = { it.id }) { reply -> + MessageCard( + message = reply, + onClick = { onOpenMessage(reply.id) }, + onDig = {}, + onDelete = {}, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + ReplyComposer( + text = state.replyText, + canReply = state.canReply, + isPosting = state.isPostingReply, + onTextChange = onReplyTextChange, + onSend = onPostReply, + ) + } +} + +@Composable +private fun ReplyComposer( + text: String, + canReply: Boolean, + isPosting: Boolean, + onTextChange: (String) -> Unit, + onSend: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text("Write a reply…") }, + enabled = !isPosting, + modifier = Modifier + .weight(1f) + .testTag(MessageDetailTags.REPLY_INPUT), + ) + Spacer(Modifier.height(8.dp)) + IconButton( + onClick = onSend, + enabled = canReply, + modifier = Modifier.testTag(MessageDetailTags.REPLY_SEND), + ) { + if (isPosting) { + CircularProgressIndicator(Modifier.height(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send reply") + } + } + } +} + +@Composable +private fun Loading(modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(MessageDetailTags.PROGRESS)) + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(MessageDetailTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.Button(onClick = onRetry) { Text("Retry") } + } +} + +@Composable +private fun Locked(message: String?, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = message ?: "This thread requires an active subscription.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(MessageDetailTags.LOCKED), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun MessageDetailPreview() { + InterlinedListTheme { + MessageDetailScreen( + state = MessageDetailUiState( + message = Message( + id = "1", content = "Parent message", + authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", + authorAvatarUrl = null, createdAt = null, digCount = 1, replyCount = 1, + dugByMe = false, parentId = null, mine = false, + ), + replies = listOf( + Message( + id = "2", content = "A reply", + authorId = "u2", authorUsername = "guest", authorDisplayName = null, + authorAvatarUrl = null, createdAt = null, digCount = 0, replyCount = 0, + dugByMe = false, parentId = "1", mine = false, + ), + ), + ), + onBack = {}, onOpenMessage = {}, onDig = {}, onReplyTextChange = {}, + onPostReply = {}, onRetry = {}, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt new file mode 100644 index 0000000..40e8c23 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt @@ -0,0 +1,132 @@ +package com.interlinedlist.android.feature.messages.ui.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate +import com.interlinedlist.android.feature.messages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav argument key for the message id the detail screen renders. */ +const val MESSAGE_ID_ARG = "messageId" + +/** Detail screen state: the message, its replies, and transient flags. */ +data class MessageDetailUiState( + val message: Message? = null, + val replies: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val replyText: String = "", + val isPostingReply: Boolean = false, +) { + val canReply: Boolean get() = replyText.isNotBlank() && !isPostingReply +} + +private data class DetailTransientState( + val isLoading: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val replyText: String = "", + val isPostingReply: Boolean = false, +) + +@HiltViewModel +class MessageDetailViewModel @Inject constructor( + private val repository: MessagesRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val messageId: String = requireNotNull(savedStateHandle[MESSAGE_ID_ARG]) { + "MessageDetailViewModel requires a '$MESSAGE_ID_ARG' nav argument" + } + + private val transient = MutableStateFlow(DetailTransientState()) + + val uiState: StateFlow = + combine( + repository.observeMessage(messageId), + repository.observeReplies(messageId), + transient, + ) { message, replies, t -> + MessageDetailUiState( + message = message, + replies = replies, + isLoading = t.isLoading, + errorMessage = t.errorMessage, + subscriptionRequired = t.subscriptionRequired, + replyText = t.replyText, + isPostingReply = t.isPostingReply, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MessageDetailUiState(isLoading = true), + ) + + init { + load() + } + + fun load() { + transient.update { it.copy(isLoading = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + val messageResult = repository.fetchMessage(messageId) + if (messageResult is ApiResult.Failure) { + transient.update { it.copy(isLoading = false).withError(messageResult.error) } + return@launch + } + when (val repliesResult = repository.refreshReplies(messageId)) { + is ApiResult.Success -> transient.update { it.copy(isLoading = false) } + is ApiResult.Failure -> transient.update { + it.copy(isLoading = false).withError(repliesResult.error) + } + } + } + } + + fun onReplyTextChange(value: String) = transient.update { it.copy(replyText = value) } + + fun onDig() { + val message = uiState.value.message ?: return + viewModelScope.launch { + val result = repository.setDug(message.id, dug = !message.dugByMe) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + fun postReply() { + val text = transient.value.replyText.trim() + if (text.isBlank()) return + transient.update { it.copy(isPostingReply = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.postReply(parentId = messageId, content = text)) { + is ApiResult.Success -> transient.update { + it.copy(isPostingReply = false, replyText = "") + } + is ApiResult.Failure -> transient.update { + it.copy(isPostingReply = false).withError(result.error) + } + } + } + } + + fun dismissError() = transient.update { it.copy(errorMessage = null, subscriptionRequired = false) } + + private fun DetailTransientState.withError(error: AppError): DetailTransientState = + copy(errorMessage = error.toUserMessage(), subscriptionRequired = error.isSubscriptionGate) +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt new file mode 100644 index 0000000..6ab8670 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt @@ -0,0 +1,357 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.components.MessageCard + +/** Stable test tags for the feed screen. */ +object MessagesFeedTags { + const val LIST = "messagesFeedList" + const val EMPTY = "messagesFeedEmpty" + const val ERROR = "messagesFeedError" + const val LOCKED = "messagesFeedLocked" + const val PROGRESS = "messagesFeedProgress" + const val FAB = "messagesFeedFab" + const val COMPOSE_INPUT = "messagesComposeInput" + const val COMPOSE_SUBMIT = "messagesComposeSubmit" +} + +/** + * Hilt-wired feed entry point. The app's NavHost hosts this as the Messages tab. + * + * @param onOpenMessage navigates to the detail screen for the given message id. + */ +@Composable +fun MessagesRoute( + onOpenMessage: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: MessagesFeedViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + MessagesFeedScreen( + state = state, + onRefresh = viewModel::refresh, + onLoadMore = viewModel::loadMore, + onOpenMessage = onOpenMessage, + onDig = viewModel::onDig, + onDelete = viewModel::onDelete, + onOpenCompose = viewModel::openCompose, + onDismissCompose = viewModel::dismissCompose, + onComposeTextChange = viewModel::onComposeTextChange, + onPost = viewModel::post, + modifier = modifier, + ) +} + +/** Stateless feed UI — drives all list/empty/error/locked states from [state]. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MessagesFeedScreen( + state: MessagesFeedUiState, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + onOpenMessage: (String) -> Unit, + onDig: (Message) -> Unit, + onDelete: (Message) -> Unit, + onOpenCompose: () -> Unit, + onDismissCompose: () -> Unit, + onComposeTextChange: (String) -> Unit, + onPost: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { TopAppBar(title = { Text("Messages") }) }, + floatingActionButton = { + if (!state.subscriptionRequired) { + FloatingActionButton( + onClick = onOpenCompose, + modifier = Modifier.testTag(MessagesFeedTags.FAB), + ) { + Icon(Icons.Filled.Add, contentDescription = "New message") + } + } + }, + ) { padding -> + when { + state.subscriptionRequired -> LockedState( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + else -> FeedContent( + state = state, + contentPadding = padding, + onRefresh = onRefresh, + onLoadMore = onLoadMore, + onOpenMessage = onOpenMessage, + onDig = onDig, + onDelete = onDelete, + ) + } + } + + if (state.isComposeOpen) { + ComposeSheet( + text = state.composeText, + isPosting = state.isPosting, + canPost = state.canPost, + onTextChange = onComposeTextChange, + onDismiss = onDismissCompose, + onPost = onPost, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FeedContent( + state: MessagesFeedUiState, + contentPadding: PaddingValues, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + onOpenMessage: (String) -> Unit, + onDig: (Message) -> Unit, + onDelete: (Message) -> Unit, +) { + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + when { + state.isEmpty && state.isRefreshing -> LoadingState() + state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) + state.isEmpty -> EmptyState() + else -> FeedList( + state = state, + onLoadMore = onLoadMore, + onOpenMessage = onOpenMessage, + onDig = onDig, + onDelete = onDelete, + ) + } + } +} + +@Composable +private fun FeedList( + state: MessagesFeedUiState, + onLoadMore: () -> Unit, + onOpenMessage: (String) -> Unit, + onDig: (Message) -> Unit, + onDelete: (Message) -> Unit, +) { + val listState = rememberLazyListState() + // Trigger load-more when the last item scrolls into view. + val shouldLoadMore by remember { + derivedStateOf { + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + state.canLoadMore && !state.isLoadingMore && last >= state.messages.size - 3 + } + } + if (shouldLoadMore) onLoadMore() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .testTag(MessagesFeedTags.LIST), + ) { + items(state.messages, key = { it.id }) { message -> + MessageCard( + message = message, + onClick = { onOpenMessage(message.id) }, + onDig = { onDig(message) }, + onDelete = { onDelete(message) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + if (state.isLoadingMore) { + item { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.height(24.dp)) + } + } + } + } +} + +@Composable +private fun LoadingState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(MessagesFeedTags.PROGRESS)) + } +} + +@Composable +private fun EmptyState() { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = "No messages yet. Be the first to post.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessagesFeedTags.EMPTY), + ) + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(MessagesFeedTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } +} + +@Composable +private fun LockedState(message: String?, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Subscribers only", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Upgrade to an active subscription to view the message feed.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(MessagesFeedTags.LOCKED), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ComposeSheet( + text: String, + isPosting: Boolean, + canPost: Boolean, + onTextChange: (String) -> Unit, + onDismiss: () -> Unit, + onPost: () -> Unit, +) { + ModalBottomSheet(onDismissRequest = onDismiss) { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 20.dp, vertical = 12.dp), + ) { + Text("New message", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = text, + onValueChange = onTextChange, + placeholder = { Text("What's on your mind?") }, + enabled = !isPosting, + minLines = 3, + modifier = Modifier + .fillMaxWidth() + .testTag(MessagesFeedTags.COMPOSE_INPUT), + ) + Spacer(Modifier.height(12.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onDismiss, enabled = !isPosting) { Text("Cancel") } + Spacer(Modifier.height(8.dp)) + Button( + onClick = onPost, + enabled = canPost, + modifier = Modifier.testTag(MessagesFeedTags.COMPOSE_SUBMIT), + ) { + if (isPosting) { + CircularProgressIndicator( + modifier = Modifier.height(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Post") + } + } + } + Spacer(Modifier.height(12.dp)) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun MessagesFeedPreview() { + InterlinedListTheme { + MessagesFeedScreen( + state = MessagesFeedUiState( + messages = listOf( + Message( + id = "1", content = "Shipping the Android messages feed today.", + authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", + authorAvatarUrl = null, createdAt = null, digCount = 4, replyCount = 2, + dugByMe = true, parentId = null, mine = true, + ), + ), + ), + onRefresh = {}, onLoadMore = {}, onOpenMessage = {}, onDig = {}, onDelete = {}, + onOpenCompose = {}, onDismissCompose = {}, onComposeTextChange = {}, onPost = {}, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt new file mode 100644 index 0000000..2da7b35 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt @@ -0,0 +1,162 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate +import com.interlinedlist.android.feature.messages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Feed screen state: the cached messages plus transient network/compose flags. */ +data class MessagesFeedUiState( + val messages: List = emptyList(), + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = false, + val errorMessage: String? = null, + /** True when the failure is a subscription gate — render an upsell instead. */ + val subscriptionRequired: Boolean = false, + val isComposeOpen: Boolean = false, + val composeText: String = "", + val isPosting: Boolean = false, +) { + val isEmpty: Boolean get() = messages.isEmpty() + val canPost: Boolean get() = composeText.isNotBlank() && !isPosting +} + +/** Transient (non-cached) UI flags kept separate from the Room-backed message list. */ +private data class FeedTransientState( + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val isComposeOpen: Boolean = false, + val composeText: String = "", + val isPosting: Boolean = false, +) + +@HiltViewModel +class MessagesFeedViewModel @Inject constructor( + private val repository: MessagesRepository, +) : ViewModel() { + + private val transient = MutableStateFlow(FeedTransientState()) + + /** + * Room is the source of truth: the feed list comes from the cache Flow and is + * combined with transient flags into a single [MessagesFeedUiState]. + */ + val uiState: StateFlow = + combine(repository.observeFeed(), transient) { messages, t -> + MessagesFeedUiState( + messages = messages, + isRefreshing = t.isRefreshing, + isLoadingMore = t.isLoadingMore, + canLoadMore = t.canLoadMore, + errorMessage = t.errorMessage, + subscriptionRequired = t.subscriptionRequired, + isComposeOpen = t.isComposeOpen, + composeText = t.composeText, + isPosting = t.isPosting, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = MessagesFeedUiState(), + ) + + init { + refresh() + } + + fun refresh() { + transient.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.refreshFeed()) { + is ApiResult.Success -> transient.update { + it.copy(isRefreshing = false, canLoadMore = result.data) + } + is ApiResult.Failure -> transient.update { + it.copy(isRefreshing = false).withError(result.error) + } + } + } + } + + fun loadMore() { + val current = uiState.value + if (current.isLoadingMore || !current.canLoadMore) return + transient.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + when (val result = repository.loadMoreFeed(currentCount = current.messages.size)) { + is ApiResult.Success -> transient.update { + it.copy(isLoadingMore = false, canLoadMore = result.data) + } + is ApiResult.Failure -> transient.update { + it.copy(isLoadingMore = false).withError(result.error) + } + } + } + } + + fun onDig(message: Message) { + viewModelScope.launch { + // Repository updates the cache optimistically and rolls back on failure. + val result = repository.setDug(message.id, dug = !message.dugByMe) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + fun onDelete(message: Message) { + viewModelScope.launch { + val result = repository.deleteMessage(message.id) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + // --- compose sheet ----------------------------------------------------- + + fun openCompose() = transient.update { it.copy(isComposeOpen = true, errorMessage = null) } + + fun dismissCompose() = transient.update { it.copy(isComposeOpen = false, composeText = "") } + + fun onComposeTextChange(value: String) = transient.update { it.copy(composeText = value) } + + fun post() { + val text = transient.value.composeText.trim() + if (text.isBlank()) return + transient.update { it.copy(isPosting = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.createMessage(text)) { + is ApiResult.Success -> transient.update { + it.copy(isPosting = false, isComposeOpen = false, composeText = "") + } + is ApiResult.Failure -> transient.update { + it.copy(isPosting = false).withError(result.error) + } + } + } + } + + fun dismissError() = transient.update { it.copy(errorMessage = null, subscriptionRequired = false) } + + private fun FeedTransientState.withError(error: AppError?): FeedTransientState = + if (error == null) this + else copy(errorMessage = error.toUserMessage(), subscriptionRequired = error.isSubscriptionGate) +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt new file mode 100644 index 0000000..2d7f15a --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt @@ -0,0 +1,239 @@ +package com.interlinedlist.android.feature.messages.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultMessagesRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private lateinit var server: MockWebServer + private lateinit var api: MessagesApi + private lateinit var dao: FakeMessageDao + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val contentType = "application/json".toMediaType() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(MessagesApi::class.java) + dao = FakeMessageDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repository(currentUserId: String? = "me") = DefaultMessagesRepository( + api = api, + messageDao = dao, + sessionStore = fakeSessionStore(currentUserId), + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueueJson(code: Int, body: String) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun `refreshFeed caches messages and reports hasMore`() = runTest(dispatcher) { + enqueueJson( + 200, + """ + { + "data": [ + { "id": "1", "content": "first", "author": { "id": "a", "username": "amy" }, + "digCount": 2, "replyCount": 1 }, + { "id": "2", "content": "second", "author": { "id": "me", "username": "me" } } + ], + "pagination": { "total": 5, "limit": 20, "offset": 0, "hasMore": true } + } + """.trimIndent(), + ) + val repo = repository() + + val result = repo.refreshFeed() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isTrue() // hasMore + val cached = repo.observeFeed().first() + assertThat(cached.map { it.id }).containsExactly("1", "2").inOrder() + // Author id "me" matches the session user -> flagged mine. + assertThat(cached.first { it.id == "2" }.mine).isTrue() + assertThat(cached.first { it.id == "1" }.mine).isFalse() + } + + @Test + fun `refreshFeed maps a 403 subscription error`() = runTest(dispatcher) { + enqueueJson(403, """{ "error": "An active subscription is required." }""") + val repo = repository() + + val result = repo.refreshFeed() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `loadMoreFeed appends after existing feed rows`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "content": "a" } ], + "pagination": { "total": 3, "limit": 20, "offset": 0, "hasMore": true } }""", + ) + enqueueJson( + 200, + """{ "data": [ { "id": "2", "content": "b" } ], + "pagination": { "total": 3, "limit": 20, "offset": 1, "hasMore": false } }""", + ) + val repo = repository() + repo.refreshFeed() + + val more = repo.loadMoreFeed(currentCount = 1) + + assertThat((more as ApiResult.Success).data).isFalse() // no more pages + val ids = repo.observeFeed().first().map { it.id } + assertThat(ids).containsExactly("1", "2").inOrder() + + // Second request carried the offset from the current feed size. + server.takeRequest() + val secondPath = server.takeRequest().path + assertThat(secondPath).contains("offset=1") + } + + @Test + fun `createMessage caches the new message at the top of the feed`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "old", "content": "old" } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson( + 201, + """{ "message": { "id": "new", "content": "brand new", + "author": { "id": "me", "username": "me" } } }""", + ) + val repo = repository() + repo.refreshFeed() + + val result = repo.createMessage("brand new") + + assertThat((result as ApiResult.Success).data.id).isEqualTo("new") + val ids = repo.observeFeed().first().map { it.id } + assertThat(ids.first()).isEqualTo("new") + } + + @Test + fun `setDig optimistically updates then rolls back on failure`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "content": "x", "digCount": 0, "dugByCurrentUser": false } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(500, """{ "error": "boom" }""") + val repo = repository() + repo.refreshFeed() + + val result = repo.setDug("1", dug = true) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + // Rolled back to the pre-dig state. + val cached = repo.observeMessage("1").first() + assertThat(cached?.dugByMe).isFalse() + assertThat(cached?.digCount).isEqualTo(0) + } + + @Test + fun `setDig persists the dig on success`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "content": "x", "digCount": 4, "dugByCurrentUser": false } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(201, "") + val repo = repository() + repo.refreshFeed() + + val result = repo.setDug("1", dug = true) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val cached = repo.observeMessage("1").first() + assertThat(cached?.dugByMe).isTrue() + assertThat(cached?.digCount).isEqualTo(5) + } + + @Test + fun `deleteMessage removes it from the cache`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "content": "x", "author": { "id": "me" } } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(200, "") + val repo = repository() + repo.refreshFeed() + + val result = repo.deleteMessage("1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo.observeFeed().first()).isEmpty() + } + + @Test + fun `postReply caches the reply under the parent and bumps reply count`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "p", "content": "parent", "replyCount": 0 } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson( + 201, + """{ "message": { "id": "r", "content": "a reply", "author": { "id": "me" } } }""", + ) + val repo = repository() + repo.refreshFeed() + + val result = repo.postReply(parentId = "p", content = "a reply") + + assertThat((result as ApiResult.Success).data.parentId).isEqualTo("p") + val replies = repo.observeReplies("p").first() + assertThat(replies.map { it.id }).containsExactly("r") + assertThat(repo.observeMessage("p").first()?.replyCount).isEqualTo(1) + } + + @Test + fun `search maps results without touching the feed cache`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "s1", "content": "found it" } ], + "pagination": { "hasMore": false } }""", + ) + val repo = repository() + + val result = repo.search("found") + + assertThat((result as ApiResult.Success).data.map { it.id }).containsExactly("s1") + assertThat(repo.observeFeed().first()).isEmpty() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt new file mode 100644 index 0000000..978671a --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.messages.data + +import com.interlinedlist.android.feature.messages.data.local.MessageDao +import com.interlinedlist.android.feature.messages.data.local.MessageEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory stand-in for the Room [MessageDao] so repository/ViewModel logic can + * be unit-tested on the JVM without an Android runtime. Mirrors the query + * semantics of the real DAO (feed = parentId null, ordered by feedOrder). + */ +class FakeMessageDao : MessageDao { + + private val rows = MutableStateFlow>(emptyMap()) + + private fun sorted(predicate: (MessageEntity) -> Boolean): List = + rows.value.values.filter(predicate).sortedBy { it.feedOrder } + + override fun observeFeed(): Flow> = + rows.map { map -> map.values.filter { it.parentId == null }.sortedBy { it.feedOrder } } + + override fun observeReplies(parentId: String): Flow> = + rows.map { map -> map.values.filter { it.parentId == parentId }.sortedBy { it.feedOrder } } + + override fun observeMessage(id: String): Flow = + rows.map { it[id] } + + override suspend fun insertAll(messages: List) { + rows.value = rows.value.toMutableMap().apply { + messages.forEach { put(it.id, it) } + } + } + + override suspend fun upsert(message: MessageEntity) { + rows.value = rows.value.toMutableMap().apply { put(message.id, message) } + } + + override suspend fun deleteById(id: String) { + rows.value = rows.value.toMutableMap().apply { remove(id) } + } + + override suspend fun clearFeed() { + rows.value = rows.value.filterValues { it.parentId != null } + } + + override suspend fun maxFeedOrder(): Long? = + sorted { it.parentId == null }.maxOfOrNull { it.feedOrder } + + /** Test helper: current feed snapshot. */ + fun feedSnapshot(): List = sorted { it.parentId == null } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TestDoubles.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TestDoubles.kt new file mode 100644 index 0000000..3b76e1f --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TestDoubles.kt @@ -0,0 +1,81 @@ +package com.interlinedlist.android.feature.messages.data + +import android.content.SharedPreferences +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.datastore.SessionStore +import kotlinx.coroutines.CoroutineDispatcher + +/** DispatcherProvider that runs everything on the test dispatcher. */ +class TestDispatcherProvider(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher +} + +/** + * Builds a [SessionStore] backed by an in-memory [SharedPreferences] so tests can + * seed a current user id without an Android runtime. Only the string operations the + * store actually uses are implemented. + */ +fun fakeSessionStore(userId: String?): SessionStore { + val store = SessionStore(InMemorySharedPreferences()) + store.userId = userId + return store +} + +/** Minimal in-memory [SharedPreferences] covering the getString/putString path. */ +private class InMemorySharedPreferences : SharedPreferences { + private val values = mutableMapOf() + + override fun getString(key: String?, defValue: String?): String? = + (values[key] as? String) ?: defValue + + override fun contains(key: String?): Boolean = values.containsKey(key) + override fun getAll(): MutableMap = values + override fun getInt(key: String?, defValue: Int): Int = (values[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (values[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (values[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = + (values[key] as? Boolean) ?: defValue + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (values[key] as? MutableSet) ?: defValues + + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + override fun edit(): SharedPreferences.Editor = Editor() + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clear = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor = + apply { pending[key] = value } + override fun putStringSet(key: String, values: MutableSet?): SharedPreferences.Editor = + apply { pending[key] = values } + override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value } + override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value } + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value } + override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = REMOVED } + override fun clear(): SharedPreferences.Editor = apply { clear = true } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clear) values.clear() + pending.forEach { (k, v) -> if (v === REMOVED) values.remove(k) else values[k] = v } + pending.clear() + clear = false + } + } + + companion object { + private val REMOVED = Any() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt new file mode 100644 index 0000000..d616749 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt @@ -0,0 +1,73 @@ +package com.interlinedlist.android.feature.messages.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class MessageDtoMapperTest { + + private fun dto( + id: String = "m1", + authorId: String = "u1", + isOwn: Boolean = false, + ) = MessageDto( + id = id, + content = "hello", + author = MessageAuthorDto(id = authorId, username = "adron", displayName = "Adron", avatar = "a.png"), + createdAt = "2026-07-18T10:00:00Z", + digCount = 3, + replyCount = 2, + dugByCurrentUser = true, + parentId = null, + isOwn = isOwn, + ) + + @Test + fun `maps all wire fields into the domain model`() { + val message = dto().toDomain(currentUserId = "someone-else") + + assertThat(message.id).isEqualTo("m1") + assertThat(message.content).isEqualTo("hello") + assertThat(message.authorUsername).isEqualTo("adron") + assertThat(message.authorDisplayName).isEqualTo("Adron") + assertThat(message.authorAvatarUrl).isEqualTo("a.png") + assertThat(message.digCount).isEqualTo(3) + assertThat(message.replyCount).isEqualTo(2) + assertThat(message.dugByMe).isTrue() + } + + @Test + fun `flags message as mine when author id matches current user`() { + val message = dto(authorId = "u1").toDomain(currentUserId = "u1") + assertThat(message.mine).isTrue() + } + + @Test + fun `flags message as mine when the payload sets isOwn`() { + val message = dto(authorId = "u1", isOwn = true).toDomain(currentUserId = "different") + assertThat(message.mine).isTrue() + } + + @Test + fun `is not mine when author differs and isOwn is false`() { + val message = dto(authorId = "u1").toDomain(currentUserId = "u2") + assertThat(message.mine).isFalse() + } + + @Test + fun `null author yields empty author fields without crashing`() { + val message = MessageDto(id = "m2", content = "x", author = null).toDomain(currentUserId = null) + assertThat(message.authorId).isEmpty() + assertThat(message.authorUsername).isEmpty() + assertThat(message.mine).isFalse() + } + + @Test + fun `author label falls back to username when display name is blank`() { + val message = MessageDto( + id = "m3", + content = "x", + author = MessageAuthorDto(id = "u9", username = "handle", displayName = " "), + ).toDomain(currentUserId = null) + assertThat(message.authorLabel).isEqualTo("handle") + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt new file mode 100644 index 0000000..ceb9ced --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -0,0 +1,107 @@ +package com.interlinedlist.android.feature.messages.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.Message +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * A configurable in-memory [MessagesRepository] for ViewModel tests. Feed/reply + * state is exposed as Flows (source of truth), and each operation's result can be + * pre-set to drive success/failure paths. + */ +class FakeMessagesRepository : MessagesRepository { + + private val feed = MutableStateFlow>(emptyList()) + private val replies = MutableStateFlow>>(emptyMap()) + private val single = MutableStateFlow>(emptyMap()) + + var refreshResult: ApiResult = ApiResult.Success(false) + var loadMoreResult: ApiResult = ApiResult.Success(false) + var createResult: ApiResult? = null + var fetchResult: ApiResult? = null + var refreshRepliesResult: ApiResult = ApiResult.Success(Unit) + var postReplyResult: ApiResult? = null + var setDugResult: ApiResult = ApiResult.Success(Unit) + var deleteResult: ApiResult = ApiResult.Success(Unit) + var searchResult: ApiResult> = ApiResult.Success(emptyList()) + + var refreshCount = 0 + var loadMoreCount = 0 + var lastSetDug: Pair? = null + var deletedIds = mutableListOf() + + fun emitFeed(messages: List) { feed.value = messages } + fun emitReplies(parentId: String, messages: List) { + replies.value = replies.value + (parentId to messages) + } + fun emitMessage(message: Message) { single.value = single.value + (message.id to message) } + + override fun observeFeed(): Flow> = feed + + override fun observeReplies(messageId: String): Flow> = + replies.map { it[messageId].orEmpty() } + + override fun observeMessage(messageId: String): Flow = + single.map { it[messageId] } + + override suspend fun refreshFeed(): ApiResult { + refreshCount++ + return refreshResult + } + + override suspend fun loadMoreFeed(currentCount: Int): ApiResult { + loadMoreCount++ + return loadMoreResult + } + + override suspend fun createMessage(content: String): ApiResult = + createResult ?: ApiResult.Failure(AppError.Unknown("createResult not set")) + + override suspend fun fetchMessage(messageId: String): ApiResult = + fetchResult ?: ApiResult.Failure(AppError.Unknown("fetchResult not set")) + + override suspend fun refreshReplies(messageId: String): ApiResult = refreshRepliesResult + + override suspend fun postReply(parentId: String, content: String): ApiResult = + postReplyResult ?: ApiResult.Failure(AppError.Unknown("postReplyResult not set")) + + override suspend fun setDug(messageId: String, dug: Boolean): ApiResult { + lastSetDug = messageId to dug + return setDugResult + } + + override suspend fun deleteMessage(messageId: String): ApiResult { + deletedIds += messageId + return deleteResult + } + + override suspend fun search(query: String): ApiResult> = searchResult +} + +/** Builds a sample [Message] for tests. */ +fun sampleMessage( + id: String = "1", + content: String = "hello", + mine: Boolean = false, + dugByMe: Boolean = false, + digCount: Int = 0, + replyCount: Int = 0, + parentId: String? = null, +) = Message( + id = id, + content = content, + authorId = "u1", + authorUsername = "adron", + authorDisplayName = "Adron", + authorAvatarUrl = null, + createdAt = null, + digCount = digCount, + replyCount = replyCount, + dugByMe = dugByMe, + parentId = parentId, + mine = mine, +) diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTimeTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTimeTest.kt new file mode 100644 index 0000000..4847692 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/RelativeTimeTest.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.messages.ui + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.Instant + +class RelativeTimeTest { + + private val now = Instant.parse("2026-07-18T12:00:00Z") + + @Test + fun `null or blank yields empty string`() { + assertThat(relativeTime(null, now)).isEmpty() + assertThat(relativeTime("", now)).isEmpty() + } + + @Test + fun `under a minute reads as just now`() { + assertThat(relativeTime("2026-07-18T11:59:30Z", now)).isEqualTo("just now") + } + + @Test + fun `minutes hours days and weeks are abbreviated`() { + assertThat(relativeTime("2026-07-18T11:55:00Z", now)).isEqualTo("5m") + assertThat(relativeTime("2026-07-18T09:00:00Z", now)).isEqualTo("3h") + assertThat(relativeTime("2026-07-16T12:00:00Z", now)).isEqualTo("2d") + assertThat(relativeTime("2026-07-04T12:00:00Z", now)).isEqualTo("2w") + } + + @Test + fun `future timestamps clamp to just now`() { + assertThat(relativeTime("2026-07-18T12:05:00Z", now)).isEqualTo("just now") + } + + @Test + fun `unparseable timestamp is returned verbatim`() { + assertThat(relativeTime("not-a-date", now)).isEqualTo("not-a-date") + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt new file mode 100644 index 0000000..5064f04 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt @@ -0,0 +1,127 @@ +package com.interlinedlist.android.feature.messages.ui.detail + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MessageDetailViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun handle(id: String = "m1") = SavedStateHandle(mapOf(MESSAGE_ID_ARG to id)) + + @Test + fun `load fetches the message and its replies`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + } + repo.emitMessage(sampleMessage(id = "m1")) + repo.emitReplies("m1", listOf(sampleMessage(id = "r1", parentId = "m1"))) + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + + advanceUntilIdle() + val state = vm.uiState.value + assertThat(state.message?.id).isEqualTo("m1") + assertThat(state.replies.map { it.id }).containsExactly("r1") + assertThat(state.isLoading).isFalse() + } + + @Test + fun `fetch failure surfaces an error and stops loading`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Failure(AppError.NotFound("gone")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + + advanceUntilIdle() + val state = vm.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.errorMessage).isEqualTo("This message is no longer available.") + } + + @Test + fun `subscription-gated fetch sets the locked flag`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribers only")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + + advanceUntilIdle() + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + } + + @Test + fun `postReply clears the input on success`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + postReplyResult = ApiResult.Success(sampleMessage(id = "r1", parentId = "m1")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onReplyTextChange("nice thread") + vm.postReply() + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.replyText).isEmpty() + assertThat(state.isPostingReply).isFalse() + } + + @Test + fun `postReply failure keeps the draft and shows an error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + postReplyResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onReplyTextChange("draft") + vm.postReply() + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.replyText).isEqualTo("draft") + assertThat(state.errorMessage).isNotEmpty() + } + + @Test + fun `dig toggles the current message's dig via the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1", dugByMe = false)) + } + repo.emitMessage(sampleMessage(id = "m1", dugByMe = false)) + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onDig() + advanceUntilIdle() + + assertThat(repo.lastSetDug).isEqualTo("m1" to true) + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt new file mode 100644 index 0000000..fa62952 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt @@ -0,0 +1,179 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MessagesFeedViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `feed emits cached messages from the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + repo.emitFeed(listOf(sampleMessage(id = "1"), sampleMessage(id = "2"))) + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.messages.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(state.isRefreshing).isFalse() + } + } + + @Test + fun `refresh runs on init and toggles the refreshing flag`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success(true) } + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(repo.refreshCount).isEqualTo(1) + assertThat(state.isRefreshing).isFalse() + assertThat(state.canLoadMore).isTrue() + } + } + + @Test + fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.errorMessage).isEqualTo("No connection. Check your network and try again.") + assertThat(state.subscriptionRequired).isFalse() + } + } + + @Test + fun `subscription-gated refresh sets the locked flag`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribers only")) + } + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.subscriptionRequired).isTrue() + assertThat(state.errorMessage).isEqualTo("Subscribers only") + } + } + + @Test + fun `loadMore is a no-op when there are no more pages`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success(false) } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(0) + } + + @Test + fun `loadMore fetches the next page when more are available`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Success(true) + loadMoreResult = ApiResult.Success(false) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(1) + assertThat(vm.uiState.value.canLoadMore).isFalse() + } + + @Test + fun `post creates a message and closes the compose sheet`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("hello world") + vm.post() + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isComposeOpen).isFalse() + assertThat(state.composeText).isEmpty() + assertThat(state.isPosting).isFalse() + } + + @Test + fun `post failure keeps the sheet open and shows an error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + createResult = ApiResult.Failure(AppError.Server("nope")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("hello") + vm.post() + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isPosting).isFalse() + assertThat(state.errorMessage).isNotEmpty() + } + + @Test + fun `dig delegates to the repository with the toggled value`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + advanceUntilIdle() + + vm.onDig(sampleMessage(id = "42", dugByMe = false)) + advanceUntilIdle() + + assertThat(repo.lastSetDug).isEqualTo("42" to true) + } + + @Test + fun `delete delegates to the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + advanceUntilIdle() + + vm.onDelete(sampleMessage(id = "9", mine = true)) + advanceUntilIdle() + + assertThat(repo.deletedIds).containsExactly("9") + } +} From da65dcd615eb14d07f0e591fb97a1741d248c860 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 12:51:01 -0700 Subject: [PATCH 03/25] chore: scaffold :feature:profile module (Phase 2 base) Empty android-library module registered in settings, verified to assemble, so parallel worktree agents branch from a base that includes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- feature/profile/build.gradle.kts | 78 ++++++++++++++++++++++++++++++++ settings.gradle.kts | 1 + 2 files changed, 79 insertions(+) create mode 100644 feature/profile/build.gradle.kts diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts new file mode 100644 index 0000000..614f1a9 --- /dev/null +++ b/feature/profile/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.profile" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + + // This module owns its own Room cache (see DocumentsDatabase) — it must not + // reuse the shared :core:database, so it pulls Room in directly. + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.coil.compose) + + implementation(libs.retrofit.core) + implementation(libs.kotlinx.serialization.json) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 795aaba..c70d80a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,3 +38,4 @@ include(":feature:auth") include(":feature:lists") include(":feature:messages") include(":feature:documents") +include(":feature:profile") From c98544354865c6617e654dc23ad8eb261033608b Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:27:10 -0700 Subject: [PATCH 04/25] feat: documents folder browser redesign + lists/messages deferred features + Phase 2 profiles Round 2 of the parallel build-out, integrated into the app: - documents: redesigned into a Files-app style drill-down folder browser (breadcrumb + push/back), with create/rename/delete folders, move documents, in-browser search, and image upload; replaces the old flat index (55 tests) - lists: schema editing, watchers, GitHub refresh, and connections (72 tests) - messages: media attach/upload, scheduled messages, report, link previews (62 tests) - profile (Phase 2, new :feature:profile): my-profile view/edit, avatar, other-user profiles, and user search; becomes the Account tab (37 tests) Navigation rewired: Documents tab -> folder browser with documents/folder/{folderId} drill-down; lists/{listId}/schema + /watchers; messages/scheduled; Account tab -> new ProfileRoute (sign-out reuses the auth-backed logout) + editProfile/userSearch/user/{username}. Built by four parallel worktree agents (each resynced to origin/dev), then integrated. :app:assembleDebug + full testDebugUnitTest green (234 unit tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 1 + .../navigation/InterlinedListNavHost.kt | 111 +++- feature/documents/build.gradle.kts | 4 + .../ui/DocumentsBrowserScreenTest.kt | 175 +++++++ .../documents/ui/DocumentsScreenTest.kt | 99 ---- .../data/DefaultDocumentsRepository.kt | 251 ++++++--- .../documents/data/DocumentsRepository.kt | 61 ++- .../documents/data/local/DocumentDao.kt | 8 + .../documents/data/local/DocumentsDatabase.kt | 2 +- .../feature/documents/data/local/FolderDao.kt | 9 + .../documents/data/local/FolderEntity.kt | 6 + .../documents/data/mapper/DocumentMappers.kt | 11 +- .../documents/data/remote/DocumentsApi.kt | 47 +- .../documents/data/remote/dto/FolderDto.kt | 42 +- .../documents/domain/DocumentFolder.kt | 50 ++ .../feature/documents/domain/FolderTree.kt | 91 ++++ .../feature/documents/domain/Pagination.kt | 33 -- .../documents/ui/browser/BrowserDialogs.kt | 312 +++++++++++ .../ui/browser/DocumentsBrowserScreen.kt | 494 ++++++++++++++++++ .../ui/browser/DocumentsBrowserViewModel.kt | 223 ++++++++ .../ui/editor/DocumentEditorScreen.kt | 41 ++ .../ui/editor/DocumentEditorViewModel.kt | 26 + .../documents/ui/index/DocumentsScreen.kt | 321 ------------ .../documents/ui/index/DocumentsViewModel.kt | 191 ------- .../data/DefaultDocumentsRepositoryTest.kt | 234 ++++++--- .../documents/data/DocumentMappersTest.kt | 27 +- .../feature/documents/data/FakeDaos.kt | 16 + .../documents/domain/FolderTreeTest.kt | 128 +++++ .../ui/DocumentEditorViewModelTest.kt | 30 ++ .../ui/DocumentsBrowserViewModelTest.kt | 217 ++++++++ .../documents/ui/DocumentsViewModelTest.kt | 146 ------ .../documents/ui/FakeDocumentsRepository.kt | 136 +++-- .../ui/connections/ConnectionsScreenTest.kt | 55 ++ .../lists/ui/schema/SchemaEditorScreenTest.kt | 67 +++ .../lists/ui/watchers/WatchersScreenTest.kt | 59 +++ .../feature/lists/data/ConnectionMapper.kt | 27 + .../lists/data/DefaultListsRepository.kt | 121 +++++ .../feature/lists/data/ListsRepository.kt | 47 ++ .../feature/lists/data/SchemaMapper.kt | 40 ++ .../feature/lists/data/WatcherMapper.kt | 36 ++ .../feature/lists/data/remote/ListsApi.kt | 69 +++ .../lists/data/remote/dto/ConnectionDtos.kt | 42 ++ .../lists/data/remote/dto/RefreshDtos.kt | 19 + .../lists/data/remote/dto/SchemaDtos.kt | 27 + .../lists/data/remote/dto/WatcherDtos.kt | 74 +++ .../feature/lists/domain/ListConnection.kt | 15 + .../feature/lists/domain/RefreshResult.kt | 24 + .../android/feature/lists/domain/Watcher.kt | 47 ++ .../lists/ui/connections/ConnectionsScreen.kt | 322 ++++++++++++ .../ui/connections/ConnectionsViewModel.kt | 89 ++++ .../lists/ui/detail/ListDetailScreen.kt | 69 ++- .../lists/ui/detail/ListDetailViewModel.kt | 48 ++ .../lists/ui/schema/SchemaEditorScreen.kt | 270 ++++++++++ .../lists/ui/schema/SchemaEditorViewModel.kt | 158 ++++++ .../lists/ui/watchers/WatchersScreen.kt | 289 ++++++++++ .../lists/ui/watchers/WatchersViewModel.kt | 128 +++++ .../feature/lists/FakeListsRepository.kt | 76 +++ .../lists/data/ConnectionMapperTest.kt | 55 ++ .../lists/data/ListsRepositoryDeferredTest.kt | 264 ++++++++++ .../feature/lists/data/SchemaMapperTest.kt | 37 ++ .../feature/lists/data/WatcherMapperTest.kt | 58 ++ .../connections/ConnectionsViewModelTest.kt | 106 ++++ .../ui/detail/ListDetailViewModelTest.kt | 40 ++ .../ui/schema/SchemaEditorViewModelTest.kt | 132 +++++ .../ui/watchers/WatchersViewModelTest.kt | 133 +++++ feature/messages/build.gradle.kts | 4 + .../ui/feed/MessagesFeedScreenTest.kt | 46 +- .../scheduled/ScheduledMessagesScreenTest.kt | 72 +++ .../data/DefaultMessagesRepository.kt | 151 +++++- .../messages/data/MessagesRepository.kt | 38 +- .../messages/data/local/MessageConverters.kt | 57 ++ .../feature/messages/data/local/MessageDao.kt | 20 +- .../messages/data/local/MessageEntity.kt | 17 + .../messages/data/local/MessagesDatabase.kt | 4 +- .../messages/data/remote/MessagesApi.kt | 29 + .../messages/data/remote/dto/MessageDto.kt | 41 +- .../data/remote/dto/MessagesResponse.kt | 54 +- .../feature/messages/domain/Message.kt | 27 + .../feature/messages/domain/ReportReason.kt | 16 + .../feature/messages/ui/MediaReader.kt | 39 ++ .../messages/ui/components/MessageCard.kt | 49 +- .../messages/ui/components/MessageMedia.kt | 150 ++++++ .../messages/ui/components/ReportDialog.kt | 102 ++++ .../messages/ui/detail/MessageDetailScreen.kt | 26 + .../ui/detail/MessageDetailViewModel.kt | 40 ++ .../messages/ui/feed/MessagesFeedScreen.kt | 211 +++++++- .../messages/ui/feed/MessagesFeedViewModel.kt | 141 ++++- .../ui/scheduled/ScheduledMessagesScreen.kt | 253 +++++++++ .../scheduled/ScheduledMessagesViewModel.kt | 89 ++++ .../data/DefaultMessagesRepositoryTest.kt | 148 ++++++ .../feature/messages/data/FakeMessageDao.kt | 17 +- .../data/remote/dto/MessageDtoMapperTest.kt | 58 ++ .../messages/ui/FakeMessagesRepository.kt | 76 ++- .../ui/detail/MessageDetailViewModelTest.kt | 40 ++ .../ui/feed/MessagesFeedViewModelTest.kt | 124 +++++ .../ScheduledMessagesViewModelTest.kt | 83 +++ feature/profile/build.gradle.kts | 4 + .../src/androidTest/AndroidManifest.xml | 2 + .../feature/profile/ui/ProfileScreenTest.kt | 131 +++++ feature/profile/src/main/AndroidManifest.xml | 2 + .../profile/data/DefaultProfileRepository.kt | 141 +++++ .../feature/profile/data/ProfileRepository.kt | 54 ++ .../feature/profile/data/local/ProfileDao.kt | 32 ++ .../profile/data/local/ProfileDatabase.kt | 18 + .../profile/data/local/ProfileEntity.kt | 42 ++ .../profile/data/mapper/ProfileMappers.kt | 29 + .../feature/profile/data/remote/ProfileApi.kt | 53 ++ .../data/remote/dto/ProfileRequests.kt | 21 + .../data/remote/dto/ProfileResponses.kt | 63 +++ .../profile/data/remote/dto/ProfileUserDto.kt | 23 + .../feature/profile/di/ProfileModule.kt | 52 ++ .../feature/profile/domain/ProfileUser.kt | 29 + .../profile/domain/UserSearchResult.kt | 17 + .../profile/ui/common/ProfileComponents.kt | 79 +++ .../profile/ui/common/ProfileErrorMessages.kt | 14 + .../profile/ui/edit/EditProfileScreen.kt | 268 ++++++++++ .../profile/ui/edit/EditProfileViewModel.kt | 157 ++++++ .../profile/ui/profile/ProfileContent.kt | 96 ++++ .../profile/ui/profile/ProfileScreen.kt | 172 ++++++ .../profile/ui/profile/ProfileViewModel.kt | 69 +++ .../profile/ui/profile/UserProfileScreen.kt | 129 +++++ .../ui/profile/UserProfileViewModel.kt | 69 +++ .../profile/ui/search/UserSearchScreen.kt | 203 +++++++ .../profile/ui/search/UserSearchViewModel.kt | 93 ++++ .../data/DefaultProfileRepositoryTest.kt | 259 +++++++++ .../feature/profile/data/FakeProfileDao.kt | 38 ++ .../profile/data/ProfileMappersTest.kt | 90 ++++ .../profile/ui/EditProfileViewModelTest.kt | 135 +++++ .../profile/ui/FakeProfileRepository.kt | 94 ++++ .../profile/ui/ProfileViewModelTest.kt | 84 +++ .../profile/ui/UserProfileViewModelTest.kt | 72 +++ .../profile/ui/UserSearchViewModelTest.kt | 99 ++++ 132 files changed, 10651 insertions(+), 1090 deletions(-) create mode 100644 feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt delete mode 100644 feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTree.kt delete mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/BrowserDialogs.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt delete mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt delete mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTreeTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt delete mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreenTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ConnectionDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RefreshDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/SchemaDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/WatcherDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListConnection.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/RefreshResult.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Watcher.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapperTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapperTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModelTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt create mode 100644 feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreenTest.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/ReportReason.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MediaReader.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageMedia.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ReportDialog.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreen.kt create mode 100644 feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModel.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModelTest.kt create mode 100644 feature/profile/src/androidTest/AndroidManifest.xml create mode 100644 feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt create mode 100644 feature/profile/src/main/AndroidManifest.xml create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDao.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDatabase.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileEntity.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ProfileMappers.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/ProfileUser.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSearchResult.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileErrorMessages.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchViewModel.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FakeProfileDao.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/ProfileMappersTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/EditProfileViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserSearchViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ede4960..d3c1663 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(project(":feature:lists")) implementation(project(":feature:messages")) implementation(project(":feature:documents")) + implementation(project(":feature:profile")) // Compose implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 4e5ef7f..96e8643 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavType @@ -24,13 +25,22 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.interlinedlist.android.feature.auth.ui.LoginRoute +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute -import com.interlinedlist.android.feature.documents.ui.index.DocumentsRoute +import com.interlinedlist.android.feature.lists.ui.connections.ConnectionsRoute import com.interlinedlist.android.feature.lists.ui.detail.ListDetailRoute import com.interlinedlist.android.feature.lists.ui.list.ListsRoute +import com.interlinedlist.android.feature.lists.ui.schema.SchemaEditorRoute +import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute -import com.interlinedlist.android.ui.home.HomeScreen +import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute +import com.interlinedlist.android.feature.profile.ui.edit.EditProfileRoute +import com.interlinedlist.android.feature.profile.ui.profile.ProfileRoute +import com.interlinedlist.android.feature.profile.ui.profile.UserProfileRoute +import com.interlinedlist.android.feature.profile.ui.search.UserSearchRoute +import com.interlinedlist.android.ui.home.HomeViewModel /** Navigation route keys. */ object Routes { @@ -43,14 +53,33 @@ object Routes { const val DOCUMENTS = "documents" const val ACCOUNT = "account" - // Detail destinations. + // Lists destinations. const val LIST_DETAIL = "lists/{listId}" + const val LIST_SCHEMA = "lists/{listId}/schema" + const val LIST_WATCHERS = "lists/{listId}/watchers" + const val LIST_CONNECTIONS = "lists/connections" + + // Messages destinations. const val MESSAGE_DETAIL = "messageDetail/{messageId}" + const val MESSAGES_SCHEDULED = "messages/scheduled" + + // Documents destinations. + const val DOCUMENT_FOLDER = "documents/folder/{folderId}" const val DOCUMENT_EDITOR = "documents/editor/{documentId}" + // Profile destinations. Distinct prefixes so a username can never collide + // with the edit/search routes. + const val PROFILE_EDIT = "editProfile" + const val USER_SEARCH = "userSearch" + const val USER_PROFILE = "user/{username}" + fun listDetail(id: String) = "lists/$id" + fun listSchema(id: String) = "lists/$id/schema" + fun listWatchers(id: String) = "lists/$id/watchers" fun messageDetail(id: String) = "messageDetail/$id" + fun documentFolder(id: String) = "documents/folder/$id" fun documentEditor(id: String) = "documents/editor/$id" + fun userProfile(username: String) = "user/$username" } /** The four post-login home tabs shown in the bottom navigation bar. */ @@ -116,8 +145,6 @@ private fun MainShell(onLoggedOut: () -> Unit) { selected = hierarchy?.any { it.route == tab.route } == true, onClick = { tabNav.navigate(tab.route) { - // Reselecting a tab returns to its root and keeps - // per-tab state, mirroring standard bottom-nav UX. popUpTo(tabNav.graph.findStartDestination().id) { saveState = true } @@ -138,21 +165,47 @@ private fun MainShell(onLoggedOut: () -> Unit) { startDestination = Routes.LISTS, modifier = Modifier.padding(padding), ) { + // ---- Lists ---- composable(Routes.LISTS) { ListsRoute(onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }) } composable( Routes.LIST_DETAIL, arguments = listOf(navArgument("listId") { type = NavType.StringType }), - ) { + ) { entry -> + val listId = entry.arguments?.getString("listId").orEmpty() ListDetailRoute( onBack = { tabNav.popBackStack() }, onListDeleted = { tabNav.popBackStack() }, + onEditSchema = { tabNav.navigate(Routes.listSchema(listId)) }, + onOpenWatchers = { tabNav.navigate(Routes.listWatchers(listId)) }, ) } + composable( + Routes.LIST_SCHEMA, + arguments = listOf(navArgument("listId") { type = NavType.StringType }), + ) { + SchemaEditorRoute( + onBack = { tabNav.popBackStack() }, + onSaved = { tabNav.popBackStack() }, + ) + } + composable( + Routes.LIST_WATCHERS, + arguments = listOf(navArgument("listId") { type = NavType.StringType }), + ) { + WatchersRoute(onBack = { tabNav.popBackStack() }) + } + composable(Routes.LIST_CONNECTIONS) { + ConnectionsRoute(onBack = { tabNav.popBackStack() }) + } + // ---- Messages ---- composable(Routes.MESSAGES) { - MessagesRoute(onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }) + MessagesRoute( + onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) }, + ) } composable( Routes.MESSAGE_DETAIL, @@ -163,11 +216,25 @@ private fun MainShell(onLoggedOut: () -> Unit) { onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, ) } + composable(Routes.MESSAGES_SCHEDULED) { + ScheduledMessagesRoute(onBack = { tabNav.popBackStack() }) + } + // ---- Documents ---- composable(Routes.DOCUMENTS) { DocumentsRoute( + onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) }, onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, - onSearch = { /* Dedicated search screen deferred; see roadmap. */ }, + ) + } + composable( + Routes.DOCUMENT_FOLDER, + arguments = listOf(navArgument("folderId") { type = NavType.StringType }), + ) { + DocumentsFolderRoute( + onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) }, + onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, + onBack = { tabNav.popBackStack() }, ) } composable( @@ -180,8 +247,34 @@ private fun MainShell(onLoggedOut: () -> Unit) { ) } + // ---- Account / Profile ---- composable(Routes.ACCOUNT) { - HomeScreen(onLoggedOut = onLoggedOut) + // Sign-out reuses the existing auth-backed logout; the profile + // module intentionally owns no session state. + val logoutViewModel: HomeViewModel = hiltViewModel() + ProfileRoute( + onEditProfile = { tabNav.navigate(Routes.PROFILE_EDIT) }, + onSearchUsers = { tabNav.navigate(Routes.USER_SEARCH) }, + onSignOut = { logoutViewModel.logout(onLoggedOut) }, + ) + } + composable(Routes.PROFILE_EDIT) { + EditProfileRoute( + onBack = { tabNav.popBackStack() }, + onSaved = { tabNav.popBackStack() }, + ) + } + composable(Routes.USER_SEARCH) { + UserSearchRoute( + onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) }, + onBack = { tabNav.popBackStack() }, + ) + } + composable( + Routes.USER_PROFILE, + arguments = listOf(navArgument("username") { type = NavType.StringType }), + ) { + UserProfileRoute(onBack = { tabNav.popBackStack() }) } } } diff --git a/feature/documents/build.gradle.kts b/feature/documents/build.gradle.kts index c22ee03..0435625 100644 --- a/feature/documents/build.gradle.kts +++ b/feature/documents/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + // Photo Picker (rememberLauncherForActivityResult) for document image uploads. + implementation(libs.androidx.activity.compose) // This module owns its own Room cache (see DocumentsDatabase) — it must not // reuse the shared :core:database, so it pulls Room in directly. @@ -54,6 +56,8 @@ dependencies { implementation(libs.coil.compose) implementation(libs.retrofit.core) + // okhttp is used directly for multipart image uploads (MultipartBody / RequestBody). + implementation(libs.okhttp.core) implementation(libs.kotlinx.serialization.json) // Unit tests diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt new file mode 100644 index 0000000..485e81a --- /dev/null +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserScreenTest.kt @@ -0,0 +1,175 @@ +package com.interlinedlist.android.feature.documents.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserScreen +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserTestTags +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DocumentsBrowserScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun rootContents( + subfolders: List = emptyList(), + documents: List = emptyList(), + ) = FolderContents( + folderId = FolderNode.ROOT_ID, + folderName = FolderNode.ROOT_NAME, + parentId = null, + subfolders = subfolders, + documents = documents, + breadcrumb = listOf(FolderSummary(FolderNode.ROOT_ID, FolderNode.ROOT_NAME)), + ) + + private fun setContent( + state: DocumentsBrowserUiState, + onOpenFolder: (String) -> Unit = {}, + onOpenDocument: (String) -> Unit = {}, + onCreateFolder: (String) -> Unit = {}, + onSearchQueryChange: (String) -> Unit = {}, + onBack: (() -> Unit)? = null, + ) { + composeRule.setContent { + InterlinedListTheme { + DocumentsBrowserScreen( + state = state, + onOpenFolder = onOpenFolder, + onOpenDocument = onOpenDocument, + onCreateDocument = {}, + onCreateFolder = onCreateFolder, + onRenameFolder = { _, _ -> }, + onDeleteFolder = {}, + onMoveDocument = { _, _ -> }, + onDeleteDocument = {}, + onOpenSearch = {}, + onCloseSearch = {}, + onSearchQueryChange = onSearchQueryChange, + onBack = onBack, + ) + } + } + } + + @Test + fun emptyState_isShown_whenFolderIsEmpty() { + setContent(DocumentsBrowserUiState(isLoading = false, contents = rootContents())) + composeRule.onNodeWithTag(DocumentsBrowserTestTags.EMPTY).assertIsDisplayed() + } + + @Test + fun documentRows_areRendered_andClickable() { + var openedId: String? = null + setContent( + state = DocumentsBrowserUiState( + isLoading = false, + contents = rootContents( + documents = listOf(Document("1", "Grocery list", null, "Milk, eggs", null, null, false, null)), + ), + ), + onOpenDocument = { openedId = it }, + ) + + composeRule.onNodeWithTag(DocumentsBrowserTestTags.docRow("1")).assertIsDisplayed().performClick() + assert(openedId == "1") + } + + @Test + fun folderRow_drillsDown_whenTapped() { + var openedFolder: String? = null + setContent( + state = DocumentsBrowserUiState( + isLoading = false, + contents = rootContents(subfolders = listOf(FolderSummary("f1", "Work", 2, 0))), + ), + onOpenFolder = { openedFolder = it }, + ) + + composeRule.onNodeWithTag(DocumentsBrowserTestTags.folderRow("f1")).assertIsDisplayed().performClick() + assert(openedFolder == "f1") + } + + @Test + fun breadcrumb_navigatesToAncestor() { + var openedFolder: String? = null + val nested = FolderContents( + folderId = "f2", + folderName = "Reports", + parentId = "f1", + subfolders = emptyList(), + documents = listOf(Document("d1", "Doc", null, "", "f2", null, false, null)), + breadcrumb = listOf( + FolderSummary(FolderNode.ROOT_ID, "Documents"), + FolderSummary("f1", "Work"), + FolderSummary("f2", "Reports"), + ), + ) + setContent( + state = DocumentsBrowserUiState(isLoading = false, contents = nested), + onOpenFolder = { openedFolder = it }, + onBack = {}, + ) + + composeRule.onNodeWithTag(DocumentsBrowserTestTags.crumb("f1")).performClick() + assert(openedFolder == "f1") + } + + @Test + fun createFolder_dialog_invokesCallback() { + var createdName: String? = null + setContent( + state = DocumentsBrowserUiState(isLoading = false, contents = rootContents()), + onCreateFolder = { createdName = it }, + ) + + composeRule.onNodeWithTag(DocumentsBrowserTestTags.CREATE_FOLDER).performClick() + composeRule.onNodeWithTag("dialogInput").performTextInput("Ideas") + composeRule.onNodeWithTag("dialogConfirm").performClick() + + assert(createdName == "Ideas") + } + + @Test + fun searchOverlay_queriesAsYouType() { + var lastQuery: String? = null + setContent( + state = DocumentsBrowserUiState( + isLoading = false, + contents = rootContents(), + isSearchActive = true, + ), + onSearchQueryChange = { lastQuery = it }, + ) + + composeRule.onNodeWithTag(DocumentsBrowserTestTags.SEARCH_FIELD).performTextInput("notes") + assert(lastQuery == "notes") + } + + @Test + fun subscriptionGate_isShown_whenRequired() { + setContent( + DocumentsBrowserUiState( + isLoading = false, + subscriptionRequired = true, + errorMessage = "Documents require an active subscription.", + contents = rootContents(), + ), + ) + composeRule.onNodeWithTag(DocumentsBrowserTestTags.SUBSCRIPTION_GATE).assertIsDisplayed() + } +} diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt deleted file mode 100644 index 0328b14..0000000 --- a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsScreenTest.kt +++ /dev/null @@ -1,99 +0,0 @@ -package com.interlinedlist.android.feature.documents.ui - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme -import com.interlinedlist.android.feature.documents.domain.Document -import com.interlinedlist.android.feature.documents.domain.DocumentFolder -import com.interlinedlist.android.feature.documents.ui.index.DocumentsScreen -import com.interlinedlist.android.feature.documents.ui.index.DocumentsTestTags -import com.interlinedlist.android.feature.documents.ui.index.DocumentsUiState -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class DocumentsScreenTest { - - @get:Rule - val composeRule = createComposeRule() - - private fun setContent( - state: DocumentsUiState, - onOpenDocument: (String) -> Unit = {}, - onCreateDocument: () -> Unit = {}, - onSelectFolder: (String?) -> Unit = {}, - ) { - composeRule.setContent { - InterlinedListTheme { - DocumentsScreen( - state = state, - onSelectFolder = onSelectFolder, - onOpenDocument = onOpenDocument, - onCreateDocument = onCreateDocument, - onLoadMore = {}, - onSearch = {}, - ) - } - } - } - - @Test - fun emptyState_isShown_whenNoDocuments() { - setContent(DocumentsUiState(isLoading = false)) - composeRule.onNodeWithTag(DocumentsTestTags.EMPTY).assertIsDisplayed() - } - - @Test - fun documentRows_areRendered_andClickable() { - var openedId: String? = null - setContent( - state = DocumentsUiState( - documents = listOf( - Document("1", "Grocery list", null, "Milk, eggs", null, null, false, null), - ), - ), - onOpenDocument = { openedId = it }, - ) - - composeRule.onNodeWithTag(DocumentsTestTags.row("1")).assertIsDisplayed().performClick() - assert(openedId == "1") - } - - @Test - fun createFab_invokesCallback() { - var created = false - setContent(state = DocumentsUiState(isLoading = false), onCreateDocument = { created = true }) - composeRule.onNodeWithTag(DocumentsTestTags.CREATE_FAB).performClick() - assert(created) - } - - @Test - fun folderChip_selectsFolder() { - var selected: String? = "sentinel" - setContent( - state = DocumentsUiState( - documents = listOf(Document("1", "Doc", null, "", "f1", "Work", false, null)), - folders = listOf(DocumentFolder("f1", "Work", null)), - ), - onSelectFolder = { selected = it }, - ) - composeRule.onNodeWithTag(DocumentsTestTags.folderChip("f1")).performClick() - assert(selected == "f1") - } - - @Test - fun subscriptionGate_isShown_whenRequired() { - setContent( - DocumentsUiState( - isLoading = false, - subscriptionRequired = true, - errorMessage = "Documents require an active subscription.", - ), - ) - composeRule.onNodeWithTag(DocumentsTestTags.SUBSCRIPTION_GATE).assertIsDisplayed() - } -} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt index 947fc66..ed9a93e 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt @@ -10,27 +10,36 @@ import com.interlinedlist.android.feature.documents.data.local.FolderDao import com.interlinedlist.android.feature.documents.data.local.toDomain import com.interlinedlist.android.feature.documents.data.local.toEntity import com.interlinedlist.android.feature.documents.data.mapper.toDomain -import com.interlinedlist.android.feature.documents.data.mapper.toPaginationDomain import com.interlinedlist.android.feature.documents.data.mapper.toTemplate import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest -import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateFolderRequest import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate -import com.interlinedlist.android.feature.documents.domain.Pagination +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.domain.FolderTree import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody import kotlinx.serialization.json.Json import javax.inject.Inject /** - * Room-backed, offline-first implementation. Reads observe Room; refreshes and - * mutations call the API and write through to Room so the UI updates reactively. + * Room-backed, offline-first implementation. The browser observes a folder's + * contents built from ALL cached folders + documents (so drilling in/out never + * hits the network); [refreshTree] pulls the whole tree once and writes it + * through. Mutations call the API then patch the cache so observers react. */ class DefaultDocumentsRepository @Inject constructor( private val api: DocumentsApi, @@ -40,58 +49,58 @@ class DefaultDocumentsRepository @Inject constructor( private val dispatchers: DispatcherProvider, ) : DocumentsRepository { - override fun observeDocuments(folderId: String?): Flow> { - val source = if (folderId == null) { - documentDao.observeRootDocuments() - } else { - documentDao.observeDocumentsInFolder(folderId) - } - return source.map { rows -> rows.map { it.toDomain() } } + /** The current tree, recomputed whenever folders or documents change in Room. */ + private val treeFlow: Flow = combine( + folderDao.observeFolders(), + documentDao.observeAllDocuments(), + ) { folders, documents -> + buildTree(folders.map { it.toDomain() }, documents.map { it.toDomain() }) } + override fun observeFolderContents(folderId: String?): Flow = + treeFlow.map { tree -> FolderTree.contentsOf(tree, folderId) } + + override fun observeFolderSummaries(): Flow> = + treeFlow.map { tree -> flattenSummaries(tree) } + override fun observeDocument(id: String): Flow = documentDao.observeDocument(id).map { it?.toDomain() } - override fun observeFolders(): Flow> = - folderDao.observeFolders().map { rows -> rows.map { it.toDomain() } } + override suspend fun refreshTree(): ApiResult = withContext(dispatchers.io) { + // One call returns the nested folder tree with embedded docs; a second returns + // the unfiled root documents. We replace the whole cache so deletions drop out. + val foldersResult = safeApiCall(json) { api.getFolders() } + val folders = when (foldersResult) { + is ApiResult.Success -> foldersResult.data.foldersOrEmpty + is ApiResult.Failure -> return@withContext foldersResult + } + val rootResult = safeApiCall(json) { api.getRootDocuments() } + val rootDocs = when (rootResult) { + is ApiResult.Success -> rootResult.data.documentsOrEmpty + is ApiResult.Failure -> return@withContext rootResult + } + + folderDao.clear() + documentDao.clearAll() - override suspend fun refreshDocuments(folderId: String?): ApiResult = - withContext(dispatchers.io) { - val result = safeApiCall(json) { - if (folderId == null) { - api.getDocuments(limit = Pagination.DEFAULT_LIMIT, offset = 0) - } else { - api.getFolderDocuments(folderId, limit = Pagination.DEFAULT_LIMIT, offset = 0) - } + folderDao.upsertAll( + folders.mapIndexed { i, dto -> dto.toDomain().toEntity(sortOrder = i) }, + ) + + var order = 0 + val docEntities = buildList { + // Root/unfiled documents first, then each folder's embedded documents. + rootDocs.forEach { dto -> + add(dto.toDomain().copy(folderId = null).toEntity(sortOrder = order++)) } - when (result) { - is ApiResult.Success -> { - // Replace the listing for this scope so server-side deletions drop out. - if (folderId == null) documentDao.clearRoot() else documentDao.clearFolder(folderId) - ApiResult.Success(cachePage(result.data, folderId, startOrder = 0)) + folders.forEach { folder -> + folder.documentsOrEmpty.forEach { dto -> + add(dto.toDomain().copy(folderId = folder.id).toEntity(sortOrder = order++)) } - is ApiResult.Failure -> result } } - - override suspend fun loadMore( - folderId: String?, - pagination: Pagination, - ): ApiResult = withContext(dispatchers.io) { - if (!pagination.hasMore) return@withContext ApiResult.Success(pagination) - val nextOffset = pagination.nextOffset - val result = safeApiCall(json) { - if (folderId == null) { - api.getDocuments(limit = pagination.limit, offset = nextOffset) - } else { - api.getFolderDocuments(folderId, limit = pagination.limit, offset = nextOffset) - } - } - when (result) { - is ApiResult.Success -> - ApiResult.Success(cachePage(result.data, folderId, startOrder = documentDao.maxSortOrder() + 1)) - is ApiResult.Failure -> result - } + documentDao.upsertAll(docEntities) + ApiResult.Success(Unit) } override suspend fun refreshDocument(id: String): ApiResult = @@ -112,6 +121,7 @@ class DefaultDocumentsRepository @Inject constructor( title: String, content: String, isPublic: Boolean, + folderId: String?, ): ApiResult = withContext(dispatchers.io) { val result = safeApiCall(json) { api.createDocument(CreateDocumentRequest(title, content, isPublic)).documentOrSelf @@ -120,8 +130,14 @@ class DefaultDocumentsRepository @Inject constructor( is ApiResult.Success -> { val dto = result.data ?: return@withContext ApiResult.Failure(AppError.Unknown("Document create returned no body")) - val domain = dto.toDomain() + // The root create endpoint always yields an unfiled doc; assign it to the + // requested folder if one was given so it lands in the right place. + val domain = dto.toDomain().let { if (folderId != null) it.copy(folderId = folderId) else it } documentDao.upsert(domain.toEntity(sortOrder = documentDao.maxSortOrder() + 1)) + if (folderId != null) { + // Best-effort move so the server record matches the cache. + safeApiCall(json) { api.updateDocument(domain.id, UpdateDocumentRequest(folderId = folderId)) } + } ApiResult.Success(domain) } is ApiResult.Failure -> result @@ -143,7 +159,6 @@ class DefaultDocumentsRepository @Inject constructor( } when (result) { is ApiResult.Success -> { - // Fall back to the locally-known values if the server echoes a thin body. val domain = result.data?.toDomain()?.let { it.copy(content = it.content ?: content) } ?: Document( @@ -163,6 +178,23 @@ class DefaultDocumentsRepository @Inject constructor( } } + override suspend fun moveDocument(id: String, folderId: String?): ApiResult = + withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.updateDocument(id, UpdateDocumentRequest(folderId = folderId)) + } + when (result) { + is ApiResult.Success -> { + // Patch the cached row's folder so the browser reflects the move offline. + documentDao.getDocument(id)?.let { cached -> + documentDao.upsert(cached.copy(folderId = folderId)) + } + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + override suspend fun deleteDocument(id: String): ApiResult = withContext(dispatchers.io) { when (val result = safeApiCall(json) { api.deleteDocument(id) }) { @@ -174,36 +206,85 @@ class DefaultDocumentsRepository @Inject constructor( } } - override suspend fun refreshFolders(): ApiResult> = + override suspend fun uploadImage( + documentId: String, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): ApiResult = withContext(dispatchers.io) { + val part = MultipartBody.Part.createFormData( + name = "image", + filename = fileName, + body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()), + ) + safeApiCall(json) { api.uploadImage(documentId, part) }.map { } + } + + override suspend fun createFolder(name: String, parentId: String?): ApiResult = withContext(dispatchers.io) { - when (val result = safeApiCall(json) { api.getFolders() }) { + val result = safeApiCall(json) { + api.createFolder(CreateFolderRequest(name, parentId?.realOrNull())).folderOrSelf + } + when (result) { is ApiResult.Success -> { - val folders = result.data.foldersOrEmpty.map { it.toDomain() } - folderDao.clear() - folderDao.upsertAll(folders.mapIndexed { i, f -> f.toEntity(sortOrder = i) }) - ApiResult.Success(folders) + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.Unknown("Folder create returned no body")) + val domain = dto.toDomain().copy(parentId = parentId?.realOrNull()) + folderDao.upsert(domain.toEntity(sortOrder = folderDao.maxSortOrder() + 1)) + ApiResult.Success(domain) } is ApiResult.Failure -> result } } - override suspend fun createFolder(name: String, parentId: String?): ApiResult = + override suspend fun renameFolder(id: String, name: String): ApiResult = withContext(dispatchers.io) { val result = safeApiCall(json) { - api.createFolder(CreateFolderRequest(name, parentId)).folderOrSelf + api.updateFolder(id, UpdateFolderRequest(name = name)).folderOrSelf } when (result) { is ApiResult.Success -> { + val cached = folderDao.getFolder(id) val dto = result.data - ?: return@withContext ApiResult.Failure(AppError.Unknown("Folder create returned no body")) - val domain = dto.toDomain() - folderDao.upsert(domain.toEntity(sortOrder = Int.MAX_VALUE)) + val domain = dto?.toDomain()?.copy( + parentId = dto.parentId ?: cached?.parentId, + ) ?: DocumentFolder(id = id, name = name, parentId = cached?.parentId) + folderDao.upsert(domain.toEntity(sortOrder = cached?.sortOrder ?: (folderDao.maxSortOrder() + 1))) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun moveFolder(id: String, newParentId: String?): ApiResult = + withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.updateFolder(id, UpdateFolderRequest(parentId = newParentId?.realOrNull())).folderOrSelf + } + when (result) { + is ApiResult.Success -> { + val cached = folderDao.getFolder(id) + val domain = (result.data?.toDomain() ?: DocumentFolder(id, cached?.name ?: "Untitled folder", null)) + .copy(parentId = newParentId?.realOrNull()) + folderDao.upsert(domain.toEntity(sortOrder = cached?.sortOrder ?: (folderDao.maxSortOrder() + 1))) ApiResult.Success(domain) } is ApiResult.Failure -> result } } + override suspend fun deleteFolder(id: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.deleteFolder(id) }) { + is ApiResult.Success -> { + // Server cascades; mirror that locally so the tree updates offline. + pruneFolderCascade(id) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + override suspend fun getTemplates(): ApiResult> = withContext(dispatchers.io) { safeApiCall(json) { api.getTemplates() } @@ -215,13 +296,15 @@ class DefaultDocumentsRepository @Inject constructor( targetFolderId: String?, ): ApiResult = withContext(dispatchers.io) { val result = safeApiCall(json) { - api.createFromTemplate(FromTemplateRequest(templateId, targetFolderId)).documentOrSelf + api.createFromTemplate(FromTemplateRequest(templateId, targetFolderId?.realOrNull())).documentOrSelf } when (result) { is ApiResult.Success -> { val dto = result.data ?: return@withContext ApiResult.Failure(AppError.Unknown("Template create returned no body")) - val domain = dto.toDomain() + val domain = dto.toDomain().let { + if (targetFolderId != null) it.copy(folderId = targetFolderId.realOrNull()) else it + } documentDao.upsert(domain.toEntity(sortOrder = documentDao.maxSortOrder() + 1)) ApiResult.Success(domain) } @@ -235,22 +318,40 @@ class DefaultDocumentsRepository @Inject constructor( .map { response -> response.documentsOrEmpty.map { it.toDomain() } } } - /** Upserts a page of documents starting at [startOrder]; returns its paging metadata. */ - private suspend fun cachePage( - response: DocumentListResponse, - folderId: String?, - startOrder: Int, - ): Pagination { - val documents = response.documentsOrEmpty.map { it.toDomain() } - val entities = documents.mapIndexed { i, doc -> - // Root refreshes clear the table, so folderId on a root doc is honoured as-is. - doc.copy(folderId = doc.folderId ?: folderId) - .toEntity(sortOrder = startOrder + i) + // --- Helpers ----------------------------------------------------------- + + private fun buildTree(folders: List, documents: List): FolderNode { + val byFolder = documents.filter { it.folderId != null }.groupBy { it.folderId!! } + val root = documents.filter { it.folderId == null } + return FolderTree.build(folders, byFolder, root) + } + + private fun flattenSummaries(node: FolderNode): List = buildList { + node.children.forEach { child -> + add(FolderSummary(child.id, child.name, child.documents.size, child.children.size)) + addAll(flattenSummaries(child)) + } + } + + /** Recursively removes a folder, its descendants, and their documents from Room. */ + private suspend fun pruneFolderCascade(folderId: String) { + val snapshot = folderDao.observeFolders().first() + val toRemove = mutableListOf(folderId) + var i = 0 + while (i < toRemove.size) { + val current = toRemove[i] + snapshot.filter { it.parentId == current }.forEach { toRemove.add(it.id) } + i++ + } + toRemove.forEach { id -> + documentDao.clearFolder(id) + folderDao.deleteById(id) } - documentDao.upsertAll(entities) - return response.pagination.toPaginationDomain(fallbackCount = documents.size) } private suspend fun existingOrder(id: String): Int = documentDao.getDocument(id)?.sortOrder ?: (documentDao.maxSortOrder() + 1) + + /** Treats the synthetic root id as "no parent" for API calls. */ + private fun String.realOrNull(): String? = takeUnless { it == FolderNode.ROOT_ID } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt index f06377a..c917b1f 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt @@ -4,42 +4,46 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate -import com.interlinedlist.android.feature.documents.domain.Pagination +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderSummary import kotlinx.coroutines.flow.Flow /** - * Offline-first access to documents and folders. List/detail reads are served as - * [Flow]s from Room (the source of truth); [refreshDocuments]/[loadMore] pull from - * the API and upsert into the cache. Mutations write through to the API and update - * the cache so the observing UI reflects the change immediately. + * Offline-first access to the document folder tree. The browser observes a + * folder's [FolderContents] (subfolders + documents + breadcrumb) as a [Flow] + * derived from Room — the source of truth — while [refreshTree] pulls the whole + * tree from the API (`/folders` nests everything, `/documents` supplies unfiled + * root docs) and writes it through the cache. Mutations write to the API and + * update the cache so observers react immediately. */ interface DocumentsRepository { - /** Root-level documents (no folder), or a folder's contents when [folderId] is set. */ - fun observeDocuments(folderId: String?): Flow> + /** + * Reactive contents of the folder identified by [folderId] (null / the root id + * resolves to the top-level "Documents" node). Rebuilds from Room on any change. + */ + fun observeFolderContents(folderId: String?): Flow + + /** All folders flattened to summaries — used by the "move document" picker. */ + fun observeFolderSummaries(): Flow> /** A single cached document (null until first loaded). */ fun observeDocument(id: String): Flow - /** All cached folders. */ - fun observeFolders(): Flow> - - /** - * Fetches the first page for [folderId] from the API and replaces the cached - * listing for that scope. Returns paging metadata for load-more. - */ - suspend fun refreshDocuments(folderId: String?): ApiResult - - /** Appends the next page for [folderId] into the cache. */ - suspend fun loadMore(folderId: String?, pagination: Pagination): ApiResult + /** Refreshes the entire folder tree + root documents from the API into Room. */ + suspend fun refreshTree(): ApiResult /** Fetches a document detail (with body) and caches it. */ suspend fun refreshDocument(id: String): ApiResult + // --- Document mutations ------------------------------------------------ + + /** Creates a document, optionally inside [folderId] (null == root). */ suspend fun createDocument( title: String, content: String, isPublic: Boolean, + folderId: String?, ): ApiResult suspend fun updateDocument( @@ -50,12 +54,31 @@ interface DocumentsRepository { folderId: String?, ): ApiResult + /** Moves a document into [folderId] (null == root/unfiled). */ + suspend fun moveDocument(id: String, folderId: String?): ApiResult + suspend fun deleteDocument(id: String): ApiResult - suspend fun refreshFolders(): ApiResult> + suspend fun uploadImage( + documentId: String, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): ApiResult + + // --- Folder mutations -------------------------------------------------- suspend fun createFolder(name: String, parentId: String?): ApiResult + suspend fun renameFolder(id: String, name: String): ApiResult + + suspend fun moveFolder(id: String, newParentId: String?): ApiResult + + /** Deletes a folder (server cascades to children + docs); prunes the cache. */ + suspend fun deleteFolder(id: String): ApiResult + + // --- Templates & search ------------------------------------------------ + suspend fun getTemplates(): ApiResult> suspend fun createFromTemplate( diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt index 144bd7a..6abf455 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentDao.kt @@ -10,6 +10,10 @@ import kotlinx.coroutines.flow.Flow @Dao interface DocumentDao { + /** Emits every cached document. The browser groups these by folder to build the tree. */ + @Query("SELECT * FROM document ORDER BY sortOrder ASC") + fun observeAllDocuments(): Flow> + /** Emits root-level documents (no folder), ordered by their server sequence. */ @Query("SELECT * FROM document WHERE folderId IS NULL ORDER BY sortOrder ASC") fun observeRootDocuments(): Flow> @@ -43,4 +47,8 @@ interface DocumentDao { @Query("DELETE FROM document WHERE folderId = :folderId") suspend fun clearFolder(folderId: String) + + /** Wipes all documents before a full-tree refresh from `/folders` + `/documents`. */ + @Query("DELETE FROM document") + suspend fun clearAll() } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt index 1696f26..24d9b6a 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt @@ -10,7 +10,7 @@ import androidx.room.RoomDatabase */ @Database( entities = [DocumentEntity::class, FolderEntity::class], - version = 1, + version = 2, exportSchema = false, ) abstract class DocumentsDatabase : RoomDatabase() { diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt index d1edbb8..fcf9835 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderDao.kt @@ -11,12 +11,21 @@ interface FolderDao { @Query("SELECT * FROM folder ORDER BY sortOrder ASC") fun observeFolders(): Flow> + @Query("SELECT * FROM folder WHERE id = :id") + suspend fun getFolder(id: String): FolderEntity? + + @Query("SELECT COALESCE(MAX(sortOrder), -1) FROM folder") + suspend fun maxSortOrder(): Int + @Upsert suspend fun upsertAll(folders: List) @Upsert suspend fun upsert(folder: FolderEntity) + @Query("DELETE FROM folder WHERE id = :id") + suspend fun deleteById(id: String) + @Query("DELETE FROM folder") suspend fun clear() } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt index 03c1b53..01c9669 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/FolderEntity.kt @@ -10,6 +10,8 @@ data class FolderEntity( @PrimaryKey val id: String, val name: String, val parentId: String?, + val createdAt: String?, + val updatedAt: String?, val sortOrder: Int, ) @@ -17,11 +19,15 @@ fun FolderEntity.toDomain(): DocumentFolder = DocumentFolder( id = id, name = name, parentId = parentId, + createdAt = createdAt, + updatedAt = updatedAt, ) fun DocumentFolder.toEntity(sortOrder: Int): FolderEntity = FolderEntity( id = id, name = name, parentId = parentId, + createdAt = createdAt, + updatedAt = updatedAt, sortOrder = sortOrder, ) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt index 415be4b..daae3a7 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt @@ -5,8 +5,6 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.FolderDto import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate -import com.interlinedlist.android.feature.documents.domain.Pagination -import com.interlinedlist.android.feature.documents.data.remote.dto.PaginationDto /** * Maps a document wire model into the domain [Document]. The snippet prefers a @@ -43,11 +41,6 @@ fun FolderDto.toDomain(): DocumentFolder = DocumentFolder( id = id, name = name?.takeIf { it.isNotBlank() } ?: "Untitled folder", parentId = parentId, + createdAt = createdAt, + updatedAt = updatedAt, ) - -fun PaginationDto?.toPaginationDomain(fallbackCount: Int): Pagination = - if (this == null) { - Pagination.single(fallbackCount) - } else { - Pagination(total = total, limit = limit, offset = offset, hasMore = hasMore) - } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt index 36ab608..281a7f3 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt @@ -8,11 +8,16 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.FolderListRe import com.interlinedlist.android.feature.documents.data.remote.dto.FolderResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateFolderRequest +import okhttp3.MultipartBody +import okhttp3.ResponseBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.PUT +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -22,11 +27,11 @@ import retrofit2.http.Query */ interface DocumentsApi { + // --- Documents --------------------------------------------------------- + + /** Root/unfiled documents (folderId == null). */ @GET("api/documents") - suspend fun getDocuments( - @Query("limit") limit: Int? = null, - @Query("offset") offset: Int? = null, - ): DocumentListResponse + suspend fun getRootDocuments(): DocumentListResponse @POST("api/documents") suspend fun createDocument(@Body body: CreateDocumentRequest): DocumentResponse @@ -50,18 +55,42 @@ interface DocumentsApi { @Query("offset") offset: Int? = null, ): DocumentListResponse + @Multipart + @POST("api/documents/{id}/images/upload") + suspend fun uploadImage( + @Path("id") documentId: String, + @Part image: MultipartBody.Part, + ): ResponseBody + + // --- Folders ----------------------------------------------------------- + + /** + * The whole folder tree in one call: folders nested via `parentId`, each + * carrying its own embedded `documents`. + */ @GET("api/documents/folders") suspend fun getFolders(): FolderListResponse @POST("api/documents/folders") suspend fun createFolder(@Body body: CreateFolderRequest): FolderResponse + @GET("api/documents/folders/{id}") + suspend fun getFolder(@Path("id") id: String): FolderResponse + + /** Rename and/or move (re-parent) a folder. */ + @PUT("api/documents/folders/{id}") + suspend fun updateFolder( + @Path("id") id: String, + @Body body: UpdateFolderRequest, + ): FolderResponse + + @DELETE("api/documents/folders/{id}") + suspend fun deleteFolder(@Path("id") id: String) + @GET("api/documents/folders/{id}/documents") - suspend fun getFolderDocuments( - @Path("id") folderId: String, - @Query("limit") limit: Int? = null, - @Query("offset") offset: Int? = null, - ): DocumentListResponse + suspend fun getFolderDocuments(@Path("id") folderId: String): DocumentListResponse + + // --- Templates --------------------------------------------------------- @GET("api/documents/templates") suspend fun getTemplates(): DocumentListResponse diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt index 3772966..676e7c3 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/FolderDto.kt @@ -2,21 +2,33 @@ package com.interlinedlist.android.feature.documents.data.remote.dto import kotlinx.serialization.Serializable -/** Wire model for a document folder. */ +/** + * Wire model for a document folder. The live `GET /api/documents/folders` response + * nests folders via [parentId] (null == root) and embeds each folder's own + * [documents], so the entire tree arrives in a single call. + */ @Serializable data class FolderDto( val id: String, val name: String? = null, val parentId: String? = null, -) + val documents: List? = null, + val createdAt: String? = null, + val updatedAt: String? = null, +) { + val documentsOrEmpty: List get() = documents ?: emptyList() +} -/** `GET /api/documents/folders`; folders may arrive under `data` or `folders`. */ +/** + * `GET /api/documents/folders`. The live API returns folders under `folders`; a + * `data` envelope is also tolerated for forward-compatibility. + */ @Serializable data class FolderListResponse( val data: List? = null, val folders: List? = null, ) { - val foldersOrEmpty: List get() = data ?: folders ?: emptyList() + val foldersOrEmpty: List get() = folders ?: data ?: emptyList() } /** A single folder, returned either bare or wrapped in `{ "folder": ... }`. */ @@ -26,9 +38,19 @@ data class FolderResponse( val id: String? = null, val name: String? = null, val parentId: String? = null, + val createdAt: String? = null, + val updatedAt: String? = null, ) { val folderOrSelf: FolderDto? - get() = folder ?: id?.let { FolderDto(id = it, name = name, parentId = parentId) } + get() = folder ?: id?.let { + FolderDto( + id = it, + name = name, + parentId = parentId, + createdAt = createdAt, + updatedAt = updatedAt, + ) + } } /** Body for `POST /api/documents/folders`. */ @@ -37,3 +59,13 @@ data class CreateFolderRequest( val name: String, val parentId: String? = null, ) + +/** + * Body for `PUT /api/documents/folders/{id}` — rename and/or move (re-parent). + * Null fields are left unchanged by the server. + */ +@Serializable +data class UpdateFolderRequest( + val name: String? = null, + val parentId: String? = null, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt index 6a712b7..07b1c19 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/DocumentFolder.kt @@ -5,4 +5,54 @@ data class DocumentFolder( val id: String, val name: String, val parentId: String?, + val createdAt: String? = null, + val updatedAt: String? = null, +) + +/** + * A node in the client-side folder tree, built from the flat folder list (linked + * by [DocumentFolder.parentId]) plus each folder's embedded documents. The root of + * the tree is synthesised (see [FolderNode.ROOT_ID]) and holds the unfiled + * documents returned by `GET /api/documents`. + */ +data class FolderNode( + val id: String, + val name: String, + val parentId: String?, + val children: List, + val documents: List, +) { + /** True for the synthesised root node that holds unfiled documents. */ + val isRoot: Boolean get() = id == ROOT_ID + + companion object { + /** Synthetic id for the root/"Documents" node (no real folder on the server). */ + const val ROOT_ID = "__root__" + const val ROOT_NAME = "Documents" + } +} + +/** + * The flattened, ready-to-render contents of a single folder: its direct + * subfolders and its documents, plus the [breadcrumb] path from the root down to + * (and including) this folder. Used by the browser UI at each drill-down level. + */ +data class FolderContents( + val folderId: String, + val folderName: String, + val parentId: String?, + val subfolders: List, + val documents: List, + val breadcrumb: List, +) { + val isRoot: Boolean get() = folderId == FolderNode.ROOT_ID + val isEmpty: Boolean get() = subfolders.isEmpty() && documents.isEmpty() +} + +/** A lightweight folder reference for breadcrumbs, subfolder rows, and pickers. */ +data class FolderSummary( + val id: String, + val name: String, + val documentCount: Int = 0, + val subfolderCount: Int = 0, ) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTree.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTree.kt new file mode 100644 index 0000000..2a924b4 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTree.kt @@ -0,0 +1,91 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * Pure, testable builders that turn the flat folder list (linked by + * [DocumentFolder.parentId]) plus per-folder documents and the unfiled root + * documents into a [FolderNode] tree and, from any point in that tree, into the + * [FolderContents] the browser renders. + * + * These functions do no I/O; the repository feeds them cached folders/documents + * so the tree can be rebuilt reactively from Room. + */ +object FolderTree { + + /** + * Builds the folder tree. + * + * @param folders every known folder (any order); root folders have a null parentId. + * @param documentsByFolder documents keyed by their folder id (embedded folder docs). + * @param rootDocuments unfiled documents (folderId == null), hung off the synthetic root. + * + * Orphan folders (whose parentId points at an unknown/deleted folder) are + * re-parented onto the root so they remain reachable rather than lost. + */ + fun build( + folders: List, + documentsByFolder: Map>, + rootDocuments: List, + ): FolderNode { + val knownIds = folders.mapTo(HashSet()) { it.id } + // Group children by their effective parent (null / unknown parent => root). + val childrenByParent: Map> = folders + .sortedBy { it.name.lowercase() } + .groupBy { folder -> + folder.parentId?.takeIf { it in knownIds } + } + + fun buildNode(folder: DocumentFolder): FolderNode = FolderNode( + id = folder.id, + name = folder.name, + parentId = folder.parentId?.takeIf { it in knownIds }, + children = childrenByParent[folder.id].orEmpty().map(::buildNode), + documents = documentsByFolder[folder.id].orEmpty(), + ) + + val topLevel = childrenByParent[null].orEmpty().map(::buildNode) + return FolderNode( + id = FolderNode.ROOT_ID, + name = FolderNode.ROOT_NAME, + parentId = null, + children = topLevel, + documents = rootDocuments, + ) + } + + /** + * Resolves the contents to show for [folderId] within [root]. A null or the + * synthetic [FolderNode.ROOT_ID] resolves to the root itself. If the id is not + * found (e.g. a folder was deleted while its route was open), falls back to the + * root so the UI degrades gracefully. + */ + fun contentsOf(root: FolderNode, folderId: String?): FolderContents { + val targetId = folderId ?: FolderNode.ROOT_ID + val path = pathTo(root, targetId) ?: listOf(root) + val node = path.last() + return FolderContents( + folderId = node.id, + folderName = node.name, + parentId = node.parentId, + subfolders = node.children.map { it.toSummary() }, + documents = node.documents, + breadcrumb = path.map { it.toSummary() }, + ) + } + + /** Returns the path of nodes from [root] down to the node with [targetId], or null. */ + private fun pathTo(root: FolderNode, targetId: String): List? { + if (root.id == targetId) return listOf(root) + for (child in root.children) { + val sub = pathTo(child, targetId) + if (sub != null) return listOf(root) + sub + } + return null + } + + private fun FolderNode.toSummary() = FolderSummary( + id = id, + name = name, + documentCount = documents.size, + subfolderCount = children.size, + ) +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt deleted file mode 100644 index 60e6189..0000000 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Pagination.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.interlinedlist.android.feature.documents.domain - -/** - * Offset/limit paging metadata returned alongside list responses. [hasMore] - * drives the index's load-more affordance. - */ -data class Pagination( - val total: Int, - val limit: Int, - val offset: Int, - val hasMore: Boolean, -) { - /** Offset to request for the next page. */ - val nextOffset: Int get() = offset + limit - - companion object { - const val DEFAULT_LIMIT = 20 - - /** A single-page result covering [count] items (used for local-only reads). */ - fun single(count: Int) = Pagination( - total = count, - limit = if (count == 0) DEFAULT_LIMIT else count, - offset = 0, - hasMore = false, - ) - } -} - -/** A page of items plus its paging metadata. */ -data class Page( - val items: List, - val pagination: Pagination, -) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/BrowserDialogs.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/BrowserDialogs.kt new file mode 100644 index 0000000..19785b2 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/BrowserDialogs.kt @@ -0,0 +1,312 @@ +package com.interlinedlist.android.feature.documents.ui.browser + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary + +/** The modal currently shown over the browser (single source of truth for dialogs). */ +sealed interface BrowserDialog { + data object None : BrowserDialog + data object CreateDocument : BrowserDialog + data object CreateFolder : BrowserDialog + data class RenameFolder(val folder: FolderSummary) : BrowserDialog + data class ConfirmDeleteFolder(val folder: FolderSummary) : BrowserDialog + data class MoveDocument(val document: Document) : BrowserDialog + data class ConfirmDeleteDoc(val document: Document) : BrowserDialog +} + +@Composable +fun BrowserDialogs( + dialog: BrowserDialog, + state: DocumentsBrowserUiState, + onCreateDocument: (String) -> Unit, + onCreateFolder: (String) -> Unit, + onRenameFolder: (String, String) -> Unit, + onDeleteFolder: (String) -> Unit, + onMoveDocument: (String, String?) -> Unit, + onDeleteDocument: (String) -> Unit, + onDismiss: () -> Unit, +) { + when (dialog) { + BrowserDialog.None -> Unit + + BrowserDialog.CreateDocument -> TextInputDialog( + title = "New document", + label = "Title", + confirmLabel = "Create", + initial = "", + onConfirm = { onCreateDocument(it); onDismiss() }, + onDismiss = onDismiss, + ) + + BrowserDialog.CreateFolder -> TextInputDialog( + title = "New folder", + label = "Folder name", + confirmLabel = "Create", + initial = "", + requireNonBlank = true, + onConfirm = { onCreateFolder(it); onDismiss() }, + onDismiss = onDismiss, + ) + + is BrowserDialog.RenameFolder -> TextInputDialog( + title = "Rename folder", + label = "Folder name", + confirmLabel = "Rename", + initial = dialog.folder.name, + requireNonBlank = true, + onConfirm = { onRenameFolder(dialog.folder.id, it); onDismiss() }, + onDismiss = onDismiss, + ) + + is BrowserDialog.ConfirmDeleteFolder -> ConfirmDialog( + title = "Delete folder?", + message = "\"${dialog.folder.name}\" and everything inside it will be deleted.", + confirmLabel = "Delete", + onConfirm = { onDeleteFolder(dialog.folder.id); onDismiss() }, + onDismiss = onDismiss, + ) + + is BrowserDialog.ConfirmDeleteDoc -> ConfirmDialog( + title = "Delete document?", + message = "\"${dialog.document.title}\" will be deleted.", + confirmLabel = "Delete", + onConfirm = { onDeleteDocument(dialog.document.id); onDismiss() }, + onDismiss = onDismiss, + ) + + is BrowserDialog.MoveDocument -> MoveDocumentDialog( + document = dialog.document, + folders = state.allFolders, + onMove = { target -> onMoveDocument(dialog.document.id, target); onDismiss() }, + onDismiss = onDismiss, + ) + } +} + +@Composable +private fun TextInputDialog( + title: String, + label: String, + confirmLabel: String, + initial: String, + requireNonBlank: Boolean = false, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var value by remember { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(label) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("dialogInput"), + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(value) }, + enabled = !requireNonBlank || value.isNotBlank(), + modifier = Modifier.testTag("dialogConfirm"), + ) { Text(confirmLabel) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun ConfirmDialog( + title: String, + message: String, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { Text(message) }, + confirmButton = { + TextButton(onClick = onConfirm, modifier = Modifier.testTag("dialogConfirm")) { + Text(confirmLabel, color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +/** Lets the user pick a destination folder (or the root) for a document. */ +@Composable +private fun MoveDocumentDialog( + document: Document, + folders: List, + onMove: (String?) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Move \"${document.title}\"") }, + text = { + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp)) { + item { + FolderPickerRow( + name = FolderNode.ROOT_NAME, + selected = document.folderId == null, + onClick = { onMove(null) }, + ) + } + items(folders, key = { it.id }) { folder -> + FolderPickerRow( + name = folder.name, + selected = document.folderId == folder.id, + onClick = { onMove(folder.id) }, + ) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun FolderPickerRow(name: String, selected: Boolean, onClick: () -> Unit) { + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !selected, onClick = onClick) + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.padding(horizontal = 8.dp)) + Text( + text = name + if (selected) " (current)" else "", + style = MaterialTheme.typography.bodyLarge, + color = if (selected) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** Full-screen search overlay backed by the dedicated `/documents/search` endpoint. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentSearchOverlay( + query: String, + isSearching: Boolean, + results: List, + onQueryChange: (String) -> Unit, + onOpenDocument: (String) -> Unit, + onClose: () -> Unit, +) { + Dialog(onDismissRequest = onClose, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Surface(modifier = Modifier.fillMaxSize()) { + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text("Search documents") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(DocumentsBrowserTestTags.SEARCH_FIELD), + ) + }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon(Icons.Default.Close, contentDescription = "Close search") + } + }, + ) + when { + isSearching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + query.isBlank() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "Type to search your documents.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + results.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + "No documents match \"$query\".", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(DocumentsBrowserTestTags.SEARCH_RESULTS), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(results, key = { it.id }) { doc -> + Column( + Modifier + .fillMaxWidth() + .clickable { onOpenDocument(doc.id) } + .testTag(DocumentsBrowserTestTags.docRow(doc.id)) + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text(doc.title, style = MaterialTheme.typography.titleMedium) + if (doc.snippet.isNotBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + doc.snippet, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } + } + } + } + } + } + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt new file mode 100644 index 0000000..3c8b7c8 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt @@ -0,0 +1,494 @@ +package com.interlinedlist.android.feature.documents.ui.browser + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.DriveFileMove +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary + +/** Stable test tags for the documents browser. */ +object DocumentsBrowserTestTags { + const val LIST = "browserList" + const val CREATE_FAB = "browserCreateFab" + const val CREATE_DOC = "browserCreateDoc" + const val CREATE_FOLDER = "browserCreateFolder" + const val SEARCH_ACTION = "browserSearchAction" + const val SEARCH_FIELD = "browserSearchField" + const val SEARCH_RESULTS = "browserSearchResults" + const val BREADCRUMB = "browserBreadcrumb" + const val EMPTY = "browserEmpty" + const val PROGRESS = "browserProgress" + const val ERROR = "browserError" + const val SUBSCRIPTION_GATE = "browserSubscriptionGate" + const val BACK = "browserBack" + fun folderRow(id: String) = "folderRow_$id" + fun docRow(id: String) = "docRow_$id" + fun crumb(id: String) = "crumb_$id" +} + +/** + * Root browser entry (the app's Documents tab). Kept named `DocumentsRoute` so the + * app NavHost keeps compiling. [onOpenFolder] pushes a drill-down level; + * [onOpenDocument] opens the editor; [onCreateDocument] receives the id of a freshly + * created document so the caller can open it. + */ +@Composable +fun DocumentsRoute( + onOpenFolder: (String) -> Unit, + onOpenDocument: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: DocumentsBrowserViewModel = hiltViewModel(), +) { + DocumentsFolderRoute( + onOpenFolder = onOpenFolder, + onOpenDocument = onOpenDocument, + onBack = null, + modifier = modifier, + viewModel = viewModel, + ) +} + +/** + * A single drill-down level. Reads its target folder from [FOLDER_ID_ARG] via the + * ViewModel's SavedStateHandle. [onBack] is null at the root (no up navigation). + */ +@Composable +fun DocumentsFolderRoute( + onOpenFolder: (String) -> Unit, + onOpenDocument: (String) -> Unit, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, + viewModel: DocumentsBrowserViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + DocumentsBrowserScreen( + state = state, + onOpenFolder = onOpenFolder, + onOpenDocument = onOpenDocument, + onCreateDocument = { title -> viewModel.createDocument(title, onOpenDocument) }, + onCreateFolder = viewModel::createFolder, + onRenameFolder = viewModel::renameFolder, + onDeleteFolder = viewModel::deleteFolder, + onMoveDocument = viewModel::moveDocument, + onDeleteDocument = viewModel::deleteDocument, + onOpenSearch = viewModel::openSearch, + onCloseSearch = viewModel::closeSearch, + onSearchQueryChange = viewModel::onSearchQueryChange, + onBack = onBack, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentsBrowserScreen( + state: DocumentsBrowserUiState, + onOpenFolder: (String) -> Unit, + onOpenDocument: (String) -> Unit, + onCreateDocument: (String) -> Unit, + onCreateFolder: (String) -> Unit, + onRenameFolder: (String, String) -> Unit, + onDeleteFolder: (String) -> Unit, + onMoveDocument: (String, String?) -> Unit, + onDeleteDocument: (String) -> Unit, + onOpenSearch: () -> Unit, + onCloseSearch: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onBack: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + var dialog by remember { mutableStateOf(BrowserDialog.None) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = state.contents.folderName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + navigationIcon = { + if (onBack != null) { + IconButton(onClick = onBack, modifier = Modifier.testTag(DocumentsBrowserTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Up") + } + } + }, + actions = { + IconButton( + onClick = onOpenSearch, + modifier = Modifier.testTag(DocumentsBrowserTestTags.SEARCH_ACTION), + ) { + Icon(Icons.Default.Search, contentDescription = "Search documents") + } + IconButton( + onClick = { dialog = BrowserDialog.CreateFolder }, + modifier = Modifier.testTag(DocumentsBrowserTestTags.CREATE_FOLDER), + ) { + Icon(Icons.Default.CreateNewFolder, contentDescription = "New folder") + } + }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { dialog = BrowserDialog.CreateDocument }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("New") }, + modifier = Modifier.testTag(DocumentsBrowserTestTags.CREATE_FAB), + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> SubscriptionGate( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(DocumentsBrowserTestTags.PROGRESS)) + } + + else -> BrowserContent( + state = state, + onOpenFolder = onOpenFolder, + onOpenDocument = onOpenDocument, + onRequestRename = { dialog = BrowserDialog.RenameFolder(it) }, + onRequestDeleteFolder = { dialog = BrowserDialog.ConfirmDeleteFolder(it) }, + onRequestMoveDoc = { dialog = BrowserDialog.MoveDocument(it) }, + onRequestDeleteDoc = { dialog = BrowserDialog.ConfirmDeleteDoc(it) }, + contentPadding = padding, + ) + } + } + + if (state.isSearchActive) { + DocumentSearchOverlay( + query = state.searchQuery, + isSearching = state.isSearching, + results = state.searchResults, + onQueryChange = onSearchQueryChange, + onOpenDocument = { onOpenDocument(it); onCloseSearch() }, + onClose = onCloseSearch, + ) + } + + BrowserDialogs( + dialog = dialog, + state = state, + onCreateDocument = onCreateDocument, + onCreateFolder = onCreateFolder, + onRenameFolder = onRenameFolder, + onDeleteFolder = onDeleteFolder, + onMoveDocument = onMoveDocument, + onDeleteDocument = onDeleteDocument, + onDismiss = { dialog = BrowserDialog.None }, + ) +} + +@Composable +private fun BrowserContent( + state: DocumentsBrowserUiState, + onOpenFolder: (String) -> Unit, + onOpenDocument: (String) -> Unit, + onRequestRename: (FolderSummary) -> Unit, + onRequestDeleteFolder: (FolderSummary) -> Unit, + onRequestMoveDoc: (Document) -> Unit, + onRequestDeleteDoc: (Document) -> Unit, + contentPadding: PaddingValues, +) { + Column(Modifier.fillMaxSize().padding(contentPadding)) { + if (state.contents.breadcrumb.size > 1) { + Breadcrumb(state.contents.breadcrumb, onOpenFolder) + } + + if (state.errorMessage != null && !state.subscriptionRequired) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(DocumentsBrowserTestTags.ERROR), + ) + } + + if (state.isEmpty) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "This folder is empty. Tap New to add a document or folder.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp).testTag(DocumentsBrowserTestTags.EMPTY), + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().testTag(DocumentsBrowserTestTags.LIST), + contentPadding = PaddingValues(bottom = 96.dp), + ) { + items(state.contents.subfolders, key = { "folder-${it.id}" }) { folder -> + FolderRow( + folder = folder, + onClick = { onOpenFolder(folder.id) }, + onRename = { onRequestRename(folder) }, + onDelete = { onRequestDeleteFolder(folder) }, + ) + } + items(state.contents.documents, key = { "doc-${it.id}" }) { document -> + DocumentRow( + document = document, + onClick = { onOpenDocument(document.id) }, + onMove = { onRequestMoveDoc(document) }, + onDelete = { onRequestDeleteDoc(document) }, + ) + } + } + } + } +} + +@Composable +private fun Breadcrumb(path: List, onOpenFolder: (String) -> Unit) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(DocumentsBrowserTestTags.BREADCRUMB), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items(path, key = { it.id }) { crumb -> + val isLast = crumb.id == path.last().id + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = crumb.name, + style = MaterialTheme.typography.labelLarge, + color = if (isLast) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary, + maxLines = 1, + modifier = Modifier + .clickable(enabled = !isLast) { onOpenFolder(crumb.id) } + .padding(vertical = 4.dp, horizontal = 2.dp) + .testTag(DocumentsBrowserTestTags.crumb(crumb.id)), + ) + if (!isLast) { + Icon( + Icons.Default.ChevronRight, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } +} + +@Composable +private fun FolderRow( + folder: FolderSummary, + onClick: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(DocumentsBrowserTestTags.folderRow(folder.id)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.size(16.dp)) + Column(Modifier.weight(1f)) { + Text(folder.name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + text = subtitleFor(folder), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + RowOverflowMenu( + actions = listOf( + OverflowAction("Rename", Icons.Default.Edit, onRename), + OverflowAction("Delete", Icons.Default.Delete, onDelete), + ), + ) + } +} + +@Composable +private fun DocumentRow( + document: Document, + onClick: () -> Unit, + onMove: () -> Unit, + onDelete: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(DocumentsBrowserTestTags.docRow(document.id)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Description, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.size(16.dp)) + Column(Modifier.weight(1f)) { + Text(document.title, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (document.snippet.isNotBlank()) { + Text( + text = document.snippet, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + RowOverflowMenu( + actions = listOf( + OverflowAction("Move", Icons.AutoMirrored.Filled.DriveFileMove, onMove), + OverflowAction("Delete", Icons.Default.Delete, onDelete), + ), + ) + } +} + +private data class OverflowAction(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector, val onClick: () -> Unit) + +@Composable +private fun RowOverflowMenu(actions: List) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton(onClick = { expanded = true }) { + Icon(Icons.Default.MoreVert, contentDescription = "More actions") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + actions.forEach { action -> + DropdownMenuItem( + text = { Text(action.label) }, + leadingIcon = { Icon(action.icon, contentDescription = null) }, + onClick = { expanded = false; action.onClick() }, + ) + } + } + } +} + +private fun subtitleFor(folder: FolderSummary): String { + val parts = buildList { + if (folder.subfolderCount > 0) add("${folder.subfolderCount} folder" + if (folder.subfolderCount == 1) "" else "s") + add("${folder.documentCount} doc" + if (folder.documentCount == 1) "" else "s") + } + return parts.joinToString(" · ") +} + +@Composable +private fun SubscriptionGate(message: String?, modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp).testTag(DocumentsBrowserTestTags.SUBSCRIPTION_GATE), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Subscriber feature", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Documents require an active subscription.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentsBrowserPreview() { + InterlinedListTheme { + DocumentsBrowserScreen( + state = DocumentsBrowserUiState( + isLoading = false, + contents = FolderContents( + folderId = FolderNode.ROOT_ID, + folderName = "Documents", + parentId = null, + subfolders = listOf(FolderSummary("f1", "Work", 3, 1), FolderSummary("f2", "Personal", 1, 0)), + documents = listOf(Document("1", "Grocery list", null, "Milk, eggs, bread", null, null, false, null)), + breadcrumb = listOf(FolderSummary(FolderNode.ROOT_ID, "Documents")), + ), + ), + onOpenFolder = {}, + onOpenDocument = {}, + onCreateDocument = {}, + onCreateFolder = {}, + onRenameFolder = { _, _ -> }, + onDeleteFolder = {}, + onMoveDocument = { _, _ -> }, + onDeleteDocument = {}, + onOpenSearch = {}, + onCloseSearch = {}, + onSearchQueryChange = {}, + onBack = null, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt new file mode 100644 index 0000000..21960b9 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt @@ -0,0 +1,223 @@ +package com.interlinedlist.android.feature.documents.ui.browser + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav arg key the browser reads the current folder id from (absent == root). */ +const val FOLDER_ID_ARG = "folderId" + +/** Sentinel value the root route passes so nav treats "no folder" uniformly. */ +const val ROOT_FOLDER_ARG = "__root__" + +/** UI state for one level of the folder browser. */ +data class DocumentsBrowserUiState( + val contents: FolderContents = EMPTY_ROOT, + val allFolders: List = emptyList(), + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + // Search overlay. + val searchQuery: String = "", + val isSearchActive: Boolean = false, + val isSearching: Boolean = false, + val searchResults: List = emptyList(), +) { + val isEmpty: Boolean get() = contents.isEmpty && !isLoading + + companion object { + val EMPTY_ROOT = FolderContents( + folderId = FolderNode.ROOT_ID, + folderName = FolderNode.ROOT_NAME, + parentId = null, + subfolders = emptyList(), + documents = emptyList(), + breadcrumb = emptyList(), + ) + } +} + +/** + * Drives one drill-down level of the folder browser. The level's [FOLDER_ID_ARG] + * (or the root when absent) selects which folder's contents to observe from the + * offline-first repository. All folder/document management flows through here. + */ +@HiltViewModel +class DocumentsBrowserViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + /** Null means the root ("Documents") level. */ + private val folderId: String? = + savedStateHandle.get(FOLDER_ID_ARG)?.takeUnless { it == ROOT_FOLDER_ARG } + + private val _uiState = MutableStateFlow(DocumentsBrowserUiState(isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + private var searchJob: Job? = null + + init { + observeContents() + observeFolders() + refresh() + } + + private fun observeContents() { + viewModelScope.launch { + repository.observeFolderContents(folderId).collect { contents -> + _uiState.update { it.copy(contents = contents) } + } + } + } + + private fun observeFolders() { + viewModelScope.launch { + repository.observeFolderSummaries().collect { folders -> + _uiState.update { it.copy(allFolders = folders) } + } + } + } + + /** Pulls the whole tree from the API into Room; the observers render the result. */ + fun refresh() { + _uiState.update { + it.copy(isRefreshing = true, isLoading = it.contents.isEmpty, errorMessage = null) + } + viewModelScope.launch { + when (val result = repository.refreshTree()) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoading = false, isRefreshing = false, subscriptionRequired = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoading = false, + isRefreshing = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + // --- Document actions -------------------------------------------------- + + /** Creates a document in this folder; invokes [onCreated] with its id to open it. */ + fun createDocument(title: String, onCreated: (String) -> Unit) { + val trimmed = title.trim().ifBlank { "Untitled" } + viewModelScope.launch { + val result = repository.createDocument( + title = trimmed, + content = "", + isPublic = false, + folderId = folderId, + ) + when (result) { + is ApiResult.Success -> onCreated(result.data.id) + is ApiResult.Failure -> showError(result.error.toUserMessage()) + } + } + } + + /** Moves [documentId] into [targetFolderId] (null == root/unfiled). */ + fun moveDocument(documentId: String, targetFolderId: String?) { + viewModelScope.launch { + when (val result = repository.moveDocument(documentId, targetFolderId)) { + is ApiResult.Success -> Unit // Observed contents update the UI. + is ApiResult.Failure -> showError(result.error.toUserMessage()) + } + } + } + + fun deleteDocument(documentId: String) { + viewModelScope.launch { + when (val result = repository.deleteDocument(documentId)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> showError(result.error.toUserMessage()) + } + } + } + + // --- Folder actions ---------------------------------------------------- + + fun createFolder(name: String) { + val trimmed = name.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + when (val result = repository.createFolder(trimmed, parentId = folderId)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> showError(result.error.toUserMessage()) + } + } + } + + fun renameFolder(id: String, newName: String) { + val trimmed = newName.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + when (val result = repository.renameFolder(id, trimmed)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> showError(result.error.toUserMessage()) + } + } + } + + fun deleteFolder(id: String) { + viewModelScope.launch { + when (val result = repository.deleteFolder(id)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> showError(result.error.toUserMessage()) + } + } + } + + // --- Search ------------------------------------------------------------ + + fun openSearch() = _uiState.update { it.copy(isSearchActive = true) } + + fun closeSearch() = _uiState.update { + it.copy(isSearchActive = false, searchQuery = "", searchResults = emptyList(), isSearching = false) + } + + fun onSearchQueryChange(query: String) { + _uiState.update { it.copy(searchQuery = query) } + searchJob?.cancel() + if (query.isBlank()) { + _uiState.update { it.copy(searchResults = emptyList(), isSearching = false) } + return + } + _uiState.update { it.copy(isSearching = true) } + searchJob = viewModelScope.launch { + when (val result = repository.searchDocuments(query.trim())) { + is ApiResult.Success -> _uiState.update { + it.copy(isSearching = false, searchResults = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSearching = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + private fun showError(message: String) = _uiState.update { it.copy(errorMessage = message) } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt index 084033e..66ba6e1 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt @@ -9,10 +9,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Visibility import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -43,6 +47,7 @@ object DocumentEditorTestTags { const val PREVIEW = "editorPreview" const val SAVE = "editorSave" const val DELETE = "editorDelete" + const val UPLOAD_IMAGE = "editorUploadImage" const val TOGGLE_PREVIEW = "editorTogglePreview" const val PROGRESS = "editorProgress" const val ERROR = "editorError" @@ -61,6 +66,25 @@ fun DocumentEditorRoute( viewModel: DocumentEditorViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = androidx.compose.ui.platform.LocalContext.current + + // Android Photo Picker: reads the picked image's bytes and hands them to the VM. + val pickImage = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia(), + ) { uri -> + if (uri != null) { + val resolver = context.contentResolver + val mime = resolver.getType(uri) ?: "image/*" + val bytes = runCatching { + resolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() + if (bytes != null) { + val name = uri.lastPathSegment?.substringAfterLast('/') ?: "image" + viewModel.uploadImage(fileName = name, mimeType = mime, bytes = bytes) + } + } + } + DocumentEditorScreen( state = state, onTitleChange = viewModel::onTitleChange, @@ -68,6 +92,11 @@ fun DocumentEditorRoute( onTogglePreview = viewModel::togglePreview, onSave = { viewModel.save() }, onDelete = { viewModel.delete(onDeleted) }, + onPickImage = { + pickImage.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, onBack = onBack, modifier = modifier, ) @@ -85,6 +114,7 @@ fun DocumentEditorScreen( onDelete: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, + onPickImage: () -> Unit = {}, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -103,6 +133,17 @@ fun DocumentEditorScreen( } }, actions = { + IconButton( + onClick = onPickImage, + enabled = !state.isUploadingImage && !state.isSaving, + modifier = Modifier.testTag(DocumentEditorTestTags.UPLOAD_IMAGE), + ) { + if (state.isUploadingImage) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Image, contentDescription = "Insert image") + } + } IconButton( onClick = onTogglePreview, modifier = Modifier.testTag(DocumentEditorTestTags.TOGGLE_PREVIEW), diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt index 9163053..2cca16f 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt @@ -24,6 +24,7 @@ data class DocumentEditorUiState( val folderId: String? = null, val isLoading: Boolean = true, val isSaving: Boolean = false, + val isUploadingImage: Boolean = false, val isPreview: Boolean = false, val hasUnsavedChanges: Boolean = false, val errorMessage: String? = null, @@ -110,6 +111,31 @@ class DocumentEditorViewModel @Inject constructor( fun togglePreview() = _uiState.update { it.copy(isPreview = !it.isPreview) } + /** + * Uploads a picked image for this document and appends a markdown image + * reference for it into the body. The screen supplies the raw bytes read from + * the picker's content URI; keeping the VM byte-based avoids an Android + * dependency here and keeps it unit-testable. + */ + fun uploadImage(fileName: String, mimeType: String, bytes: ByteArray) { + _uiState.update { it.copy(isUploadingImage = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.uploadImage(documentId, fileName, mimeType, bytes)) { + is ApiResult.Success -> _uiState.update { + val marker = "\n![${fileName}](uploading…)\n" + it.copy( + isUploadingImage = false, + content = it.content + marker, + hasUnsavedChanges = true, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isUploadingImage = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + /** Persists edits; invokes [onSaved] on success. */ fun save(onSaved: () -> Unit = {}) { val state = _uiState.value diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt deleted file mode 100644 index c18629f..0000000 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsScreen.kt +++ /dev/null @@ -1,321 +0,0 @@ -package com.interlinedlist.android.feature.documents.ui.index - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExtendedFloatingActionButton -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme -import com.interlinedlist.android.feature.documents.domain.Document -import com.interlinedlist.android.feature.documents.domain.DocumentFolder - -/** Stable test tags for the documents index. */ -object DocumentsTestTags { - const val LIST = "documentsList" - const val CREATE_FAB = "documentsCreateFab" - const val SEARCH = "documentsSearch" - const val EMPTY = "documentsEmpty" - const val PROGRESS = "documentsProgress" - const val ERROR = "documentsError" - const val SUBSCRIPTION_GATE = "documentsSubscriptionGate" - fun row(id: String) = "documentRow_$id" - fun folderChip(id: String?) = "folderChip_${id ?: "root"}" -} - -/** - * Hilt-wired index entry. [onOpenDocument] navigates to the editor for a document - * id; [onCreateDocument] is invoked with the id of a freshly created document so - * the caller can open it; [onSearch] opens the search screen. - */ -@Composable -fun DocumentsRoute( - onOpenDocument: (String) -> Unit, - onSearch: () -> Unit, - modifier: Modifier = Modifier, - viewModel: DocumentsViewModel = hiltViewModel(), -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - DocumentsScreen( - state = state, - onSelectFolder = viewModel::selectFolder, - onOpenDocument = onOpenDocument, - onCreateDocument = { viewModel.createDocument(title = "Untitled", onCreated = onOpenDocument) }, - onLoadMore = viewModel::loadMore, - onSearch = onSearch, - modifier = modifier, - ) -} - -/** Stateless documents index — folder filter chips + a scrollable list. */ -@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) -@Composable -fun DocumentsScreen( - state: DocumentsUiState, - onSelectFolder: (String?) -> Unit, - onOpenDocument: (String) -> Unit, - onCreateDocument: () -> Unit, - onLoadMore: () -> Unit, - onSearch: () -> Unit, - modifier: Modifier = Modifier, -) { - val listState = rememberLazyListState() - - // Trigger load-more when the last item scrolls into view. - LaunchedEffect(listState, state.hasMore, state.documents.size) { - snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } - .collect { lastVisible -> - if (state.hasMore && !state.isLoadingMore && - lastVisible != null && lastVisible >= state.documents.size - 1 - ) { - onLoadMore() - } - } - } - - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text("Documents") }, - actions = { - IconButton(onClick = onSearch, modifier = Modifier.testTag(DocumentsTestTags.SEARCH)) { - Icon(Icons.Default.Search, contentDescription = "Search documents") - } - }, - ) - }, - floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onCreateDocument, - icon = { Icon(Icons.Default.Add, contentDescription = null) }, - text = { Text("New") }, - modifier = Modifier.testTag(DocumentsTestTags.CREATE_FAB), - ) - }, - ) { padding -> - when { - state.subscriptionRequired -> SubscriptionGate( - message = state.errorMessage, - modifier = Modifier.padding(padding), - ) - - state.isLoading -> Box( - Modifier.fillMaxSize().padding(padding), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator(Modifier.testTag(DocumentsTestTags.PROGRESS)) - } - - else -> DocumentsContent( - state = state, - listState = listState, - onSelectFolder = onSelectFolder, - onOpenDocument = onOpenDocument, - contentPadding = padding, - ) - } - } -} - -@Composable -private fun DocumentsContent( - state: DocumentsUiState, - listState: androidx.compose.foundation.lazy.LazyListState, - onSelectFolder: (String?) -> Unit, - onOpenDocument: (String) -> Unit, - contentPadding: PaddingValues, -) { - Column(Modifier.fillMaxSize().padding(contentPadding)) { - if (state.folders.isNotEmpty()) { - FolderChips( - folders = state.folders, - selectedFolderId = state.selectedFolderId, - onSelectFolder = onSelectFolder, - ) - } - - if (state.errorMessage != null && !state.subscriptionRequired) { - Text( - text = state.errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) - .testTag(DocumentsTestTags.ERROR), - ) - } - - if (state.isEmpty) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text( - text = "No documents yet. Tap New to create one.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.testTag(DocumentsTestTags.EMPTY), - ) - } - } else { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize().testTag(DocumentsTestTags.LIST), - contentPadding = PaddingValues(bottom = 96.dp), - ) { - items(state.documents, key = { it.id }) { document -> - DocumentRow(document = document, onClick = { onOpenDocument(document.id) }) - } - if (state.isLoadingMore) { - item { - Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { - CircularProgressIndicator(Modifier.size(24.dp)) - } - } - } - } - } - } -} - -@Composable -private fun FolderChips( - folders: List, - selectedFolderId: String?, - onSelectFolder: (String?) -> Unit, -) { - androidx.compose.foundation.lazy.LazyRow( - modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp), - ) { - item { - FilterChip( - selected = selectedFolderId == null, - onClick = { onSelectFolder(null) }, - label = { Text("All") }, - modifier = Modifier.testTag(DocumentsTestTags.folderChip(null)), - ) - } - items(folders, key = { it.id }) { folder -> - FilterChip( - selected = selectedFolderId == folder.id, - onClick = { onSelectFolder(folder.id) }, - label = { Text(folder.name) }, - modifier = Modifier.testTag(DocumentsTestTags.folderChip(folder.id)), - ) - } - } -} - -@Composable -private fun DocumentRow(document: Document, onClick: () -> Unit) { - Column( - modifier = Modifier - .fillMaxWidth() - .testTag(DocumentsTestTags.row(document.id)) - .padding(horizontal = 16.dp, vertical = 12.dp), - ) { - Text( - text = document.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (document.snippet.isNotBlank()) { - Spacer(Modifier.height(4.dp)) - Text( - text = document.snippet, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - if (document.folderName != null || document.updatedAt != null) { - Spacer(Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - document.folderName?.let { - Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) - } - document.updatedAt?.let { - Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - } - } -} - -@Composable -private fun SubscriptionGate(message: String?, modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxSize().padding(24.dp).testTag(DocumentsTestTags.SUBSCRIPTION_GATE), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = "Subscriber feature", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(8.dp)) - Text( - text = message ?: "Documents require an active subscription.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Preview(showBackground = true) -@Composable -private fun DocumentsScreenPreview() { - InterlinedListTheme { - DocumentsScreen( - state = DocumentsUiState( - documents = listOf( - Document("1", "Grocery list", null, "Milk, eggs, bread", null, null, false, "2h ago"), - Document("2", "Meeting notes", null, "Discussed Q3 roadmap", "f1", "Work", false, "1d ago"), - ), - folders = listOf(DocumentFolder("f1", "Work", null)), - ), - onSelectFolder = {}, - onOpenDocument = {}, - onCreateDocument = {}, - onLoadMore = {}, - onSearch = {}, - ) - } -} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt deleted file mode 100644 index 957664a..0000000 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/index/DocumentsViewModel.kt +++ /dev/null @@ -1,191 +0,0 @@ -package com.interlinedlist.android.feature.documents.ui.index - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.feature.documents.data.DocumentsRepository -import com.interlinedlist.android.feature.documents.domain.Document -import com.interlinedlist.android.feature.documents.domain.DocumentFolder -import com.interlinedlist.android.feature.documents.domain.DocumentTemplate -import com.interlinedlist.android.feature.documents.domain.Pagination -import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate -import com.interlinedlist.android.feature.documents.ui.common.toUserMessage -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** UI state for the documents index. */ -data class DocumentsUiState( - val documents: List = emptyList(), - val folders: List = emptyList(), - val selectedFolderId: String? = null, - val isLoading: Boolean = false, - val isLoadingMore: Boolean = false, - val isRefreshing: Boolean = false, - val hasMore: Boolean = false, - val errorMessage: String? = null, - val subscriptionRequired: Boolean = false, - // Template picker state. - val templates: List = emptyList(), - val isLoadingTemplates: Boolean = false, -) { - val isEmpty: Boolean get() = documents.isEmpty() && !isLoading -} - -@HiltViewModel -class DocumentsViewModel @Inject constructor( - private val repository: DocumentsRepository, -) : ViewModel() { - - private val _uiState = MutableStateFlow(DocumentsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - /** Paging cursor for the current folder scope; advanced by [loadMore]. */ - private var pagination: Pagination = Pagination.single(0) - private var observeJob: Job? = null - - init { - observeDocuments(folderId = null) - observeFolders() - refresh() - } - - /** Re-points the Room observer at the given scope (root when null). */ - private fun observeDocuments(folderId: String?) { - observeJob?.cancel() - observeJob = viewModelScope.launch { - repository.observeDocuments(folderId).collect { docs -> - _uiState.update { it.copy(documents = docs) } - } - } - } - - private fun observeFolders() { - viewModelScope.launch { - repository.observeFolders().collect { folders -> - _uiState.update { it.copy(folders = folders) } - } - } - } - - /** Switches the visible folder and refreshes it from the API. */ - fun selectFolder(folderId: String?) { - if (folderId == _uiState.value.selectedFolderId) return - _uiState.update { it.copy(selectedFolderId = folderId, documents = emptyList()) } - observeDocuments(folderId) - refresh() - } - - /** Pulls the first page for the current scope from the API. */ - fun refresh() { - val folderId = _uiState.value.selectedFolderId - _uiState.update { it.copy(isRefreshing = true, isLoading = it.documents.isEmpty(), errorMessage = null) } - viewModelScope.launch { - when (val result = repository.refreshDocuments(folderId)) { - is ApiResult.Success -> { - pagination = result.data - _uiState.update { - it.copy( - isLoading = false, - isRefreshing = false, - hasMore = result.data.hasMore, - subscriptionRequired = false, - ) - } - } - is ApiResult.Failure -> _uiState.update { - it.copy( - isLoading = false, - isRefreshing = false, - errorMessage = result.error.toUserMessage(), - subscriptionRequired = result.error.isSubscriptionGate, - ) - } - } - refreshFolders() - } - } - - private suspend fun refreshFolders() { - // Best-effort; folder failures don't block the document list. - repository.refreshFolders() - } - - /** Appends the next page when [DocumentsUiState.hasMore]. */ - fun loadMore() { - val state = _uiState.value - if (!state.hasMore || state.isLoadingMore) return - _uiState.update { it.copy(isLoadingMore = true) } - viewModelScope.launch { - when (val result = repository.loadMore(state.selectedFolderId, pagination)) { - is ApiResult.Success -> { - pagination = result.data - _uiState.update { it.copy(isLoadingMore = false, hasMore = result.data.hasMore) } - } - is ApiResult.Failure -> _uiState.update { - it.copy(isLoadingMore = false, errorMessage = result.error.toUserMessage()) - } - } - } - } - - /** Creates a document in the current scope; invokes [onCreated] with its id. */ - fun createDocument(title: String, onCreated: (String) -> Unit) { - val trimmed = title.trim().ifBlank { "Untitled" } - viewModelScope.launch { - when (val result = repository.createDocument(trimmed, content = "", isPublic = false)) { - is ApiResult.Success -> onCreated(result.data.id) - is ApiResult.Failure -> _uiState.update { - it.copy(errorMessage = result.error.toUserMessage()) - } - } - } - } - - /** Loads templates for the picker (lazily, when the sheet opens). */ - fun loadTemplates() { - _uiState.update { it.copy(isLoadingTemplates = true) } - viewModelScope.launch { - when (val result = repository.getTemplates()) { - is ApiResult.Success -> _uiState.update { - it.copy(isLoadingTemplates = false, templates = result.data) - } - is ApiResult.Failure -> _uiState.update { - it.copy(isLoadingTemplates = false, errorMessage = result.error.toUserMessage()) - } - } - } - } - - fun createFromTemplate(templateId: String, onCreated: (String) -> Unit) { - val targetFolderId = _uiState.value.selectedFolderId - viewModelScope.launch { - when (val result = repository.createFromTemplate(templateId, targetFolderId)) { - is ApiResult.Success -> onCreated(result.data.id) - is ApiResult.Failure -> _uiState.update { - it.copy(errorMessage = result.error.toUserMessage()) - } - } - } - } - - fun createFolder(name: String) { - val trimmed = name.trim() - if (trimmed.isBlank()) return - viewModelScope.launch { - when (val result = repository.createFolder(trimmed, parentId = null)) { - is ApiResult.Success -> Unit // Observed folders flow updates the UI. - is ApiResult.Failure -> _uiState.update { - it.copy(errorMessage = result.error.toUserMessage()) - } - } - } - } - - fun clearError() = _uiState.update { it.copy(errorMessage = null) } -} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt index 2679c1b..7260540 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt @@ -3,7 +3,9 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.interlinedlist.android.feature.documents.domain.FolderNode import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -56,47 +58,169 @@ class DefaultDocumentsRepositoryTest { fun tearDown() = server.shutdown() @Test - fun `refreshDocuments caches the page and reports pagination`() = runTest(testDispatcher) { + fun `refreshTree caches the nested folders with embedded and unfiled documents`() = + runTest(testDispatcher) { + // GET /api/documents/folders (nested tree with embedded docs). + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "folders": [ + { "id": "f1", "name": "Work", "parentId": null, + "documents": [ { "id": "d1", "title": "Report", "content": "body" } ] }, + { "id": "f2", "name": "Reports", "parentId": "f1", "documents": [] } + ] + } + """.trimIndent(), + ), + ) + // GET /api/documents (unfiled root docs). + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "documents": [ { "id": "r1", "title": "Loose note" } ] }""", + ), + ) + + val result = repository.refreshTree() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(folderDao.snapshot().map { it.name }).containsExactly("Work", "Reports") + + // Root contents: top-level folder "Work" and the unfiled doc "r1". + val rootContents = repository.observeFolderContents(null).first() + assertThat(rootContents.subfolders.map { it.id }).containsExactly("f1") + assertThat(rootContents.documents.map { it.id }).containsExactly("r1") + + // Folder "f1" contents: subfolder "f2" and the embedded doc "d1". + val f1Contents = repository.observeFolderContents("f1").first() + assertThat(f1Contents.subfolders.map { it.id }).containsExactly("f2") + assertThat(f1Contents.documents.map { it.id }).containsExactly("d1") + } + + @Test + fun `refreshTree maps a 403 subscription error to SubscriptionRequired`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription." }"""), + ) + + val result = repository.refreshTree() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `createFolder posts name and parentId and caches the folder`() = runTest(testDispatcher) { server.enqueue( - MockResponse().setResponseCode(200).setBody( - """ - { - "data": [ - { "id": "1", "title": "First", "content": "hello world", "isPublic": false }, - { "id": "2", "title": "Second", "content": "more text" } - ], - "pagination": { "total": 40, "limit": 20, "offset": 0, "hasMore": true } - } - """.trimIndent(), - ), + MockResponse().setResponseCode(201) + .setBody("""{ "folder": { "id": "nf", "name": "Archive", "parentId": "f1" } }"""), ) - val result = repository.refreshDocuments(folderId = null) + val result = repository.createFolder("Archive", parentId = "f1") assertThat(result).isInstanceOf(ApiResult.Success::class.java) - val pagination = (result as ApiResult.Success).data - assertThat(pagination.hasMore).isTrue() - assertThat(pagination.total).isEqualTo(40) - - val cached = repository.observeDocuments(null).first() - assertThat(cached.map { it.id }).containsExactly("1", "2").inOrder() - assertThat(cached.first().title).isEqualTo("First") + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents/folders") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"name\":\"Archive\"") + assertThat(body).contains("\"parentId\":\"f1\"") + assertThat(folderDao.getFolder("nf")).isNotNull() } @Test - fun `refreshDocuments maps a 403 subscription error to SubscriptionRequired`() = runTest(testDispatcher) { + fun `createFolder treats the synthetic root id as no parent`() = runTest(testDispatcher) { server.enqueue( - MockResponse().setResponseCode(403) - .setBody("""{ "error": "This feature requires an active subscription." }"""), + MockResponse().setResponseCode(201).setBody("""{ "folder": { "id": "nf", "name": "Top" } }"""), ) - val result = repository.refreshDocuments(folderId = null) + repository.createFolder("Top", parentId = FolderNode.ROOT_ID) + + val body = server.takeRequest().body.readUtf8() + assertThat(body).doesNotContain(FolderNode.ROOT_ID) + } + + @Test + fun `renameFolder issues a PUT with the new name and updates the cache`() = runTest(testDispatcher) { + // Seed a cached folder via create. + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "folder": { "id": "f1", "name": "Work" } }""")) + repository.createFolder("Work", parentId = null) + server.takeRequest() + + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "folder": { "id": "f1", "name": "Job" } }""")) - assertThat(result).isInstanceOf(ApiResult.Failure::class.java) - val error = (result as ApiResult.Failure).error - assertThat(error).isInstanceOf(com.interlinedlist.android.core.common.result.AppError.SubscriptionRequired::class.java) + val result = repository.renameFolder("f1", "Job") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PUT") + assertThat(recorded.path).isEqualTo("/api/documents/folders/f1") + assertThat(recorded.body.readUtf8()).contains("\"name\":\"Job\"") + assertThat(folderDao.getFolder("f1")?.name).isEqualTo("Job") } + @Test + fun `deleteFolder issues a DELETE and prunes the folder subtree from the cache`() = + runTest(testDispatcher) { + // Build a small tree: f1 -> f2, each with a document, plus an unrelated folder f3. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "folders": [ + { "id": "f1", "name": "Work", "parentId": null, + "documents": [ { "id": "d1", "title": "A" } ] }, + { "id": "f2", "name": "Reports", "parentId": "f1", + "documents": [ { "id": "d2", "title": "B" } ] }, + { "id": "f3", "name": "Other", "parentId": null, "documents": [] } + ] + } + """.trimIndent(), + ), + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "documents": [] }""")) + repository.refreshTree() + server.takeRequest(); server.takeRequest() + + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + val result = repository.deleteFolder("f1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/documents/folders/f1") + + // f1 and its descendant f2 are gone; f3 remains. + assertThat(folderDao.snapshot().map { it.id }).containsExactly("f3") + // Documents in the deleted subtree are removed. + assertThat(documentDao.getDocument("d1")).isNull() + assertThat(documentDao.getDocument("d2")).isNull() + } + + @Test + fun `moveDocument issues a PUT with the target folderId and patches the cache`() = + runTest(testDispatcher) { + // Seed a cached document via create (lands at root). + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "id": "d1", "title": "T", "content": "c" }""")) + repository.createDocument("T", "c", isPublic = false, folderId = null) + server.takeRequest() + assertThat(documentDao.getDocument("d1")?.folderId).isNull() + + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "id": "d1", "title": "T", "folderId": "f9" }""")) + + val result = repository.moveDocument("d1", folderId = "f9") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PUT") + assertThat(recorded.path).isEqualTo("/api/documents/d1") + assertThat(recorded.body.readUtf8()).contains("\"folderId\":\"f9\"") + assertThat(documentDao.getDocument("d1")?.folderId).isEqualTo("f9") + } + @Test fun `createDocument posts the body and caches the created document`() = runTest(testDispatcher) { server.enqueue( @@ -105,7 +229,7 @@ class DefaultDocumentsRepositoryTest { ), ) - val result = repository.createDocument("Fresh", "body", isPublic = false) + val result = repository.createDocument("Fresh", "body", isPublic = false, folderId = null) assertThat(result).isInstanceOf(ApiResult.Success::class.java) assertThat((result as ApiResult.Success).data.id).isEqualTo("new1") @@ -114,7 +238,6 @@ class DefaultDocumentsRepositoryTest { assertThat(recorded.method).isEqualTo("POST") assertThat(recorded.path).isEqualTo("/api/documents") assertThat(recorded.body.readUtf8()).contains("\"title\":\"Fresh\"") - assertThat(documentDao.getDocument("new1")).isNotNull() } @@ -135,32 +258,10 @@ class DefaultDocumentsRepositoryTest { assertThat(documentDao.getDocument("d9")?.content).contains("# Heading") } - @Test - fun `updateDocument issues a PUT and updates the cache`() = runTest(testDispatcher) { - // Seed a cached copy first. - server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "id": "d1", "title": "Old", "content": "old" }""")) - repository.refreshDocument("d1") - server.takeRequest() - - server.enqueue( - MockResponse().setResponseCode(200).setBody("""{ "id": "d1", "title": "New", "content": "new body" }"""), - ) - - val result = repository.updateDocument("d1", "New", "new body", isPublic = true, folderId = null) - - assertThat(result).isInstanceOf(ApiResult.Success::class.java) - val recorded = server.takeRequest() - assertThat(recorded.method).isEqualTo("PUT") - assertThat(recorded.path).isEqualTo("/api/documents/d1") - assertThat(documentDao.getDocument("d1")?.title).isEqualTo("New") - assertThat(documentDao.getDocument("d1")?.content).isEqualTo("new body") - } - @Test fun `deleteDocument issues a DELETE and removes the cached row`() = runTest(testDispatcher) { - // Seed the cache directly through a create. server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "id": "gone", "title": "T", "content": "c" }""")) - repository.createDocument("T", "c", isPublic = false) + repository.createDocument("T", "c", isPublic = false, folderId = null) server.takeRequest() assertThat(documentDao.getDocument("gone")).isNotNull() @@ -174,33 +275,36 @@ class DefaultDocumentsRepositoryTest { } @Test - fun `refreshFolders caches folders from the data envelope`() = runTest(testDispatcher) { + fun `searchDocuments passes the query and maps results`() = runTest(testDispatcher) { server.enqueue( MockResponse().setResponseCode(200).setBody( - """{ "data": [ { "id": "f1", "name": "Work" }, { "id": "f2", "name": "Personal" } ] }""", + """{ "data": [ { "id": "s1", "title": "Match", "content": "found" } ] }""", ), ) - val result = repository.refreshFolders() + val result = repository.searchDocuments("found") assertThat(result).isInstanceOf(ApiResult.Success::class.java) - assertThat(folderDao.snapshot().map { it.name }).containsExactly("Work", "Personal").inOrder() + assertThat((result as ApiResult.Success).data.single().title).isEqualTo("Match") + assertThat(server.takeRequest().path).isEqualTo("/api/documents/search?q=found") } @Test - fun `searchDocuments passes the query and maps results`() = runTest(testDispatcher) { - server.enqueue( - MockResponse().setResponseCode(200).setBody( - """{ "data": [ { "id": "s1", "title": "Match", "content": "found" } ] }""", - ), + fun `uploadImage posts multipart to the images endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "url": "https://cdn/x.png" }""")) + + val result = repository.uploadImage( + documentId = "d1", + fileName = "shot.png", + mimeType = "image/png", + bytes = byteArrayOf(1, 2, 3), ) - val result = repository.searchDocuments("found") - assertThat(result).isInstanceOf(ApiResult.Success::class.java) - assertThat((result as ApiResult.Success).data.single().title).isEqualTo("Match") val recorded = server.takeRequest() - assertThat(recorded.path).isEqualTo("/api/documents/search?q=found") + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents/d1/images/upload") + assertThat(recorded.getHeader("Content-Type")).contains("multipart/form-data") } @Test diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt index ad10a0e..75b09d7 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DocumentMappersTest.kt @@ -2,11 +2,9 @@ package com.interlinedlist.android.feature.documents.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.feature.documents.data.mapper.toDomain -import com.interlinedlist.android.feature.documents.data.mapper.toPaginationDomain import com.interlinedlist.android.feature.documents.data.mapper.toTemplate import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentDto import com.interlinedlist.android.feature.documents.data.remote.dto.FolderDto -import com.interlinedlist.android.feature.documents.data.remote.dto.PaginationDto import com.interlinedlist.android.feature.documents.domain.Document import org.junit.Test @@ -74,17 +72,18 @@ class DocumentMappersTest { } @Test - fun `null pagination falls back to a single page over the item count`() { - val page = (null as PaginationDto?).toPaginationDomain(fallbackCount = 3) - assertThat(page.hasMore).isFalse() - assertThat(page.total).isEqualTo(3) - assertThat(page.offset).isEqualTo(0) - } - - @Test - fun `pagination maps through and computes next offset`() { - val page = PaginationDto(total = 40, limit = 20, offset = 0, hasMore = true).toPaginationDomain(0) - assertThat(page.hasMore).isTrue() - assertThat(page.nextOffset).isEqualTo(20) + fun `folder maps embedded documents count and timestamps`() { + val dto = FolderDto( + id = "f1", + name = "Work", + parentId = null, + documents = listOf(DocumentDto(id = "d1", title = "A")), + createdAt = "2026-01-01", + updatedAt = "2026-02-02", + ) + assertThat(dto.documentsOrEmpty.map { it.id }).containsExactly("d1") + val folder = dto.toDomain() + assertThat(folder.createdAt).isEqualTo("2026-01-01") + assertThat(folder.updatedAt).isEqualTo("2026-02-02") } } diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt index 707576a..24c4cd1 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt @@ -17,6 +17,9 @@ class FakeDocumentDao : DocumentDao { fun snapshot(): List = rows.value.sortedBy { it.sortOrder } + override fun observeAllDocuments(): Flow> = + rows.map { list -> list.sortedBy { it.sortOrder } } + override fun observeRootDocuments(): Flow> = rows.map { list -> list.filter { it.folderId == null }.sortedBy { it.sortOrder } } @@ -50,6 +53,10 @@ class FakeDocumentDao : DocumentDao { override suspend fun clearFolder(folderId: String) { rows.value = rows.value.filterNot { it.folderId == folderId } } + + override suspend fun clearAll() { + rows.value = emptyList() + } } class FakeFolderDao : FolderDao { @@ -60,6 +67,11 @@ class FakeFolderDao : FolderDao { override fun observeFolders(): Flow> = rows.map { list -> list.sortedBy { it.sortOrder } } + override suspend fun getFolder(id: String): FolderEntity? = + rows.value.firstOrNull { it.id == id } + + override suspend fun maxSortOrder(): Int = rows.value.maxOfOrNull { it.sortOrder } ?: -1 + override suspend fun upsertAll(folders: List) { folders.forEach { upsert(it) } } @@ -68,6 +80,10 @@ class FakeFolderDao : FolderDao { rows.value = rows.value.filterNot { it.id == folder.id } + folder } + override suspend fun deleteById(id: String) { + rows.value = rows.value.filterNot { it.id == id } + } + override suspend fun clear() { rows.value = emptyList() } diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTreeTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTreeTest.kt new file mode 100644 index 0000000..7d32fef --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/domain/FolderTreeTest.kt @@ -0,0 +1,128 @@ +package com.interlinedlist.android.feature.documents.domain + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class FolderTreeTest { + + private fun folder(id: String, name: String, parentId: String? = null) = + DocumentFolder(id = id, name = name, parentId = parentId) + + private fun doc(id: String, folderId: String? = null) = Document( + id = id, + title = "Doc $id", + content = null, + snippet = "", + folderId = folderId, + folderName = null, + isPublic = false, + updatedAt = null, + ) + + @Test + fun `build nests folders by parentId under a synthetic root`() { + val folders = listOf( + folder("f1", "Work"), + folder("f2", "Reports", parentId = "f1"), + folder("f3", "Personal"), + ) + + val root = FolderTree.build(folders, documentsByFolder = emptyMap(), rootDocuments = emptyList()) + + assertThat(root.id).isEqualTo(FolderNode.ROOT_ID) + assertThat(root.isRoot).isTrue() + assertThat(root.children.map { it.id }).containsExactly("f1", "f3") + val work = root.children.first { it.id == "f1" } + assertThat(work.children.map { it.id }).containsExactly("f2") + } + + @Test + fun `build attaches embedded documents to their folder and unfiled docs to root`() { + val folders = listOf(folder("f1", "Work")) + val root = FolderTree.build( + folders = folders, + documentsByFolder = mapOf("f1" to listOf(doc("d1", "f1"))), + rootDocuments = listOf(doc("rootDoc")), + ) + + assertThat(root.documents.map { it.id }).containsExactly("rootDoc") + assertThat(root.children.single().documents.map { it.id }).containsExactly("d1") + } + + @Test + fun `orphan folders whose parent is unknown are re-parented onto the root`() { + val folders = listOf(folder("f2", "Reports", parentId = "missing")) + + val root = FolderTree.build(folders, emptyMap(), emptyList()) + + assertThat(root.children.map { it.id }).containsExactly("f2") + assertThat(root.children.single().parentId).isNull() + } + + @Test + fun `folders are ordered alphabetically case-insensitively`() { + val folders = listOf(folder("b", "banana"), folder("a", "Apple"), folder("c", "cherry")) + + val root = FolderTree.build(folders, emptyMap(), emptyList()) + + assertThat(root.children.map { it.name }).containsExactly("Apple", "banana", "cherry").inOrder() + } + + @Test + fun `contentsOf root returns top-level subfolders and unfiled documents`() { + val root = FolderTree.build( + folders = listOf(folder("f1", "Work")), + documentsByFolder = emptyMap(), + rootDocuments = listOf(doc("rootDoc")), + ) + + val contents = FolderTree.contentsOf(root, folderId = null) + + assertThat(contents.isRoot).isTrue() + assertThat(contents.subfolders.map { it.id }).containsExactly("f1") + assertThat(contents.documents.map { it.id }).containsExactly("rootDoc") + assertThat(contents.breadcrumb.map { it.name }).containsExactly(FolderNode.ROOT_NAME) + } + + @Test + fun `contentsOf a nested folder builds the full breadcrumb path`() { + val folders = listOf( + folder("f1", "Work"), + folder("f2", "Reports", parentId = "f1"), + ) + val root = FolderTree.build(folders, mapOf("f2" to listOf(doc("d1", "f2"))), emptyList()) + + val contents = FolderTree.contentsOf(root, folderId = "f2") + + assertThat(contents.folderId).isEqualTo("f2") + assertThat(contents.folderName).isEqualTo("Reports") + assertThat(contents.parentId).isEqualTo("f1") + assertThat(contents.documents.map { it.id }).containsExactly("d1") + assertThat(contents.breadcrumb.map { it.name }) + .containsExactly(FolderNode.ROOT_NAME, "Work", "Reports").inOrder() + } + + @Test + fun `subfolder summaries carry document and subfolder counts`() { + val folders = listOf( + folder("f1", "Work"), + folder("f2", "Reports", parentId = "f1"), + ) + val root = FolderTree.build(folders, mapOf("f1" to listOf(doc("d1", "f1"))), emptyList()) + + val summary = FolderTree.contentsOf(root, null).subfolders.single() + + assertThat(summary.id).isEqualTo("f1") + assertThat(summary.documentCount).isEqualTo(1) + assertThat(summary.subfolderCount).isEqualTo(1) + } + + @Test + fun `contentsOf an unknown folder falls back to the root`() { + val root = FolderTree.build(listOf(folder("f1", "Work")), emptyMap(), emptyList()) + + val contents = FolderTree.contentsOf(root, folderId = "does-not-exist") + + assertThat(contents.folderId).isEqualTo(FolderNode.ROOT_ID) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt index 03589f8..cfd8dc0 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt @@ -119,6 +119,36 @@ class DocumentEditorViewModelTest { assertThat(vm.uiState.value.isPreview).isTrue() } + @Test + fun `uploadImage appends a markdown reference and marks unsaved changes`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "start")) + repo.uploadResult = ApiResult.Success(Unit) + val vm = viewModel() + advanceUntilIdle() + + vm.uploadImage(fileName = "photo.png", mimeType = "image/png", bytes = byteArrayOf(1, 2)) + advanceUntilIdle() + + assertThat(vm.uiState.value.content).contains("photo.png") + assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() + assertThat(vm.uiState.value.isUploadingImage).isFalse() + } + + @Test + fun `uploadImage surfaces an error on failure`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "start")) + repo.uploadResult = ApiResult.Failure(AppError.Server("boom")) + val vm = viewModel() + advanceUntilIdle() + + vm.uploadImage(fileName = "photo.png", mimeType = "image/png", bytes = byteArrayOf(1)) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("InterlinedList is having trouble right now. Try again shortly.") + assertThat(vm.uiState.value.isUploadingImage).isFalse() + } + @Test fun `refresh does not overwrite in-progress edits`() = runTest(dispatcher) { repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "server")) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt new file mode 100644 index 0000000..dc8c6e6 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt @@ -0,0 +1,217 @@ +package com.interlinedlist.android.feature.documents.ui + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.ui.browser.DocumentsBrowserViewModel +import com.interlinedlist.android.feature.documents.ui.browser.FOLDER_ID_ARG +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentsBrowserViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeDocumentsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeDocumentsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun rootViewModel() = + DocumentsBrowserViewModel(repo, SavedStateHandle()) + + private fun folderViewModel(folderId: String) = + DocumentsBrowserViewModel(repo, SavedStateHandle(mapOf(FOLDER_ID_ARG to folderId))) + + @Test + fun `root level shows top-level folders and unfiled documents`() = runTest(dispatcher) { + repo.folders.value = listOf(DocumentFolder("f1", "Work", null)) + repo.documents.value = listOf( + testDocument("root1"), + testDocument("inWork", folderId = "f1"), + ) + + val vm = rootViewModel() + advanceUntilIdle() + + val contents = vm.uiState.value.contents + assertThat(contents.folderId).isEqualTo(FolderNode.ROOT_ID) + assertThat(contents.subfolders.map { it.id }).containsExactly("f1") + assertThat(contents.documents.map { it.id }).containsExactly("root1") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `opening a folder level shows its documents and breadcrumb`() = runTest(dispatcher) { + repo.folders.value = listOf( + DocumentFolder("f1", "Work", null), + DocumentFolder("f2", "Reports", "f1"), + ) + repo.documents.value = listOf(testDocument("d1", folderId = "f2")) + + val vm = folderViewModel("f2") + advanceUntilIdle() + + val contents = vm.uiState.value.contents + assertThat(contents.folderId).isEqualTo("f2") + assertThat(contents.documents.map { it.id }).containsExactly("d1") + assertThat(contents.breadcrumb.map { it.name }) + .containsExactly(FolderNode.ROOT_NAME, "Work", "Reports").inOrder() + } + + @Test + fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { + repo.refreshTreeResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = rootViewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + assertThat(vm.uiState.value.subscriptionRequired).isFalse() + } + + @Test + fun `subscription gate is flagged on a subscription-required failure`() = runTest(dispatcher) { + repo.refreshTreeResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribe to use documents")) + + val vm = rootViewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.errorMessage).isEqualTo("Subscribe to use documents") + } + + @Test + fun `create document uses the current folder and invokes onCreated with the new id`() = + runTest(dispatcher) { + repo.createResult = ApiResult.Success(testDocument("new-id", title = "Untitled", folderId = "f1")) + + val vm = folderViewModel("f1") + advanceUntilIdle() + + var createdId: String? = null + vm.createDocument(title = "Untitled") { createdId = it } + advanceUntilIdle() + + assertThat(createdId).isEqualTo("new-id") + assertThat(repo.lastCreate?.folderId).isEqualTo("f1") + } + + @Test + fun `move document delegates to the repository with the target folder`() = runTest(dispatcher) { + val vm = rootViewModel() + advanceUntilIdle() + + vm.moveDocument("d1", targetFolderId = "f2") + advanceUntilIdle() + + assertThat(repo.lastMove?.id).isEqualTo("d1") + assertThat(repo.lastMove?.folderId).isEqualTo("f2") + } + + @Test + fun `create folder parents under the current level`() = runTest(dispatcher) { + val vm = folderViewModel("f1") + advanceUntilIdle() + + vm.createFolder("Reports") + advanceUntilIdle() + + assertThat(repo.lastFolderCreate?.name).isEqualTo("Reports") + assertThat(repo.lastFolderCreate?.parentId).isEqualTo("f1") + } + + @Test + fun `blank folder name is ignored`() = runTest(dispatcher) { + val vm = rootViewModel() + advanceUntilIdle() + + vm.createFolder(" ") + advanceUntilIdle() + + assertThat(repo.lastFolderCreate).isNull() + } + + @Test + fun `rename folder delegates the trimmed name`() = runTest(dispatcher) { + val vm = rootViewModel() + advanceUntilIdle() + + vm.renameFolder("f1", " Archive ") + advanceUntilIdle() + + assertThat(repo.lastFolderRename?.id).isEqualTo("f1") + assertThat(repo.lastFolderRename?.name).isEqualTo("Archive") + } + + @Test + fun `delete folder delegates to the repository`() = runTest(dispatcher) { + val vm = rootViewModel() + advanceUntilIdle() + + vm.deleteFolder("f1") + advanceUntilIdle() + + assertThat(repo.lastDeletedFolderId).isEqualTo("f1") + } + + @Test + fun `search runs against the repository and stores results`() = runTest(dispatcher) { + repo.searchResult = ApiResult.Success(listOf(testDocument("s1", title = "Match"))) + + val vm = rootViewModel() + advanceUntilIdle() + + vm.openSearch() + vm.onSearchQueryChange("match") + advanceUntilIdle() + + assertThat(vm.uiState.value.isSearchActive).isTrue() + assertThat(repo.lastSearchQuery).isEqualTo("match") + assertThat(vm.uiState.value.searchResults.single().title).isEqualTo("Match") + assertThat(vm.uiState.value.isSearching).isFalse() + } + + @Test + fun `blank search query clears results without hitting the repository`() = runTest(dispatcher) { + val vm = rootViewModel() + advanceUntilIdle() + + vm.openSearch() + vm.onSearchQueryChange("") + advanceUntilIdle() + + assertThat(vm.uiState.value.searchResults).isEmpty() + assertThat(repo.lastSearchQuery).isNull() + } + + @Test + fun `deleted folder route falls back to root contents`() = runTest(dispatcher) { + // Folder "ghost" is not in the tree; contents should degrade to root. + repo.folders.value = listOf(DocumentFolder("f1", "Work", null)) + repo.documents.value = listOf(testDocument("root1")) + + val vm = folderViewModel("ghost") + advanceUntilIdle() + + assertThat(vm.uiState.value.contents.folderId).isEqualTo(FolderNode.ROOT_ID) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt deleted file mode 100644 index caf8f1b..0000000 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsViewModelTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.interlinedlist.android.feature.documents.ui - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.core.common.result.AppError -import com.interlinedlist.android.feature.documents.domain.DocumentFolder -import com.interlinedlist.android.feature.documents.domain.Pagination -import com.interlinedlist.android.feature.documents.ui.index.DocumentsViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class DocumentsViewModelTest { - - private val dispatcher = StandardTestDispatcher() - private lateinit var repo: FakeDocumentsRepository - - @Before - fun setUp() { - Dispatchers.setMain(dispatcher) - repo = FakeDocumentsRepository() - } - - @After - fun tearDown() = Dispatchers.resetMain() - - @Test - fun `emits cached documents from the room flow`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Success(Pagination.single(1)) - repo.rootDocuments.value = listOf(testDocument("1"), testDocument("2")) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - - assertThat(vm.uiState.value.documents.map { it.id }).containsExactly("1", "2") - assertThat(vm.uiState.value.isLoading).isFalse() - } - - @Test - fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Failure(AppError.Network("offline")) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - - assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") - assertThat(vm.uiState.value.subscriptionRequired).isFalse() - } - - @Test - fun `subscription gate is flagged on a subscription-required failure`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribe to use documents")) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - - assertThat(vm.uiState.value.subscriptionRequired).isTrue() - assertThat(vm.uiState.value.errorMessage).isEqualTo("Subscribe to use documents") - } - - @Test - fun `selecting a folder switches the observed source and refreshes it`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Success(Pagination.single(0)) - repo.rootDocuments.value = listOf(testDocument("root")) - repo.folderDocuments.value = listOf(testDocument("infolder", folderId = "f1")) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - assertThat(vm.uiState.value.documents.map { it.id }).containsExactly("root") - - vm.selectFolder("f1") - advanceUntilIdle() - - assertThat(vm.uiState.value.selectedFolderId).isEqualTo("f1") - assertThat(vm.uiState.value.documents.map { it.id }).containsExactly("infolder") - assertThat(repo.lastSelectedFolderId).isEqualTo("f1") - } - - @Test - fun `hasMore drives load-more which appends the next page`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Success(Pagination(total = 40, limit = 20, offset = 0, hasMore = true)) - repo.loadMoreResult = ApiResult.Success(Pagination(total = 40, limit = 20, offset = 20, hasMore = false)) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - assertThat(vm.uiState.value.hasMore).isTrue() - - vm.loadMore() - advanceUntilIdle() - - assertThat(repo.loadMoreCount).isEqualTo(1) - assertThat(vm.uiState.value.hasMore).isFalse() - assertThat(vm.uiState.value.isLoadingMore).isFalse() - } - - @Test - fun `load-more is skipped when there is no next page`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Success(Pagination.single(2)) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - - vm.loadMore() - advanceUntilIdle() - - assertThat(repo.loadMoreCount).isEqualTo(0) - } - - @Test - fun `create document invokes onCreated with the new id`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Success(Pagination.single(0)) - repo.createResult = ApiResult.Success(testDocument("new-id", title = "Untitled")) - - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - - var createdId: String? = null - vm.createDocument(title = "Untitled") { createdId = it } - advanceUntilIdle() - - assertThat(createdId).isEqualTo("new-id") - assertThat(repo.lastCreateTitle).isEqualTo("Untitled") - } - - @Test - fun `folders flow is reflected in state via Turbine`() = runTest(dispatcher) { - repo.refreshResult = ApiResult.Success(Pagination.single(0)) - val vm = DocumentsViewModel(repo) - advanceUntilIdle() - - vm.uiState.test { - assertThat(awaitItem().folders).isEmpty() - repo.foldersFlow.value = listOf(DocumentFolder("f1", "Work", null)) - assertThat(awaitItem().folders.single().name).isEqualTo("Work") - } - } -} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt index 61939a3..1910b80 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt @@ -6,64 +6,84 @@ import com.interlinedlist.android.feature.documents.data.DocumentsRepository import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate -import com.interlinedlist.android.feature.documents.domain.Pagination +import com.interlinedlist.android.feature.documents.domain.FolderContents +import com.interlinedlist.android.feature.documents.domain.FolderNode +import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.domain.FolderTree import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map /** - * In-memory [DocumentsRepository] for ViewModel tests. Backed by simple - * StateFlows so tests can observe the same reactive behaviour as Room without a - * device. Failure modes are injectable per operation. + * In-memory [DocumentsRepository] for ViewModel tests. Backed by StateFlows of a + * flat folder list + documents so tests observe the same reactive tree-building + * behaviour as Room without a device. Failure modes are injectable per operation. */ class FakeDocumentsRepository : DocumentsRepository { - val rootDocuments = MutableStateFlow>(emptyList()) - val folderDocuments = MutableStateFlow>(emptyList()) - val foldersFlow = MutableStateFlow>(emptyList()) + val folders = MutableStateFlow>(emptyList()) + val documents = MutableStateFlow>(emptyList()) val documentFlow = MutableStateFlow(null) - var refreshResult: ApiResult = ApiResult.Success(Pagination.single(0)) - var loadMoreResult: ApiResult = ApiResult.Success(Pagination.single(0)) + var refreshTreeResult: ApiResult = ApiResult.Success(Unit) var refreshDocumentResult: ApiResult? = null var createResult: ApiResult? = null var updateResult: ApiResult? = null + var moveDocumentResult: ApiResult = ApiResult.Success(Unit) var deleteResult: ApiResult = ApiResult.Success(Unit) + var uploadResult: ApiResult = ApiResult.Success(Unit) + var createFolderResult: ApiResult? = null + var renameFolderResult: ApiResult? = null + var moveFolderResult: ApiResult? = null + var deleteFolderResult: ApiResult = ApiResult.Success(Unit) var templatesResult: ApiResult> = ApiResult.Success(emptyList()) var fromTemplateResult: ApiResult? = null - var createFolderResult: ApiResult? = null var searchResult: ApiResult> = ApiResult.Success(emptyList()) - var refreshCount = 0 - var loadMoreCount = 0 - var lastSelectedFolderId: String? = null - var lastCreateTitle: String? = null + var refreshTreeCount = 0 + var lastCreate: Create? = null var lastUpdate: Update? = null - + var lastMove: Move? = null + var lastDeletedDocId: String? = null + var lastFolderCreate: FolderCreate? = null + var lastFolderRename: FolderRename? = null + var lastDeletedFolderId: String? = null + var lastSearchQuery: String? = null + + data class Create(val title: String, val content: String, val isPublic: Boolean, val folderId: String?) data class Update(val id: String, val title: String, val content: String, val isPublic: Boolean, val folderId: String?) + data class Move(val id: String, val folderId: String?) + data class FolderCreate(val name: String, val parentId: String?) + data class FolderRename(val id: String, val name: String) + + private fun tree(): FolderNode { + val byFolder = documents.value.filter { it.folderId != null }.groupBy { it.folderId!! } + val root = documents.value.filter { it.folderId == null } + return FolderTree.build(folders.value, byFolder, root) + } - override fun observeDocuments(folderId: String?) = - if (folderId == null) rootDocuments.map { it } else folderDocuments.map { it } - - override fun observeDocument(id: String) = documentFlow.map { it } + override fun observeFolderContents(folderId: String?) = + combine2(folders, documents) { _, _ -> FolderTree.contentsOf(tree(), folderId) } - override fun observeFolders() = foldersFlow.map { it } + override fun observeFolderSummaries() = + combine2(folders, documents) { _, _ -> flatten(tree()) } - override suspend fun refreshDocuments(folderId: String?): ApiResult { - refreshCount++ - lastSelectedFolderId = folderId - return refreshResult - } + override fun observeDocument(id: String) = documentFlow.map { it } - override suspend fun loadMore(folderId: String?, pagination: Pagination): ApiResult { - loadMoreCount++ - return loadMoreResult + override suspend fun refreshTree(): ApiResult { + refreshTreeCount++ + return refreshTreeResult } override suspend fun refreshDocument(id: String): ApiResult = refreshDocumentResult ?: ApiResult.Failure(AppError.NotFound("not set")) - override suspend fun createDocument(title: String, content: String, isPublic: Boolean): ApiResult { - lastCreateTitle = title + override suspend fun createDocument( + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + ): ApiResult { + lastCreate = Create(title, content, isPublic, folderId) return createResult ?: ApiResult.Failure(AppError.Unknown("not set")) } @@ -78,22 +98,66 @@ class FakeDocumentsRepository : DocumentsRepository { return updateResult ?: ApiResult.Failure(AppError.Unknown("not set")) } - override suspend fun deleteDocument(id: String): ApiResult = deleteResult + override suspend fun moveDocument(id: String, folderId: String?): ApiResult { + lastMove = Move(id, folderId) + return moveDocumentResult + } + + override suspend fun deleteDocument(id: String): ApiResult { + lastDeletedDocId = id + return deleteResult + } + + override suspend fun uploadImage( + documentId: String, + fileName: String, + mimeType: String, + bytes: ByteArray, + ): ApiResult = uploadResult - override suspend fun refreshFolders(): ApiResult> = - ApiResult.Success(foldersFlow.value) + override suspend fun createFolder(name: String, parentId: String?): ApiResult { + lastFolderCreate = FolderCreate(name, parentId) + return createFolderResult ?: ApiResult.Success(DocumentFolder("new-folder", name, parentId)) + } + + override suspend fun renameFolder(id: String, name: String): ApiResult { + lastFolderRename = FolderRename(id, name) + return renameFolderResult ?: ApiResult.Success(DocumentFolder(id, name, null)) + } - override suspend fun createFolder(name: String, parentId: String?): ApiResult = - createFolderResult ?: ApiResult.Failure(AppError.Unknown("not set")) + override suspend fun moveFolder(id: String, newParentId: String?): ApiResult = + moveFolderResult ?: ApiResult.Success(DocumentFolder(id, "moved", newParentId)) + + override suspend fun deleteFolder(id: String): ApiResult { + lastDeletedFolderId = id + return deleteFolderResult + } override suspend fun getTemplates(): ApiResult> = templatesResult override suspend fun createFromTemplate(templateId: String, targetFolderId: String?): ApiResult = fromTemplateResult ?: ApiResult.Failure(AppError.Unknown("not set")) - override suspend fun searchDocuments(query: String): ApiResult> = searchResult + override suspend fun searchDocuments(query: String): ApiResult> { + lastSearchQuery = query + return searchResult + } + + private fun flatten(node: FolderNode): List = buildList { + node.children.forEach { child -> + add(FolderSummary(child.id, child.name, child.documents.size, child.children.size)) + addAll(flatten(child)) + } + } } +/** Small combine helper to keep the fake independent of kotlinx.coroutines.flow.combine imports. */ +private fun combine2( + a: MutableStateFlow, + b: MutableStateFlow, + transform: (A, B) -> R, +) = kotlinx.coroutines.flow.combine(a, b) { av, bv -> transform(av, bv) } + /** Shorthand for building a domain document in tests. */ fun testDocument( id: String, diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreenTest.kt new file mode 100644 index 0000000..782ea14 --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreenTest.kt @@ -0,0 +1,55 @@ +package com.interlinedlist.android.feature.lists.ui.connections + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListConnection +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Verifies the connections screen renders connection rows and the empty state. */ +@RunWith(AndroidJUnit4::class) +class ConnectionsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen(state: ConnectionsUiState) { + composeRule.setContent { + InterlinedListTheme { + ConnectionsScreen( + state = state, + onBack = {}, + onCreateConnection = { _, _, _ -> }, + onDeleteConnection = {}, + ) + } + } + } + + @Test + fun rendersConnectionRows() { + setScreen( + ConnectionsUiState( + connections = listOf( + ListConnection("c1", "l1", "l2", "blocks", "Backlog", "Roadmap"), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ConnectionsTestTags.connection("c1")).assertIsDisplayed() + composeRule.onNodeWithText("Backlog → Roadmap").assertIsDisplayed() + } + + @Test + fun showsEmptyState_whenNoConnections() { + setScreen(ConnectionsUiState(connections = emptyList(), isLoading = false)) + + composeRule.onNodeWithTag(ConnectionsTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt new file mode 100644 index 0000000..1e6e218 --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreenTest.kt @@ -0,0 +1,67 @@ +package com.interlinedlist.android.feature.lists.ui.schema + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.FieldType +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies the schema editor renders the existing columns as editable rows and + * exposes the add-column and save affordances. + */ +@RunWith(AndroidJUnit4::class) +class SchemaEditorScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen(state: SchemaEditorUiState) { + composeRule.setContent { + InterlinedListTheme { + SchemaEditorScreen( + state = state, + onBack = {}, + onAddColumn = {}, + onRemoveColumn = {}, + onKeyChange = { _, _ -> }, + onLabelChange = { _, _ -> }, + onTypeChange = { _, _ -> }, + onSave = {}, + ) + } + } + } + + @Test + fun rendersExistingColumnsAndActions() { + setScreen( + SchemaEditorUiState( + columns = listOf( + EditableColumn(0, "title", "Title", FieldType.TEXT), + EditableColumn(1, "pages", "Pages", FieldType.NUMBER), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(SchemaEditorTestTags.column(0)).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.column(1)).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.ADD_COLUMN).assertIsDisplayed() + composeRule.onNodeWithTag(SchemaEditorTestTags.SAVE).assertIsDisplayed() + // The field key is rendered into its editable text field. + composeRule.onNodeWithText("title").assertIsDisplayed() + } + + @Test + fun showsProgress_whenLoading() { + setScreen(SchemaEditorUiState(isLoading = true)) + + composeRule.onNodeWithTag(SchemaEditorTestTags.PROGRESS).assertIsDisplayed() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt new file mode 100644 index 0000000..b9ab4ac --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreenTest.kt @@ -0,0 +1,59 @@ +package com.interlinedlist.android.feature.lists.ui.watchers + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherRole +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Verifies the watchers screen renders watcher rows and the empty state. */ +@RunWith(AndroidJUnit4::class) +class WatchersScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen(state: WatchersUiState) { + composeRule.setContent { + InterlinedListTheme { + WatchersScreen( + state = state, + onBack = {}, + onSearchQueryChange = {}, + onAddCandidate = {}, + onChangeRole = { _, _ -> }, + onRemoveWatcher = {}, + ) + } + } + } + + @Test + fun rendersWatcherRows() { + setScreen( + WatchersUiState( + watchers = listOf( + Watcher("u1", "ada", "Ada Lovelace", null, WatcherRole.EDITOR), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(WatchersTestTags.watcher("u1")).assertIsDisplayed() + composeRule.onNodeWithText("Ada Lovelace").assertIsDisplayed() + composeRule.onNodeWithText("@ada").assertIsDisplayed() + } + + @Test + fun showsEmptyState_whenNoWatchers() { + setScreen(WatchersUiState(watchers = emptyList(), isLoading = false)) + + composeRule.onNodeWithTag(WatchersTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapper.kt new file mode 100644 index 0000000..28e61a4 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapper.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionDto +import com.interlinedlist.android.feature.lists.data.remote.dto.RefreshResultDto +import com.interlinedlist.android.feature.lists.domain.ListConnection +import com.interlinedlist.android.feature.lists.domain.RefreshResult + +/** DTO → domain mapping for list connections and the GitHub refresh result. */ +object ConnectionMapper { + + fun connectionFromDto(dto: ConnectionDto): ListConnection = ListConnection( + id = dto.id, + fromListId = dto.fromListId, + toListId = dto.toListId, + label = dto.label?.takeIf { it.isNotBlank() }, + // Prefer titles when the API supplies them, else fall back to the ids. + fromListTitle = dto.fromListTitle?.takeIf { it.isNotBlank() } ?: dto.fromListId, + toListTitle = dto.toListTitle?.takeIf { it.isNotBlank() } ?: dto.toListId, + ) + + fun refreshFromDto(dto: RefreshResultDto): RefreshResult = RefreshResult( + message = dto.message?.takeIf { it.isNotBlank() }, + added = dto.added, + updated = dto.updated, + removed = dto.removed, + ) +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index 22cd931..1791917 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -6,17 +6,26 @@ import com.interlinedlist.android.core.common.result.map import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.lists.data.local.ListDao import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.data.remote.dto.AddWatcherRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnectionRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest +import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged +import com.interlinedlist.android.feature.lists.domain.RefreshResult +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext @@ -168,6 +177,118 @@ class DefaultListsRepository @Inject constructor( .map(ListMapper::folderFromDto) } + override suspend fun updateSchema(listId: String, schema: ListSchema): ApiResult = + withContext(dispatchers.io) { + // The API expects the schema as a serialised DSL string; send the edited + // fields as the canonical array DSL and re-parse the response. + val dsl = SchemaMapper.toDsl(schema).toString() + when (val result = safeApiCall(json) { api.updateSchema(listId, UpdateSchemaRequest(dsl)) }) { + is ApiResult.Success -> { + val returned = result.data.schema ?: result.data.data + // Echo the round-tripped schema when present; otherwise trust what we sent. + val parsed = if (returned != null) SchemaMapper.fromJson(returned) else schema + ApiResult.Success(parsed) + } + is ApiResult.Failure -> result + } + } + + override suspend fun refreshGithubList(listId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.refreshList(listId) } + .map(ConnectionMapper::refreshFromDto) + } + + override suspend fun getWatchers(listId: String, limit: Int): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getWatchers(listId, limit = limit, offset = 0) } + .map { response -> response.items.mapNotNull(WatcherMapper::watcherFromDto) } + } + + override suspend fun isWatching(listId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getWatchingStatus(listId) } + .map { it.isWatchingResolved } + } + + override suspend fun searchWatcherCandidates( + listId: String, + query: String, + limit: Int, + ): ApiResult> = withContext(dispatchers.io) { + safeApiCall(json) { + api.searchWatcherUsers( + id = listId, + search = query, + excludeWatchers = true, + limit = limit, + offset = 0, + ) + }.map { response -> response.items.map(WatcherMapper::candidateFromDto) } + } + + override suspend fun addWatcher( + listId: String, + userId: String, + role: WatcherRole, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.addWatcher(listId, AddWatcherRequest(userId = userId, role = role.apiValue)) + }.map { } + } + + override suspend fun updateWatcherRole( + listId: String, + userId: String, + role: WatcherRole, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.updateWatcherRole(listId, userId, UpdateWatcherRoleRequest(role = role.apiValue)) + }.map { } + } + + override suspend fun removeWatcher(listId: String, userId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.removeWatcher(listId, userId) }.map { } + } + + override suspend fun getConnections(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getConnections() } + .map { response -> response.items.map(ConnectionMapper::connectionFromDto) } + } + + override suspend fun createConnection( + fromListId: String, + toListId: String, + label: String?, + ): ApiResult = withContext(dispatchers.io) { + val body = CreateConnectionRequest( + fromListId = fromListId, + toListId = toListId, + label = label?.takeIf { it.isNotBlank() }, + ) + when (val result = safeApiCall(json) { api.createConnection(body) }) { + is ApiResult.Success -> { + val dto = result.data.connection ?: result.data.data + ?: return@withContext ApiResult.Success( + ListConnection( + id = "", fromListId = fromListId, toListId = toListId, + label = label?.takeIf { it.isNotBlank() }, + fromListTitle = fromListId, toListTitle = toListId, + ), + ) + ApiResult.Success(ConnectionMapper.connectionFromDto(dto)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun deleteConnection(id: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.deleteConnection(id) }.map { } + } + /** Blank form fields are dropped so we don't overwrite server values with empty strings. */ private fun Map.toJsonData(): Map = filterValues { it.isNotBlank() } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index aba1f01..06e3272 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -1,11 +1,17 @@ package com.interlinedlist.android.feature.lists.data import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged +import com.interlinedlist.android.feature.lists.domain.RefreshResult +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow /** @@ -46,6 +52,47 @@ interface ListsRepository { suspend fun createFolder(name: String, parentId: String?): ApiResult + /** Replaces a list's schema (add/edit/remove columns) and returns the parsed result. */ + suspend fun updateSchema(listId: String, schema: ListSchema): ApiResult + + /** Manually re-syncs a GitHub-backed list and reports what changed. */ + suspend fun refreshGithubList(listId: String): ApiResult + + /** Watchers of a list (users granted access), with their roles. */ + suspend fun getWatchers(listId: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Whether the current user is watching [listId]. */ + suspend fun isWatching(listId: String): ApiResult + + /** Searches users who could be added as watchers (excludes existing watchers). */ + suspend fun searchWatcherCandidates( + listId: String, + query: String, + limit: Int = DEFAULT_PAGE_SIZE, + ): ApiResult> + + /** Adds a user as a watcher with the given role. */ + suspend fun addWatcher(listId: String, userId: String, role: WatcherRole): ApiResult + + /** Changes an existing watcher's role. */ + suspend fun updateWatcherRole(listId: String, userId: String, role: WatcherRole): ApiResult + + /** Removes a user's access to the list. */ + suspend fun removeWatcher(listId: String, userId: String): ApiResult + + /** All connections between the user's lists. */ + suspend fun getConnections(): ApiResult> + + /** Creates a labelled link from one list to another. */ + suspend fun createConnection( + fromListId: String, + toListId: String, + label: String?, + ): ApiResult + + /** Removes a connection between lists. */ + suspend fun deleteConnection(id: String): ApiResult + companion object { const val DEFAULT_PAGE_SIZE = 20 } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt index 26d214b..e7a0191 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapper.kt @@ -8,7 +8,10 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put /** * Interprets a list's user-defined schema DSL into a typed [ListSchema]. @@ -27,6 +30,43 @@ import kotlinx.serialization.json.contentOrNull */ object SchemaMapper { + /** The DSL wire value for each [FieldType]; the inverse of [FieldType.fromDsl]. */ + private fun FieldType.toDsl(): String = when (this) { + FieldType.TEXT -> "text" + FieldType.NUMBER -> "number" + FieldType.BOOLEAN -> "boolean" + FieldType.DATE -> "date" + FieldType.URL -> "url" + FieldType.SELECT -> "select" + } + + /** + * Serialises a [ListSchema] back to the canonical array DSL the API accepts on + * `PUT /api/lists/{id}/schema`: `[{ "key", "label", "type", "required"?, + * "options"? }, ...]`. Blank keys are dropped so an empty editor row is not + * persisted; [required]/[options] are only emitted when meaningful. + */ + fun toDsl(schema: ListSchema): JsonArray = buildJsonArray { + schema.fields + .filter { it.key.isNotBlank() } + .forEach { field -> + add( + buildJsonObject { + put("key", field.key) + put("label", field.label) + put("type", field.type.toDsl()) + if (field.required) put("required", true) + if (field.options.isNotEmpty()) { + put( + "options", + buildJsonArray { field.options.forEach { add(JsonPrimitive(it)) } }, + ) + } + }, + ) + } + } + /** Parses the (possibly null) schema element; returns [ListSchema.EMPTY] if unusable. */ fun fromJson(element: JsonElement?): ListSchema { val root = unwrap(element) ?: return ListSchema.EMPTY diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapper.kt new file mode 100644 index 0000000..8da5c0a --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapper.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.WatcherDto +import com.interlinedlist.android.feature.lists.data.remote.dto.WatcherUserDto +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole + +/** + * DTO → domain mapping for watchers and candidate users. + * + * A watcher row reaches the client in two shapes: flattened (`userId`/`username` + * on the row) or with a nested `user` object. This mapper reads the user id, + * username, display name, and avatar from whichever is present, and normalises the + * role via [WatcherRole.fromApi], so a watcher is never dropped for a missing field. + */ +object WatcherMapper { + + fun watcherFromDto(dto: WatcherDto): Watcher? { + val userId = dto.userId ?: dto.user?.id ?: dto.id ?: return null + return Watcher( + userId = userId, + username = dto.username ?: dto.user?.username ?: userId, + displayName = dto.displayName ?: dto.user?.displayName, + avatarUrl = dto.avatarUrl ?: dto.user?.avatarUrl, + role = WatcherRole.fromApi(dto.role), + ) + } + + fun candidateFromDto(dto: WatcherUserDto): WatcherCandidate = WatcherCandidate( + userId = dto.id, + username = dto.username.ifBlank { dto.id }, + displayName = dto.displayName, + avatarUrl = dto.avatarUrl, + ) +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt index ba197ce..498e407 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt @@ -1,14 +1,25 @@ package com.interlinedlist.android.feature.lists.data.remote +import com.interlinedlist.android.feature.lists.data.remote.dto.AddWatcherRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionsResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnectionRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.FolderDto import com.interlinedlist.android.feature.lists.data.remote.dto.FoldersResponse import com.interlinedlist.android.feature.lists.data.remote.dto.ListEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.RefreshResultDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest import com.interlinedlist.android.feature.lists.data.remote.dto.RowsResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.SchemaEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.WatcherUsersResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.WatchersResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.WatchingStatusDto import kotlinx.serialization.json.JsonElement import retrofit2.http.Body import retrofit2.http.DELETE @@ -51,6 +62,64 @@ interface ListsApi { @GET("api/lists/{id}/schema") suspend fun getSchema(@Path("id") id: String): JsonElement + /** Replaces a list's schema with the serialised DSL in [body]. */ + @PUT("api/lists/{id}/schema") + suspend fun updateSchema( + @Path("id") id: String, + @Body body: UpdateSchemaRequest, + ): SchemaEnvelope + + /** Manual re-sync of a GitHub-backed list. */ + @POST("api/lists/{id}/refresh") + suspend fun refreshList(@Path("id") id: String): RefreshResultDto + + @GET("api/lists/{id}/watchers") + suspend fun getWatchers( + @Path("id") id: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): WatchersResponse + + @POST("api/lists/{id}/watchers") + suspend fun addWatcher( + @Path("id") id: String, + @Body body: AddWatcherRequest, + ) + + @GET("api/lists/{id}/watchers/me") + suspend fun getWatchingStatus(@Path("id") id: String): WatchingStatusDto + + @GET("api/lists/{id}/watchers/users") + suspend fun searchWatcherUsers( + @Path("id") id: String, + @Query("search") search: String, + @Query("excludeWatchers") excludeWatchers: Boolean, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): WatcherUsersResponse + + @PUT("api/lists/{id}/watchers/{userId}") + suspend fun updateWatcherRole( + @Path("id") id: String, + @Path("userId") userId: String, + @Body body: UpdateWatcherRoleRequest, + ) + + @DELETE("api/lists/{id}/watchers/{userId}") + suspend fun removeWatcher( + @Path("id") id: String, + @Path("userId") userId: String, + ) + + @GET("api/lists/connections") + suspend fun getConnections(): ConnectionsResponse + + @POST("api/lists/connections") + suspend fun createConnection(@Body body: CreateConnectionRequest): ConnectionEnvelope + + @DELETE("api/lists/connections/{id}") + suspend fun deleteConnection(@Path("id") id: String) + @GET("api/lists/{id}/data") suspend fun getRows( @Path("id") id: String, diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ConnectionDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ConnectionDtos.kt new file mode 100644 index 0000000..6e49202 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ConnectionDtos.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * A connection (edge) between two lists. The endpoint may return the linked list + * titles inline (`fromListTitle`/`toListTitle`) or omit them; the mapper falls + * back to the ids so the row still renders. + */ +@Serializable +data class ConnectionDto( + val id: String, + val fromListId: String = "", + val toListId: String = "", + val label: String? = null, + val fromListTitle: String? = null, + val toListTitle: String? = null, +) + +/** Envelope for `GET /api/lists/connections`; connections may be wrapped or bare. */ +@Serializable +data class ConnectionsResponse( + val data: List? = null, + val connections: List? = null, +) { + val items: List get() = data ?: connections ?: emptyList() +} + +/** Envelope for a single connection create; the connection may be wrapped or bare. */ +@Serializable +data class ConnectionEnvelope( + val connection: ConnectionDto? = null, + val data: ConnectionDto? = null, +) + +/** Body for `POST /api/lists/connections`. */ +@Serializable +data class CreateConnectionRequest( + val fromListId: String, + val toListId: String, + val label: String? = null, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RefreshDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RefreshDtos.kt new file mode 100644 index 0000000..10b0103 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/RefreshDtos.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Result of `POST /api/lists/{id}/refresh` (manual re-sync of a GitHub-backed + * list). The API may report how many rows were added/updated; all fields are + * optional so any success shape maps cleanly. + */ +@Serializable +data class RefreshResultDto( + val success: Boolean? = null, + val message: String? = null, + val added: Int? = null, + val updated: Int? = null, + val removed: Int? = null, + val itemCount: Int? = null, + val rowCount: Int? = null, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/SchemaDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/SchemaDtos.kt new file mode 100644 index 0000000..45729cc --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/SchemaDtos.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement + +/** + * Body for `PUT /api/lists/{id}/schema`. The API accepts the schema as a + * serialised DSL string (the same form `POST /api/lists` uses), so the editor + * serialises the edited field list to a JSON array string via + * [com.interlinedlist.android.feature.lists.data.SchemaMapper.toDsl] and sends it + * here. + */ +@Serializable +data class UpdateSchemaRequest( + val schema: String, +) + +/** + * Envelope for `PUT`/`GET` of a list's schema. The updated schema may come back + * bare (an array/object) or wrapped under `schema`; the mapper interprets either + * shape, so this keeps the payload as a raw [JsonElement]. + */ +@Serializable +data class SchemaEnvelope( + val schema: JsonElement? = null, + val data: JsonElement? = null, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/WatcherDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/WatcherDtos.kt new file mode 100644 index 0000000..01da7df --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/WatcherDtos.kt @@ -0,0 +1,74 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the list watchers endpoints. Field names follow the + * InterlinedList REST contract; the shared Json ignores unknown keys, so only the + * fields the UI renders need declaring. A watcher may arrive either flattened + * (`userId`/`username` on the row) or with a nested `user` object — the mapper + * ([com.interlinedlist.android.feature.lists.data.WatcherMapper]) tolerates both. + */ +@Serializable +data class WatcherDto( + val id: String? = null, + val userId: String? = null, + val role: String? = null, + val username: String? = null, + val displayName: String? = null, + val avatarUrl: String? = null, + val user: WatcherUserDto? = null, +) + +/** A user reference nested on a watcher row or returned by the user search. */ +@Serializable +data class WatcherUserDto( + val id: String, + val username: String = "", + val displayName: String? = null, + val avatarUrl: String? = null, +) + +/** Envelope for `GET /api/lists/{id}/watchers`; watchers may be wrapped or bare. */ +@Serializable +data class WatchersResponse( + val data: List? = null, + val watchers: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: watchers ?: emptyList() +} + +/** Envelope for `GET /api/lists/{id}/watchers/users` (candidate users to add). */ +@Serializable +data class WatcherUsersResponse( + val data: List? = null, + val users: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: users ?: emptyList() +} + +/** Envelope for `GET /api/lists/{id}/watchers/me`. */ +@Serializable +data class WatchingStatusDto( + val watching: Boolean = false, + val isWatching: Boolean? = null, + val role: String? = null, +) { + /** True when either boolean flavour the API may use reports watching. */ + val isWatchingResolved: Boolean get() = isWatching ?: watching +} + +/** Body for `POST /api/lists/{id}/watchers` — add one user with an optional role. */ +@Serializable +data class AddWatcherRequest( + val userId: String, + val role: String? = null, +) + +/** Body for `PUT /api/lists/{id}/watchers/{userId}` — change a user's role. */ +@Serializable +data class UpdateWatcherRoleRequest( + val role: String, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListConnection.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListConnection.kt new file mode 100644 index 0000000..f6b7d2a --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ListConnection.kt @@ -0,0 +1,15 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A directed link between two lists, optionally labelled. Titles are resolved for + * display when the API supplies them, falling back to the list ids so a row always + * renders something meaningful. + */ +data class ListConnection( + val id: String, + val fromListId: String, + val toListId: String, + val label: String?, + val fromListTitle: String, + val toListTitle: String, +) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/RefreshResult.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/RefreshResult.kt new file mode 100644 index 0000000..c59adb8 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/RefreshResult.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * Outcome of a manual refresh of a GitHub-backed list. The counts are optional + * (the API may only confirm success), so the UI shows a summary when available and + * a generic confirmation otherwise. + */ +data class RefreshResult( + val message: String?, + val added: Int?, + val updated: Int?, + val removed: Int?, +) { + /** A human summary of what changed, or null when the API reported no counts. */ + val summary: String? + get() { + val parts = buildList { + added?.takeIf { it > 0 }?.let { add("$it added") } + updated?.takeIf { it > 0 }?.let { add("$it updated") } + removed?.takeIf { it > 0 }?.let { add("$it removed") } + } + return parts.takeIf { it.isNotEmpty() }?.joinToString(", ") + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Watcher.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Watcher.kt new file mode 100644 index 0000000..e227740 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Watcher.kt @@ -0,0 +1,47 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A user who watches a list, together with the access role granted to them. Roles + * gate what a watcher can do; [WatcherRole.VIEWER] is the default read-only grant. + */ +data class Watcher( + val userId: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, + val role: WatcherRole, +) { + /** Best label for the row: display name when present, else the username. */ + val label: String get() = displayName?.takeIf { it.isNotBlank() } ?: username +} + +/** A user candidate returned by the watcher search (not yet a watcher). */ +data class WatcherCandidate( + val userId: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) { + val label: String get() = displayName?.takeIf { it.isNotBlank() } ?: username +} + +/** + * Access level a watcher holds on a list. Unknown/absent roles map to [VIEWER] so + * a watcher is never dropped and defaults to the least-privileged grant. + */ +enum class WatcherRole(val apiValue: String) { + VIEWER("viewer"), + EDITOR("editor"), + ADMIN("admin"), + OWNER("owner"); + + companion object { + /** Maps an API role string (case-insensitive) to a [WatcherRole], defaulting to [VIEWER]. */ + fun fromApi(raw: String?): WatcherRole = when (raw?.trim()?.lowercase()) { + "editor" -> EDITOR + "admin" -> ADMIN + "owner" -> OWNER + else -> VIEWER + } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreen.kt new file mode 100644 index 0000000..c0a34d3 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsScreen.kt @@ -0,0 +1,322 @@ +package com.interlinedlist.android.feature.lists.ui.connections + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListConnection +import com.interlinedlist.android.feature.lists.domain.ListSummary + +/** Stable test tags for the connections screen. */ +object ConnectionsTestTags { + const val LIST = "connectionsList" + const val CREATE_FAB = "connectionsCreateFab" + const val EMPTY = "connectionsEmpty" + const val PROGRESS = "connectionsProgress" + const val ERROR = "connectionsError" + const val SAVE = "connectionsSave" + fun connection(id: String) = "connection_$id" + fun remove(id: String) = "connectionRemove_$id" +} + +/** + * Hilt-wired entry for cross-list connections. This is a list-level screen (not + * per-list), so it takes no id nav arg; [onBack] pops navigation. + */ +@Composable +fun ConnectionsRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectionsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ConnectionsScreen( + state = state, + onBack = onBack, + onCreateConnection = { from, to, label -> viewModel.createConnection(from, to, label) }, + onDeleteConnection = viewModel::deleteConnection, + modifier = modifier, + ) +} + +/** Stateless connections screen — list, create, and remove links between lists. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectionsScreen( + state: ConnectionsUiState, + onBack: () -> Unit, + onCreateConnection: (String, String, String?) -> Unit, + onDeleteConnection: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var creating by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Connections") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + floatingActionButton = { + if (state.canCreate) { + ExtendedFloatingActionButton( + onClick = { creating = true }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("Connect") }, + modifier = Modifier.testTag(ConnectionsTestTags.CREATE_FAB), + ) + } + }, + ) { padding -> + when { + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(ConnectionsTestTags.PROGRESS)) } + + else -> Column(Modifier.padding(padding)) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(ConnectionsTestTags.ERROR), + ) + } + if (state.isEmpty) { + EmptyState() + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(ConnectionsTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.connections, key = { it.id }) { connection -> + ConnectionCard( + connection = connection, + onRemove = { onDeleteConnection(connection.id) }, + ) + } + } + } + } + } + } + + if (creating) { + ModalBottomSheet(onDismissRequest = { creating = false }, sheetState = sheetState) { + ConnectionEditor( + lists = state.lists, + isSaving = state.isSaving, + onCreate = { from, to, label -> + onCreateConnection(from, to, label) + creating = false + }, + onCancel = { creating = false }, + ) + } + } +} + +@Composable +private fun ConnectionCard(connection: ListConnection, onRemove: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectionsTestTags.connection(connection.id)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = "${connection.fromListTitle} → ${connection.toListTitle}", + style = MaterialTheme.typography.titleMedium, + ) + if (!connection.label.isNullOrBlank()) { + Text( + text = connection.label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton( + onClick = onRemove, + modifier = Modifier.testTag(ConnectionsTestTags.remove(connection.id)), + ) { Icon(Icons.Default.Delete, contentDescription = "Remove connection") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ConnectionEditor( + lists: List, + isSaving: Boolean, + onCreate: (String, String, String?) -> Unit, + onCancel: () -> Unit, +) { + var fromId by remember { mutableStateOf(null) } + var toId by remember { mutableStateOf(null) } + var label by remember { mutableStateOf("") } + + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("New connection", style = MaterialTheme.typography.titleLarge) + + Text("From", style = MaterialTheme.typography.labelMedium) + ListPicker(lists = lists, selectedId = fromId, onSelect = { fromId = it }) + + Text("To", style = MaterialTheme.typography.labelMedium) + ListPicker(lists = lists, selectedId = toId, onSelect = { toId = it }, disabledId = fromId) + + OutlinedTextField( + value = label, + onValueChange = { label = it }, + label = { Text("Label (optional)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton(onClick = onCancel) { Text("Cancel") } + Button( + onClick = { + val from = fromId + val to = toId + if (from != null && to != null) onCreate(from, to, label.ifBlank { null }) + }, + enabled = !isSaving && fromId != null && toId != null && fromId != toId, + modifier = Modifier.testTag(ConnectionsTestTags.SAVE), + ) { Text("Connect") } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ListPicker( + lists: List, + selectedId: String?, + onSelect: (String) -> Unit, + disabledId: String? = null, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + lists.forEach { list -> + FilterChip( + selected = selectedId == list.id, + onClick = { onSelect(list.id) }, + enabled = list.id != disabledId, + label = { Text(list.title.ifBlank { "Untitled" }) }, + ) + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(ConnectionsTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No connections yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Connect two lists to relate their data.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectionsScreenPreview() { + InterlinedListTheme { + ConnectionsScreen( + state = ConnectionsUiState( + connections = listOf( + ListConnection("c1", "l1", "l2", "depends on", "Backlog", "Roadmap"), + ), + isLoading = false, + ), + onBack = {}, + onCreateConnection = { _, _, _ -> }, + onDeleteConnection = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModel.kt new file mode 100644 index 0000000..8fbf733 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModel.kt @@ -0,0 +1,89 @@ +package com.interlinedlist.android.feature.lists.ui.connections + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListConnection +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the connections screen. */ +data class ConnectionsUiState( + val connections: List = emptyList(), + val lists: List = emptyList(), + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val errorMessage: String? = null, +) { + val isEmpty: Boolean get() = connections.isEmpty() && !isLoading && errorMessage == null + + /** Lists selectable as connection endpoints; needs at least two to connect. */ + val canCreate: Boolean get() = lists.size >= 2 && !isSaving +} + +@HiltViewModel +class ConnectionsViewModel @Inject constructor( + private val repository: ListsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConnectionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getConnections()) { + is ApiResult.Success -> _uiState.update { it.copy(connections = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + // The picker needs the user's lists as endpoints; a failure just leaves it empty. + when (val lists = repository.refreshLists()) { + is ApiResult.Success -> _uiState.update { it.copy(lists = lists.data.items) } + is ApiResult.Failure -> Unit + } + } + } + + fun createConnection(fromListId: String, toListId: String, label: String?, onDone: () -> Unit = {}) { + if (fromListId.isBlank() || toListId.isBlank() || fromListId == toListId) return + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.createConnection(fromListId, toListId, label)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false, connections = it.connections + result.data) } + onDone() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun deleteConnection(id: String) { + viewModelScope.launch { + when (val result = repository.deleteConnection(id)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(connections = state.connections.filterNot { it.id == id }) + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt index db47d9b..d2a3951 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -19,8 +19,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Refresh import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -28,10 +32,13 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -60,19 +67,26 @@ object ListDetailTestTags { const val ERROR = "listDetailError" const val SUBSCRIPTION = "listDetailSubscription" const val DELETE_LIST = "listDetailDeleteList" + const val REFRESH = "listDetailRefresh" + const val OVERFLOW = "listDetailOverflow" + const val EDIT_SCHEMA = "listDetailEditSchema" + const val WATCHERS = "listDetailWatchers" fun row(id: String) = "listDetailRow_$id" } /** * Hilt-wired entry for a single list. Reads its `listId` from the nav * SavedStateHandle (see [LIST_ID_ARG]); [onBack] and [onListDeleted] let the app - * pop navigation. + * pop navigation. [onEditSchema] and [onOpenWatchers] push the drill-down routes + * for the list's columns and watchers (both keyed by the same list id). */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ListDetailRoute( onBack: () -> Unit, onListDeleted: () -> Unit, + onEditSchema: () -> Unit, + onOpenWatchers: () -> Unit, modifier: Modifier = Modifier, viewModel: ListDetailViewModel = hiltViewModel(), ) { @@ -80,6 +94,15 @@ fun ListDetailRoute( var editing by remember { mutableStateOf(null) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val snackbarHostState = remember { SnackbarHostState() } + + // Surface the refresh outcome as a transient snackbar, then clear it. + LaunchedEffect(state.refreshMessage) { + state.refreshMessage?.let { + snackbarHostState.showSnackbar(it) + viewModel.clearRefreshMessage() + } + } ListDetailScreen( state = state, @@ -88,6 +111,10 @@ fun ListDetailRoute( onEditRow = { editing = EditorTarget.Existing(it) }, onDeleteRow = viewModel::deleteRow, onDeleteList = { viewModel.deleteList(onListDeleted) }, + onRefresh = viewModel::refreshFromGithub, + onEditSchema = onEditSchema, + onOpenWatchers = onOpenWatchers, + snackbarHostState = snackbarHostState, modifier = modifier, ) @@ -126,9 +153,15 @@ fun ListDetailScreen( onDeleteRow: (String) -> Unit, onDeleteList: () -> Unit, modifier: Modifier = Modifier, + onRefresh: () -> Unit = {}, + onEditSchema: () -> Unit = {}, + onOpenWatchers: () -> Unit = {}, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { + var menuOpen by remember { mutableStateOf(false) } Scaffold( modifier = modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackbarHostState) }, topBar = { TopAppBar( title = { Text(state.title.ifBlank { "List" }, maxLines = 1, overflow = TextOverflow.Ellipsis) }, @@ -138,8 +171,38 @@ fun ListDetailScreen( } }, actions = { - IconButton(onClick = onDeleteList, modifier = Modifier.testTag(ListDetailTestTags.DELETE_LIST)) { - Icon(Icons.Default.Delete, contentDescription = "Delete list") + if (state.isRefreshing) { + CircularProgressIndicator( + Modifier + .padding(horizontal = 12.dp) + .height(20.dp) + .width(20.dp), + ) + } else { + IconButton(onClick = onRefresh, modifier = Modifier.testTag(ListDetailTestTags.REFRESH)) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh from source") + } + } + IconButton( + onClick = { menuOpen = true }, + modifier = Modifier.testTag(ListDetailTestTags.OVERFLOW), + ) { Icon(Icons.Default.MoreVert, contentDescription = "More actions") } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Edit columns") }, + onClick = { menuOpen = false; onEditSchema() }, + modifier = Modifier.testTag(ListDetailTestTags.EDIT_SCHEMA), + ) + DropdownMenuItem( + text = { Text("Watchers") }, + onClick = { menuOpen = false; onOpenWatchers() }, + modifier = Modifier.testTag(ListDetailTestTags.WATCHERS), + ) + DropdownMenuItem( + text = { Text("Delete list") }, + onClick = { menuOpen = false; onDeleteList() }, + modifier = Modifier.testTag(ListDetailTestTags.DELETE_LIST), + ) } }, ) diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt index 6927013..f2ae575 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt @@ -28,6 +28,8 @@ data class ListDetailUiState( val subscriptionRequired: Boolean = false, val isSaving: Boolean = false, val deleted: Boolean = false, + val isRefreshing: Boolean = false, + val refreshMessage: String? = null, ) { val title: String get() = summary?.title.orEmpty() val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null @@ -124,6 +126,52 @@ class ListDetailViewModel @Inject constructor( } } + /** + * Manually re-syncs a GitHub-backed list. On success the detail is reloaded so + * the new rows appear, and a short summary is surfaced for the UI to toast. + */ + fun refreshFromGithub() { + if (_uiState.value.isRefreshing) return + _uiState.update { it.copy(isRefreshing = true, errorMessage = null, refreshMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshGithubList(listId)) { + is ApiResult.Success -> { + _uiState.update { + it.copy( + isRefreshing = false, + refreshMessage = result.data.summary + ?: result.data.message + ?: "List refreshed.", + ) + } + // Pull the freshly-synced rows into view. + reload() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isRefreshing = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Reloads detail without toggling the top-level loading spinner (post-refresh). */ + private fun reload() { + viewModelScope.launch { + when (val result = repository.getListDetail(listId)) { + is ApiResult.Success -> _uiState.update { + it.copy( + summary = result.data.summary, + schema = result.data.schema, + rows = result.data.rows, + ) + } + is ApiResult.Failure -> Unit // Keep the existing rows; refresh already succeeded. + } + } + } + + fun clearRefreshMessage() = _uiState.update { it.copy(refreshMessage = null) } + fun deleteList(onDeleted: () -> Unit = {}) { viewModelScope.launch { when (val result = repository.deleteList(listId)) { diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt new file mode 100644 index 0000000..b058aba --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorScreen.kt @@ -0,0 +1,270 @@ +package com.interlinedlist.android.feature.lists.ui.schema + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.FieldType + +/** Stable test tags for the schema editor. */ +object SchemaEditorTestTags { + const val LIST = "schemaEditorList" + const val ADD_COLUMN = "schemaEditorAddColumn" + const val SAVE = "schemaEditorSave" + const val PROGRESS = "schemaEditorProgress" + const val ERROR = "schemaEditorError" + const val SUBSCRIPTION = "schemaEditorSubscription" + fun column(uiId: Long) = "schemaColumn_$uiId" + fun key(uiId: Long) = "schemaColumnKey_$uiId" + fun remove(uiId: Long) = "schemaColumnRemove_$uiId" +} + +/** + * Hilt-wired entry for editing a list's schema (columns). Reads its `listId` from + * the nav SavedStateHandle (see [SCHEMA_LIST_ID_ARG]). [onBack] pops navigation; + * [onSaved] is invoked after a successful save so the app can pop back to detail. + */ +@Composable +fun SchemaEditorRoute( + onBack: () -> Unit, + onSaved: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SchemaEditorViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + // Pop back once the schema has been persisted. + LaunchedEffect(state.saved) { + if (state.saved) onSaved() + } + + SchemaEditorScreen( + state = state, + onBack = onBack, + onAddColumn = viewModel::addColumn, + onRemoveColumn = viewModel::removeColumn, + onKeyChange = viewModel::updateKey, + onLabelChange = viewModel::updateLabel, + onTypeChange = viewModel::updateType, + onSave = { viewModel.save() }, + modifier = modifier, + ) +} + +/** Stateless schema editor — add/edit/remove typed columns with loading/error states. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SchemaEditorScreen( + state: SchemaEditorUiState, + onBack: () -> Unit, + onAddColumn: () -> Unit, + onRemoveColumn: (Long) -> Unit, + onKeyChange: (Long, String) -> Unit, + onLabelChange: (Long, String) -> Unit, + onTypeChange: (Long, FieldType) -> Unit, + onSave: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Edit columns") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton( + onClick = onSave, + enabled = state.canSave, + modifier = Modifier.testTag(SchemaEditorTestTags.SAVE), + ) { Text("Save") } + }, + ) + }, + floatingActionButton = { + if (!state.subscriptionRequired && !state.isLoading) { + ExtendedFloatingActionButton( + onClick = onAddColumn, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("Add column") }, + modifier = Modifier.testTag(SchemaEditorTestTags.ADD_COLUMN), + ) + } + }, + ) { padding -> + when { + state.subscriptionRequired -> Centered(Modifier.padding(padding).testTag(SchemaEditorTestTags.SUBSCRIPTION)) { + Text("Subscribers only", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text(state.errorMessage ?: "Editing lists requires an active subscription.") + } + + state.isLoading -> Centered(Modifier.padding(padding)) { + CircularProgressIndicator(Modifier.testTag(SchemaEditorTestTags.PROGRESS)) + } + + else -> Column(Modifier.padding(padding)) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(SchemaEditorTestTags.ERROR), + ) + } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(SchemaEditorTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.columns, key = { it.uiId }) { column -> + ColumnCard( + column = column, + onKeyChange = { onKeyChange(column.uiId, it) }, + onLabelChange = { onLabelChange(column.uiId, it) }, + onTypeChange = { onTypeChange(column.uiId, it) }, + onRemove = { onRemoveColumn(column.uiId) }, + ) + } + if (state.columns.isEmpty()) { + item { + Text( + "No columns yet. Use Add column to define this list's shape.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ColumnCard( + column: EditableColumn, + onKeyChange: (String) -> Unit, + onLabelChange: (String) -> Unit, + onTypeChange: (FieldType) -> Unit, + onRemove: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(SchemaEditorTestTags.column(column.uiId)), + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = column.key, + onValueChange = onKeyChange, + label = { Text("Field key") }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag(SchemaEditorTestTags.key(column.uiId)), + ) + IconButton( + onClick = onRemove, + modifier = Modifier.testTag(SchemaEditorTestTags.remove(column.uiId)), + ) { Icon(Icons.Default.Delete, contentDescription = "Remove column") } + } + OutlinedTextField( + value = column.label, + onValueChange = onLabelChange, + label = { Text("Label") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Text("Type", style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FieldType.entries.forEach { type -> + FilterChip( + selected = column.type == type, + onClick = { onTypeChange(type) }, + label = { Text(type.name.lowercase().replaceFirstChar { it.uppercase() }) }, + ) + } + } + } + } +} + +@Composable +private fun Centered(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, content = { content() }) + } +} + +@Preview(showBackground = true) +@Composable +private fun SchemaEditorScreenPreview() { + InterlinedListTheme { + SchemaEditorScreen( + state = SchemaEditorUiState( + columns = listOf( + EditableColumn(0, "title", "Title", FieldType.TEXT), + EditableColumn(1, "pages", "Pages", FieldType.NUMBER), + ), + isLoading = false, + ), + onBack = {}, + onAddColumn = {}, + onRemoveColumn = {}, + onKeyChange = { _, _ -> }, + onLabelChange = { _, _ -> }, + onTypeChange = { _, _ -> }, + onSave = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt new file mode 100644 index 0000000..dda47fa --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModel.kt @@ -0,0 +1,158 @@ +package com.interlinedlist.android.feature.lists.ui.schema + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.SchemaField +import com.interlinedlist.android.feature.lists.ui.isSubscriptionGate +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the schema editor route reads its list id from. */ +const val SCHEMA_LIST_ID_ARG = "listId" + +/** + * One editable column in the schema editor. A stable [uiId] keys the row in the + * list so reordering/removal stays correct while the user types; it is never sent + * to the server (the [key]/[label]/[type] become the DSL). + */ +data class EditableColumn( + val uiId: Long, + val key: String = "", + val label: String = "", + val type: FieldType = FieldType.TEXT, +) { + /** True once the column has a usable key to persist. */ + val isComplete: Boolean get() = key.isNotBlank() +} + +/** UI state for the schema editor. */ +data class SchemaEditorUiState( + val columns: List = emptyList(), + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val saved: Boolean = false, +) { + /** Save is allowed once at least one column has a key and nothing is in flight. */ + val canSave: Boolean get() = !isSaving && columns.any { it.isComplete } +} + +@HiltViewModel +class SchemaEditorViewModel @Inject constructor( + private val repository: ListsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val listId: String = requireNotNull(savedStateHandle[SCHEMA_LIST_ID_ARG]) { + "SchemaEditorViewModel requires a '$SCHEMA_LIST_ID_ARG' nav argument" + } + + private var nextUiId = 0L + + private val _uiState = MutableStateFlow(SchemaEditorUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.getListDetail(listId)) { + is ApiResult.Success -> _uiState.update { + it.copy( + columns = result.data.schema.fields.map(::toEditable), + isLoading = false, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoading = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun addColumn() = _uiState.update { + it.copy(columns = it.columns + EditableColumn(uiId = nextUiId++)) + } + + fun removeColumn(uiId: Long) = _uiState.update { + it.copy(columns = it.columns.filterNot { column -> column.uiId == uiId }) + } + + fun updateKey(uiId: Long, key: String) = mutate(uiId) { it.copy(key = key) } + + fun updateLabel(uiId: Long, label: String) = mutate(uiId) { it.copy(label = label) } + + fun updateType(uiId: Long, type: FieldType) = mutate(uiId) { it.copy(type = type) } + + fun save(onSaved: () -> Unit = {}) { + val schema = toSchema() + if (schema.isEmpty) return + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.updateSchema(listId, schema)) { + is ApiResult.Success -> { + _uiState.update { + it.copy( + isSaving = false, + saved = true, + columns = result.data.fields.map(::toEditable), + ) + } + onSaved() + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isSaving = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + /** Projects the editable rows (dropping incomplete ones) into a [ListSchema]. */ + private fun toSchema(): ListSchema = ListSchema( + _uiState.value.columns + .filter { it.isComplete } + .map { column -> + SchemaField( + key = column.key.trim(), + label = column.label.trim().ifBlank { column.key.trim() }, + type = column.type, + ) + }, + ) + + private fun toEditable(field: SchemaField): EditableColumn = EditableColumn( + uiId = nextUiId++, + key = field.key, + label = field.label, + type = field.type, + ) + + private fun mutate(uiId: Long, transform: (EditableColumn) -> EditableColumn) = _uiState.update { state -> + state.copy(columns = state.columns.map { if (it.uiId == uiId) transform(it) else it }) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt new file mode 100644 index 0000000..c0b2701 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt @@ -0,0 +1,289 @@ +package com.interlinedlist.android.feature.lists.ui.watchers + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole + +/** Stable test tags for the watchers screen. */ +object WatchersTestTags { + const val LIST = "watchersList" + const val SEARCH = "watchersSearch" + const val EMPTY = "watchersEmpty" + const val PROGRESS = "watchersProgress" + const val ERROR = "watchersError" + fun watcher(userId: String) = "watcher_$userId" + fun remove(userId: String) = "watcherRemove_$userId" + fun candidate(userId: String) = "watcherCandidate_$userId" +} + +/** + * Hilt-wired entry for a list's watchers. Reads its `listId` from the nav + * SavedStateHandle (see [WATCHERS_LIST_ID_ARG]); [onBack] pops navigation. + */ +@Composable +fun WatchersRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: WatchersViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + WatchersScreen( + state = state, + onBack = onBack, + onSearchQueryChange = viewModel::onSearchQueryChange, + onAddCandidate = { viewModel.addWatcher(it) }, + onChangeRole = viewModel::changeRole, + onRemoveWatcher = viewModel::removeWatcher, + modifier = modifier, + ) +} + +/** Stateless watchers screen — list watchers, change roles, add/remove watchers. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WatchersScreen( + state: WatchersUiState, + onBack: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onAddCandidate: (WatcherCandidate) -> Unit, + onChangeRole: (Watcher, WatcherRole) -> Unit, + onRemoveWatcher: (Watcher) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Watchers") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(WatchersTestTags.PROGRESS)) } + + else -> Column(Modifier.padding(padding)) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("Add a watcher") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(WatchersTestTags.SEARCH), + ) + + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag(WatchersTestTags.ERROR), + ) + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(WatchersTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.candidates.isNotEmpty()) { + item { + Text("Suggestions", style = MaterialTheme.typography.labelLarge) + } + items(state.candidates, key = { "candidate-${it.userId}" }) { candidate -> + CandidateRow(candidate = candidate, onAdd = { onAddCandidate(candidate) }) + } + } + + if (state.isEmpty && state.candidates.isEmpty()) { + item { EmptyState() } + } else { + items(state.watchers, key = { it.userId }) { watcher -> + WatcherRow( + watcher = watcher, + onChangeRole = { onChangeRole(watcher, it) }, + onRemove = { onRemoveWatcher(watcher) }, + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WatcherRow( + watcher: Watcher, + onChangeRole: (WatcherRole) -> Unit, + onRemove: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(WatchersTestTags.watcher(watcher.userId)), + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = watcher.label, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${watcher.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.testTag(WatchersTestTags.remove(watcher.userId)), + ) { Icon(Icons.Default.Close, contentDescription = "Remove watcher") } + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + WatcherRole.entries.forEach { role -> + FilterChip( + selected = watcher.role == role, + onClick = { onChangeRole(role) }, + label = { Text(role.name.lowercase().replaceFirstChar { it.uppercase() }) }, + ) + } + } + } + } +} + +@Composable +private fun CandidateRow(candidate: WatcherCandidate, onAdd: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(WatchersTestTags.candidate(candidate.userId)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(candidate.label, style = MaterialTheme.typography.titleMedium) + Text( + text = "@${candidate.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = onAdd, + label = { Text("Add") }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + ) + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + .testTag(WatchersTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No watchers yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Search above to grant someone access.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun WatchersScreenPreview() { + InterlinedListTheme { + WatchersScreen( + state = WatchersUiState( + watchers = listOf( + Watcher("u1", "ada", "Ada Lovelace", null, WatcherRole.EDITOR), + Watcher("u2", "grace", null, null, WatcherRole.VIEWER), + ), + isLoading = false, + ), + onBack = {}, + onSearchQueryChange = {}, + onAddCandidate = {}, + onChangeRole = { _, _ -> }, + onRemoveWatcher = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt new file mode 100644 index 0000000..f7f2ae0 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt @@ -0,0 +1,128 @@ +package com.interlinedlist.android.feature.lists.ui.watchers + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the watchers route reads its list id from. */ +const val WATCHERS_LIST_ID_ARG = "listId" + +/** UI state for the watchers screen. */ +data class WatchersUiState( + val watchers: List = emptyList(), + val isWatching: Boolean = false, + val isLoading: Boolean = true, + val errorMessage: String? = null, + val searchQuery: String = "", + val candidates: List = emptyList(), + val isSearching: Boolean = false, +) { + val isEmpty: Boolean get() = watchers.isEmpty() && !isLoading && errorMessage == null +} + +@HiltViewModel +class WatchersViewModel @Inject constructor( + private val repository: ListsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val listId: String = requireNotNull(savedStateHandle[WATCHERS_LIST_ID_ARG]) { + "WatchersViewModel requires a '$WATCHERS_LIST_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(WatchersUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getWatchers(listId)) { + is ApiResult.Success -> _uiState.update { it.copy(watchers = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + // The "am I watching?" flag is best-effort; a failure just leaves it false. + when (val status = repository.isWatching(listId)) { + is ApiResult.Success -> _uiState.update { it.copy(isWatching = status.data) } + is ApiResult.Failure -> Unit + } + } + } + + fun onSearchQueryChange(query: String) { + _uiState.update { it.copy(searchQuery = query) } + if (query.isBlank()) { + _uiState.update { it.copy(candidates = emptyList(), isSearching = false) } + return + } + _uiState.update { it.copy(isSearching = true) } + viewModelScope.launch { + when (val result = repository.searchWatcherCandidates(listId, query.trim())) { + is ApiResult.Success -> _uiState.update { it.copy(candidates = result.data, isSearching = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(candidates = emptyList(), isSearching = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun addWatcher(candidate: WatcherCandidate, role: WatcherRole = WatcherRole.VIEWER) { + viewModelScope.launch { + when (val result = repository.addWatcher(listId, candidate.userId, role)) { + is ApiResult.Success -> { + // Clear the search and reload so the new watcher's server-side role shows. + _uiState.update { it.copy(searchQuery = "", candidates = emptyList()) } + load() + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun changeRole(watcher: Watcher, role: WatcherRole) { + if (watcher.role == role) return + viewModelScope.launch { + when (val result = repository.updateWatcherRole(listId, watcher.userId, role)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy( + watchers = state.watchers.map { + if (it.userId == watcher.userId) it.copy(role = role) else it + }, + ) + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun removeWatcher(watcher: Watcher) { + viewModelScope.launch { + when (val result = repository.removeWatcher(listId, watcher.userId)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(watchers = state.watchers.filterNot { it.userId == watcher.userId }) + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index 31b4679..1b95062 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -3,12 +3,17 @@ package com.interlinedlist.android.feature.lists import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged +import com.interlinedlist.android.feature.lists.domain.RefreshResult +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -32,8 +37,28 @@ class FakeListsRepository : ListsRepository { var updateRowResult: ApiResult? = null var deleteRowResult: ApiResult = ApiResult.Success(Unit) + // Round-2 deferred features. + var updateSchemaResult: ApiResult? = null + var refreshGithubResult: ApiResult = + ApiResult.Success(RefreshResult(message = null, added = 0, updated = 0, removed = 0)) + var watchersResult: ApiResult> = ApiResult.Success(emptyList()) + var isWatchingResult: ApiResult = ApiResult.Success(false) + var candidatesResult: ApiResult> = ApiResult.Success(emptyList()) + var addWatcherResult: ApiResult = ApiResult.Success(Unit) + var updateWatcherRoleResult: ApiResult = ApiResult.Success(Unit) + var removeWatcherResult: ApiResult = ApiResult.Success(Unit) + var connectionsResult: ApiResult> = ApiResult.Success(emptyList()) + var createConnectionResult: ApiResult? = null + var deleteConnectionResult: ApiResult = ApiResult.Success(Unit) + var refreshCount = 0 var loadMoreCount = 0 + var updateSchemaCount = 0 + var refreshGithubCount = 0 + var addWatcherCount = 0 + var removeWatcherCount = 0 + var lastSchemaUpdate: ListSchema? = null + var lastWatcherSearch: String? = null override fun observeLists(): Flow> = cache @@ -83,6 +108,57 @@ class FakeListsRepository : ListsRepository { override suspend fun createFolder(name: String, parentId: String?): ApiResult = ApiResult.Success(ListFolder("f", name, parentId)) + override suspend fun updateSchema(listId: String, schema: ListSchema): ApiResult { + updateSchemaCount++ + lastSchemaUpdate = schema + return updateSchemaResult ?: ApiResult.Success(schema) + } + + override suspend fun refreshGithubList(listId: String): ApiResult { + refreshGithubCount++ + return refreshGithubResult + } + + override suspend fun getWatchers(listId: String, limit: Int): ApiResult> = watchersResult + + override suspend fun isWatching(listId: String): ApiResult = isWatchingResult + + override suspend fun searchWatcherCandidates( + listId: String, + query: String, + limit: Int, + ): ApiResult> { + lastWatcherSearch = query + return candidatesResult + } + + override suspend fun addWatcher(listId: String, userId: String, role: WatcherRole): ApiResult { + addWatcherCount++ + return addWatcherResult + } + + override suspend fun updateWatcherRole( + listId: String, + userId: String, + role: WatcherRole, + ): ApiResult = updateWatcherRoleResult + + override suspend fun removeWatcher(listId: String, userId: String): ApiResult { + removeWatcherCount++ + return removeWatcherResult + } + + override suspend fun getConnections(): ApiResult> = connectionsResult + + override suspend fun createConnection( + fromListId: String, + toListId: String, + label: String?, + ): ApiResult = createConnectionResult + ?: ApiResult.Success(ListConnection("c-new", fromListId, toListId, label, fromListId, toListId)) + + override suspend fun deleteConnection(id: String): ApiResult = deleteConnectionResult + companion object { fun subscriptionFailure(): ApiResult.Failure = ApiResult.Failure(AppError.SubscriptionRequired("Lists require an active subscription")) diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapperTest.kt new file mode 100644 index 0000000..7d76584 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ConnectionMapperTest.kt @@ -0,0 +1,55 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionDto +import com.interlinedlist.android.feature.lists.data.remote.dto.RefreshResultDto +import org.junit.Test + +/** Connections and refresh results map defensively, filling missing titles from ids. */ +class ConnectionMapperTest { + + @Test + fun `uses supplied titles when present`() { + val connection = ConnectionMapper.connectionFromDto( + ConnectionDto( + id = "c1", + fromListId = "l1", + toListId = "l2", + label = "blocks", + fromListTitle = "Backlog", + toListTitle = "Roadmap", + ), + ) + + assertThat(connection.fromListTitle).isEqualTo("Backlog") + assertThat(connection.toListTitle).isEqualTo("Roadmap") + assertThat(connection.label).isEqualTo("blocks") + } + + @Test + fun `falls back to ids for titles and nulls out a blank label`() { + val connection = ConnectionMapper.connectionFromDto( + ConnectionDto(id = "c2", fromListId = "l1", toListId = "l2", label = " "), + ) + + assertThat(connection.fromListTitle).isEqualTo("l1") + assertThat(connection.toListTitle).isEqualTo("l2") + assertThat(connection.label).isNull() + } + + @Test + fun `refresh summary reports only the non-zero counts`() { + val result = ConnectionMapper.refreshFromDto( + RefreshResultDto(added = 3, updated = 0, removed = 1), + ) + + assertThat(result.summary).isEqualTo("3 added, 1 removed") + } + + @Test + fun `refresh summary is null when nothing changed`() { + val result = ConnectionMapper.refreshFromDto(RefreshResultDto(added = 0, updated = 0, removed = 0)) + + assertThat(result.summary).isNull() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt new file mode 100644 index 0000000..e58d5db --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepositoryDeferredTest.kt @@ -0,0 +1,264 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.SchemaField +import com.interlinedlist.android.feature.lists.domain.WatcherRole +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * MockWebServer coverage for the round-2 deferred endpoints: schema editing, + * GitHub refresh, watchers, and connections. Verifies request shapes (paths, + * methods, bodies) and DTO→domain mapping over a real HTTP stack. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ListsRepositoryDeferredTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var repository: DefaultListsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ListsApi::class.java) + repository = DefaultListsRepository(api, FakeDao(), json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `updateSchema sends the serialised DSL string and parses the echo`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "schema": [ { "key": "title", "type": "text" } ] }""", + ), + ) + val schema = ListSchema(listOf(SchemaField("title", "Title", FieldType.TEXT))) + + val result = repository.updateSchema("L1", schema) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.fields.map { it.key }).containsExactly("title") + + val request: RecordedRequest = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/lists/L1/schema") + val body = request.body.readUtf8() + // The schema is sent as a stringified DSL under "schema". + assertThat(body).contains("\"schema\"") + assertThat(body).contains("title") + } + + @Test + fun `updateSchema keeps the sent schema when the response omits it`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + val schema = ListSchema(listOf(SchemaField("k", "K", FieldType.NUMBER))) + + val result = repository.updateSchema("L1", schema) + + val parsed = (result as ApiResult.Success).data + assertThat(parsed.fields.single().key).isEqualTo("k") + assertThat(parsed.fields.single().type).isEqualTo(FieldType.NUMBER) + } + + @Test + fun `refreshGithubList maps counts into a summary`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "success": true, "added": 2, "updated": 1, "removed": 0 }"""), + ) + + val result = repository.refreshGithubList("L1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.summary).isEqualTo("2 added, 1 updated") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/L1/refresh") + } + + @Test + fun `getWatchers maps flattened and nested rows`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "data": [ + { "userId": "u1", "username": "ada", "role": "admin" }, + { "role": "viewer", "user": { "id": "u2", "username": "grace" } } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getWatchers("L1") + + val watchers = (result as ApiResult.Success).data + assertThat(watchers.map { it.userId }).containsExactly("u1", "u2").inOrder() + assertThat(watchers[0].role).isEqualTo(WatcherRole.ADMIN) + assertThat(watchers[1].username).isEqualTo("grace") + } + + @Test + fun `isWatching resolves either boolean flavour`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "isWatching": true }""")) + + val result = repository.isWatching("L1") + + assertThat((result as ApiResult.Success).data).isTrue() + assertThat(server.takeRequest().path).isEqualTo("/api/lists/L1/watchers/me") + } + + @Test + fun `searchWatcherCandidates excludes existing watchers via query`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "data": [ { "id": "u9", "username": "linus" } ] }"""), + ) + + val result = repository.searchWatcherCandidates("L1", "lin") + + assertThat((result as ApiResult.Success).data.single().username).isEqualTo("linus") + val request = server.takeRequest() + assertThat(request.path).contains("/api/lists/L1/watchers/users") + assertThat(request.path).contains("search=lin") + assertThat(request.path).contains("excludeWatchers=true") + } + + @Test + fun `addWatcher posts the user id and role`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + + val result = repository.addWatcher("L1", "u5", WatcherRole.EDITOR) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/L1/watchers") + val body = request.body.readUtf8() + assertThat(body).contains("\"userId\":\"u5\"") + assertThat(body).contains("\"role\":\"editor\"") + } + + @Test + fun `updateWatcherRole puts the new role`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + repository.updateWatcherRole("L1", "u5", WatcherRole.ADMIN) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/lists/L1/watchers/u5") + assertThat(request.body.readUtf8()).contains("\"role\":\"admin\"") + } + + @Test + fun `removeWatcher deletes by user id`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.removeWatcher("L1", "u5") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/lists/L1/watchers/u5") + } + + @Test + fun `getConnections maps rows with title fallbacks`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "data": [ { "id": "c1", "fromListId": "l1", "toListId": "l2" } ] }""", + ), + ) + + val result = repository.getConnections() + + val connection = (result as ApiResult.Success).data.single() + assertThat(connection.id).isEqualTo("c1") + assertThat(connection.fromListTitle).isEqualTo("l1") + assertThat(server.takeRequest().path).isEqualTo("/api/lists/connections") + } + + @Test + fun `createConnection posts the edge and returns the created connection`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "connection": { "id": "c9", "fromListId": "l1", "toListId": "l2", "label": "blocks" } }"""), + ) + + val result = repository.createConnection("l1", "l2", "blocks") + + val connection = (result as ApiResult.Success).data + assertThat(connection.id).isEqualTo("c9") + assertThat(connection.label).isEqualTo("blocks") + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/connections") + val body = request.body.readUtf8() + assertThat(body).contains("\"fromListId\":\"l1\"") + assertThat(body).contains("\"toListId\":\"l2\"") + } + + @Test + fun `deleteConnection maps a 404 to NotFound`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "gone" }""")) + + val result = repository.deleteConnection("c1") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } +} + +/** Minimal in-memory [ListDao] for these HTTP-level tests (cache is not exercised). */ +private class FakeDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + override fun observeLists(): Flow> = state + override suspend fun upsertAll(lists: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + lists.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + override suspend fun upsert(list: CachedListEntity) = upsertAll(listOf(list)) + override suspend fun deleteById(id: String) { state.value = state.value.filterNot { it.id == id } } + override suspend fun clear() { state.value = emptyList() } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt index 7f60cc9..56058b2 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/SchemaMapperTest.kt @@ -2,6 +2,8 @@ package com.interlinedlist.android.feature.lists.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.SchemaField import kotlinx.serialization.json.Json import org.junit.Test @@ -93,4 +95,39 @@ class SchemaMapperTest { assertThat(SchemaMapper.fromJson(null).isEmpty).isTrue() assertThat(parse("\"nope\"").isEmpty).isTrue() } + + @Test + fun `toDsl serialises fields to the canonical array DSL`() { + val schema = ListSchema( + listOf( + SchemaField("title", "Title", FieldType.TEXT), + SchemaField("status", "Status", FieldType.SELECT, required = true, options = listOf("open", "done")), + ), + ) + + val dsl = SchemaMapper.toDsl(schema) + + assertThat(dsl).hasSize(2) + // The result round-trips back through fromJson unchanged in key/label/type. + val reparsed = SchemaMapper.fromJson(dsl) + assertThat(reparsed.fields.map { it.key }).containsExactly("title", "status").inOrder() + val status = reparsed.fields[1] + assertThat(status.type).isEqualTo(FieldType.SELECT) + assertThat(status.required).isTrue() + assertThat(status.options).containsExactly("open", "done").inOrder() + } + + @Test + fun `toDsl drops columns with a blank key`() { + val schema = ListSchema( + listOf( + SchemaField("kept", "Kept", FieldType.TEXT), + SchemaField("", "Ignored", FieldType.TEXT), + ), + ) + + val reparsed = SchemaMapper.fromJson(SchemaMapper.toDsl(schema)) + + assertThat(reparsed.fields.map { it.key }).containsExactly("kept") + } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapperTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapperTest.kt new file mode 100644 index 0000000..0cd50b5 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/WatcherMapperTest.kt @@ -0,0 +1,58 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.lists.data.remote.dto.WatcherDto +import com.interlinedlist.android.feature.lists.data.remote.dto.WatcherUserDto +import com.interlinedlist.android.feature.lists.domain.WatcherRole +import org.junit.Test + +/** + * Watcher rows arrive flattened or nested and with varied role strings; the mapper + * tolerates both shapes and normalises the role so a watcher is never dropped. + */ +class WatcherMapperTest { + + @Test + fun `maps a flattened watcher row`() { + val watcher = WatcherMapper.watcherFromDto( + WatcherDto(userId = "u1", username = "ada", displayName = "Ada", role = "editor"), + ) + + assertThat(watcher).isNotNull() + assertThat(watcher!!.userId).isEqualTo("u1") + assertThat(watcher.username).isEqualTo("ada") + assertThat(watcher.label).isEqualTo("Ada") + assertThat(watcher.role).isEqualTo(WatcherRole.EDITOR) + } + + @Test + fun `maps a nested user object and defaults an unknown role to viewer`() { + val watcher = WatcherMapper.watcherFromDto( + WatcherDto( + role = "wizard", + user = WatcherUserDto(id = "u2", username = "grace", displayName = null), + ), + ) + + assertThat(watcher!!.userId).isEqualTo("u2") + assertThat(watcher.username).isEqualTo("grace") + // No display name → the row labels by username. + assertThat(watcher.label).isEqualTo("grace") + assertThat(watcher.role).isEqualTo(WatcherRole.VIEWER) + } + + @Test + fun `returns null when no user id can be resolved`() { + assertThat(WatcherMapper.watcherFromDto(WatcherDto(role = "viewer"))).isNull() + } + + @Test + fun `maps a candidate user`() { + val candidate = WatcherMapper.candidateFromDto( + WatcherUserDto(id = "u3", username = "linus", displayName = "Linus"), + ) + + assertThat(candidate.userId).isEqualTo("u3") + assertThat(candidate.label).isEqualTo("Linus") + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModelTest.kt new file mode 100644 index 0000000..cb1f688 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/connections/ConnectionsViewModelTest.kt @@ -0,0 +1,106 @@ +package com.interlinedlist.android.feature.lists.ui.connections + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.ListConnection +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.Paged +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectionsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun connection(id: String) = ListConnection(id, "l1", "l2", null, "L1", "L2") + private fun summary(id: String) = ListSummary(id, "List $id", null, 0, null, false, null) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads connections and lists on init`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + connectionsResult = ApiResult.Success(listOf(connection("c1"))) + refreshResult = ApiResult.Success( + Paged(listOf(summary("l1"), summary("l2")), hasMore = false, total = 2, offset = 2), + ) + } + val vm = ConnectionsViewModel(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.connections.map { it.id }).containsExactly("c1") + assertThat(state.lists.map { it.id }).containsExactly("l1", "l2").inOrder() + assertThat(state.canCreate).isTrue() + } + + @Test + fun `createConnection appends the created edge`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + createConnectionResult = ApiResult.Success(connection("c-new")) + } + val vm = ConnectionsViewModel(repo) + advanceUntilIdle() + + var done = false + vm.createConnection("l1", "l2", "blocks") { done = true } + advanceUntilIdle() + + assertThat(done).isTrue() + assertThat(vm.uiState.value.connections.map { it.id }).contains("c-new") + assertThat(vm.uiState.value.isSaving).isFalse() + } + + @Test + fun `createConnection is rejected when endpoints match`() = runTest(dispatcher) { + val repo = FakeListsRepository() + val vm = ConnectionsViewModel(repo) + advanceUntilIdle() + + vm.createConnection("l1", "l1", null) + advanceUntilIdle() + + assertThat(vm.uiState.value.connections).isEmpty() + } + + @Test + fun `deleteConnection removes the edge from state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + connectionsResult = ApiResult.Success(listOf(connection("c1"), connection("c2"))) + } + val vm = ConnectionsViewModel(repo) + advanceUntilIdle() + + vm.deleteConnection("c1") + advanceUntilIdle() + + assertThat(vm.uiState.value.connections.map { it.id }).containsExactly("c2") + } + + @Test + fun `load failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + connectionsResult = ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.Network("offline"), + ) + } + val vm = ConnectionsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.isLoading).isFalse() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt index 7f846c7..724a994 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListRow import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.RefreshResult import com.interlinedlist.android.feature.lists.domain.SchemaField import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -133,4 +134,43 @@ class ListDetailViewModelTest { assertThat(vm.uiState.value.subscriptionRequired).isTrue() assertThat(vm.uiState.value.isLoading).isFalse() } + + @Test + fun `refreshFromGithub surfaces a summary and reloads the rows`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(emptyList())) + refreshGithubResult = ApiResult.Success( + RefreshResult(message = null, added = 2, updated = 0, removed = 0), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + // After the refresh, the reload returns freshly-synced rows. + repo.detailResult = ApiResult.Success(detail(listOf(ListRow("r1", mapOf("title" to "Synced"))))) + vm.refreshFromGithub() + advanceUntilIdle() + + assertThat(repo.refreshGithubCount).isEqualTo(1) + assertThat(vm.uiState.value.isRefreshing).isFalse() + assertThat(vm.uiState.value.refreshMessage).isEqualTo("2 added") + assertThat(vm.uiState.value.rows.single().valueFor("title")).isEqualTo("Synced") + } + + @Test + fun `refreshFromGithub failure surfaces an error and clears the spinner`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(emptyList())) + refreshGithubResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.refreshFromGithub() + advanceUntilIdle() + + assertThat(vm.uiState.value.isRefreshing).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.refreshMessage).isNull() + } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt new file mode 100644 index 0000000..6fe54ff --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/schema/SchemaEditorViewModelTest.kt @@ -0,0 +1,132 @@ +package com.interlinedlist.android.feature.lists.ui.schema + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.FieldType +import com.interlinedlist.android.feature.lists.domain.ListDetail +import com.interlinedlist.android.feature.lists.domain.ListSchema +import com.interlinedlist.android.feature.lists.domain.ListSummary +import com.interlinedlist.android.feature.lists.domain.SchemaField +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SchemaEditorViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun detail(schema: ListSchema) = ListDetail( + summary = ListSummary("L1", "Reading", null, 0, null, false, null), + schema = schema, + rows = emptyList(), + ) + + private fun viewModel(repo: FakeListsRepository) = + SchemaEditorViewModel(repo, SavedStateHandle(mapOf(SCHEMA_LIST_ID_ARG to "L1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads existing columns from the list schema`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success( + detail(ListSchema(listOf(SchemaField("title", "Title", FieldType.TEXT)))), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.columns.map { it.key }).containsExactly("title") + } + + @Test + fun `add edit and remove columns update the editable state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(detail(ListSchema.EMPTY)) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.addColumn() + val added = vm.uiState.value.columns.single() + vm.updateKey(added.uiId, "pages") + vm.updateLabel(added.uiId, "Pages") + vm.updateType(added.uiId, FieldType.NUMBER) + + val edited = vm.uiState.value.columns.single() + assertThat(edited.key).isEqualTo("pages") + assertThat(edited.label).isEqualTo("Pages") + assertThat(edited.type).isEqualTo(FieldType.NUMBER) + assertThat(vm.uiState.value.canSave).isTrue() + + vm.removeColumn(edited.uiId) + assertThat(vm.uiState.value.columns).isEmpty() + assertThat(vm.uiState.value.canSave).isFalse() + } + + @Test + fun `save sends the edited schema and flags saved`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(detail(ListSchema.EMPTY)) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.addColumn() + val id = vm.uiState.value.columns.single().uiId + vm.updateKey(id, "name") + + var saved = false + vm.save { saved = true } + advanceUntilIdle() + + assertThat(saved).isTrue() + assertThat(vm.uiState.value.saved).isTrue() + assertThat(repo.updateSchemaCount).isEqualTo(1) + // Incomplete rows are dropped; only the keyed column is persisted. + assertThat(repo.lastSchemaUpdate!!.fields.map { it.key }).containsExactly("name") + } + + @Test + fun `save is a no-op when no column has a key`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(detail(ListSchema.EMPTY)) } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.addColumn() // key still blank + vm.save() + advanceUntilIdle() + + assertThat(repo.updateSchemaCount).isEqualTo(0) + assertThat(vm.uiState.value.saved).isFalse() + } + + @Test + fun `save failure surfaces an error and does not flag saved`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(ListSchema.EMPTY)) + updateSchemaResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.addColumn() + vm.updateKey(vm.uiState.value.columns.single().uiId, "name") + vm.save() + advanceUntilIdle() + + assertThat(vm.uiState.value.saved).isFalse() + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt new file mode 100644 index 0000000..edb983f --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt @@ -0,0 +1,133 @@ +package com.interlinedlist.android.feature.lists.ui.watchers + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.Watcher +import com.interlinedlist.android.feature.lists.domain.WatcherCandidate +import com.interlinedlist.android.feature.lists.domain.WatcherRole +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class WatchersViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun watcher(id: String, role: WatcherRole = WatcherRole.VIEWER) = + Watcher(id, "user$id", "User $id", null, role) + + private fun viewModel(repo: FakeListsRepository) = + WatchersViewModel(repo, SavedStateHandle(mapOf(WATCHERS_LIST_ID_ARG to "L1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads watchers and watching status on init`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + watchersResult = ApiResult.Success(listOf(watcher("1"), watcher("2"))) + isWatchingResult = ApiResult.Success(true) + } + val vm = viewModel(repo) + + vm.uiState.test { + awaitItem() // initial + advanceUntilIdle() + val loaded = expectMostRecentItem() + assertThat(loaded.isLoading).isFalse() + assertThat(loaded.watchers.map { it.userId }).containsExactly("1", "2").inOrder() + assertThat(loaded.isWatching).isTrue() + } + } + + @Test + fun `search surfaces candidate users and clearing empties them`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + candidatesResult = ApiResult.Success(listOf(WatcherCandidate("u9", "linus", "Linus", null))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onSearchQueryChange("lin") + advanceUntilIdle() + assertThat(vm.uiState.value.candidates.map { it.userId }).containsExactly("u9") + assertThat(repo.lastWatcherSearch).isEqualTo("lin") + + vm.onSearchQueryChange("") + advanceUntilIdle() + assertThat(vm.uiState.value.candidates).isEmpty() + } + + @Test + fun `adding a candidate clears the search and reloads watchers`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + watchersResult = ApiResult.Success(listOf(watcher("1"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + // After add, load() returns the (now larger) watcher set. + repo.watchersResult = ApiResult.Success(listOf(watcher("1"), watcher("u9"))) + vm.addWatcher(WatcherCandidate("u9", "linus", null, null)) + advanceUntilIdle() + + assertThat(repo.addWatcherCount).isEqualTo(1) + assertThat(vm.uiState.value.searchQuery).isEmpty() + assertThat(vm.uiState.value.watchers.map { it.userId }).containsExactly("1", "u9").inOrder() + } + + @Test + fun `changeRole updates the matching watcher optimistically`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + watchersResult = ApiResult.Success(listOf(watcher("1", WatcherRole.VIEWER))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.changeRole(vm.uiState.value.watchers.single(), WatcherRole.EDITOR) + advanceUntilIdle() + + assertThat(vm.uiState.value.watchers.single().role).isEqualTo(WatcherRole.EDITOR) + } + + @Test + fun `removeWatcher drops the watcher from state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + watchersResult = ApiResult.Success(listOf(watcher("1"), watcher("2"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.removeWatcher(vm.uiState.value.watchers.first { it.userId == "1" }) + advanceUntilIdle() + + assertThat(repo.removeWatcherCount).isEqualTo(1) + assertThat(vm.uiState.value.watchers.map { it.userId }).containsExactly("2") + } + + @Test + fun `load failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + watchersResult = ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.Network("offline"), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.isLoading).isFalse() + } +} diff --git a/feature/messages/build.gradle.kts b/feature/messages/build.gradle.kts index c7ff2ba..69805ac 100644 --- a/feature/messages/build.gradle.kts +++ b/feature/messages/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + // Media picker (rememberLauncherForActivityResult). + implementation(libs.androidx.activity.compose) implementation(libs.hilt.android) ksp(libs.hilt.compiler) @@ -48,6 +50,8 @@ dependencies { // Networking (DTOs are serialized via the shared Retrofit/Json). implementation(libs.retrofit.core) implementation(libs.kotlinx.serialization.json) + // Multipart bodies for image/video uploads. + implementation(libs.okhttp.core) // This module owns its own Room cache (does not touch :core:database). implementation(libs.room.runtime) diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt index a8392c6..7edf96a 100644 --- a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt @@ -11,6 +11,9 @@ import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.components.MessageCardTags +import com.interlinedlist.android.feature.messages.ui.components.MessageMediaTags +import com.interlinedlist.android.feature.messages.ui.components.ReportDialogTags import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -21,16 +24,22 @@ class MessagesFeedScreenTest { @get:Rule val composeRule = createComposeRule() - private fun message(id: String, body: String) = Message( + private fun message( + id: String, + body: String, + imageUrls: List = emptyList(), + ) = Message( id = id, content = body, authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = false, + imageUrls = imageUrls, ) /** Hosts the stateless feed with a tiny in-memory state holder. */ private fun setFeed( initial: MessagesFeedUiState, onOpenMessage: (String) -> Unit = {}, + onReport: (Message) -> Unit = {}, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -46,6 +55,7 @@ class MessagesFeedScreenTest { onDismissCompose = { state = state.copy(isComposeOpen = false) }, onComposeTextChange = { state = state.copy(composeText = it) }, onPost = {}, + onReport = onReport, ) } } @@ -86,4 +96,38 @@ class MessagesFeedScreenTest { composeRule.onNodeWithTag(MessagesFeedTags.FAB).performClick() composeRule.onNodeWithTag(MessagesFeedTags.COMPOSE_INPUT).assertIsDisplayed() } + + @Test + fun attachedImage_isRendered() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "with photo", imageUrls = listOf("https://cdn/a.png"))), + ), + ) + composeRule.onNodeWithTag(MessageMediaTags.IMAGE).assertIsDisplayed() + } + + @Test + fun overflowMenu_reportsAnotherUsersMessage() { + var reported: String? = null + setFeed( + MessagesFeedUiState(messages = listOf(message("77", "not mine"))), + onReport = { reported = it.id }, + ) + composeRule.onNodeWithTag(MessageCardTags.MENU).performClick() + composeRule.onNodeWithTag(MessageCardTags.REPORT).performClick() + assert(reported == "77") + } + + @Test + fun reportDialog_isShown_whenReportTargetIsSet() { + setFeed(MessagesFeedUiState(reportTarget = message("77", "not mine"))) + composeRule.onNodeWithTag(ReportDialogTags.DIALOG).assertIsDisplayed() + } + + @Test + fun scheduledAction_isPresent_inTheTopBar() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "hi")))) + composeRule.onNodeWithTag(MessagesFeedTags.SCHEDULED_ACTION).assertIsDisplayed() + } } diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreenTest.kt new file mode 100644 index 0000000..ff71e9e --- /dev/null +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreenTest.kt @@ -0,0 +1,72 @@ +package com.interlinedlist.android.feature.messages.ui.scheduled + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScheduledMessagesScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun scheduled(id: String, body: String) = Message( + id = id, content = body, authorId = "u1", authorUsername = "adron", + authorDisplayName = "Adron", authorAvatarUrl = null, createdAt = null, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, mine = true, + scheduledAt = "2026-07-19T09:00:00Z", + ) + + private fun setScreen( + state: ScheduledUiState, + onCancel: (Message) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + ScheduledMessagesScreen( + state = state, + onBack = {}, + onRefresh = {}, + onCancel = onCancel, + ) + } + } + } + + @Test + fun emptyState_isShown_whenThereAreNoScheduledMessages() { + setScreen(ScheduledUiState(messages = emptyList())) + composeRule.onNodeWithTag(ScheduledMessagesTags.EMPTY).assertIsDisplayed() + } + + @Test + fun scheduledMessages_areRendered() { + setScreen(ScheduledUiState(messages = listOf(scheduled("1", "Goes out tomorrow")))) + composeRule.onNodeWithText("Goes out tomorrow").assertIsDisplayed() + } + + @Test + fun cancel_invokesCallbackWithMessage() { + var cancelled: String? = null + setScreen( + ScheduledUiState(messages = listOf(scheduled("42", "Cancel me"))), + onCancel = { cancelled = it.id }, + ) + composeRule.onNodeWithTag(ScheduledMessagesTags.CANCEL).performClick() + assert(cancelled == "42") + } + + @Test + fun lockedState_isShown_whenSubscriptionRequired() { + setScreen(ScheduledUiState(subscriptionRequired = true, errorMessage = "Subscribers only")) + composeRule.onNodeWithTag(ScheduledMessagesTags.LOCKED).assertIsDisplayed() + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index f654926..a08ef26 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -9,14 +9,19 @@ import com.interlinedlist.android.feature.messages.data.local.toEntity import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto +import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.toDomain import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody import javax.inject.Inject class DefaultMessagesRepository @Inject constructor( @@ -36,6 +41,9 @@ class DefaultMessagesRepository @Inject constructor( override fun observeMessage(messageId: String): Flow = messageDao.observeMessage(messageId).map { it?.toDomain() } + override fun observeScheduled(): Flow> = + messageDao.observeScheduled().map { rows -> rows.map { it.toDomain() } } + override suspend fun refreshFeed(): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { is ApiResult.Success -> { @@ -68,19 +76,51 @@ class DefaultMessagesRepository @Inject constructor( } } - override suspend fun createMessage(content: String): ApiResult = withContext(dispatchers.io) { - when (val result = safeCall { api.createMessage(CreateMessageRequest(content = content)) }) { + override suspend fun createMessage( + content: String, + imageUrls: List, + videoUrls: List, + scheduledAt: String?, + ): ApiResult = withContext(dispatchers.io) { + val request = CreateMessageRequest( + content = content, + imageUrls = imageUrls.ifEmpty { null }, + videoUrls = videoUrls.ifEmpty { null }, + scheduledAt = scheduledAt, + ) + when (val result = safeCall { api.createMessage(request) }) { is ApiResult.Success -> { val message = result.data.message.toDomain(currentUserId()) - // Insert at the very top of the feed. - val topOrder = (messageDao.maxFeedOrder() ?: 0L) - messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L)) + if (message.scheduledAt != null) { + // Scheduled messages are cached in the scheduled view, not the feed. + messageDao.upsert(message.toEntity(feedOrder = 0L)) + } else { + // Insert at the very top of the feed. + val topOrder = (messageDao.maxFeedOrder() ?: 0L) + messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L)) + } ApiResult.Success(message) } is ApiResult.Failure -> result } } + override suspend fun uploadImage( + bytes: ByteArray, + fileName: String, + mimeType: String, + ): ApiResult = withContext(dispatchers.io) { + upload(bytes, fileName, mimeType) { api.uploadImage(it) } + } + + override suspend fun uploadVideo( + bytes: ByteArray, + fileName: String, + mimeType: String, + ): ApiResult = withContext(dispatchers.io) { + upload(bytes, fileName, mimeType) { api.uploadVideo(it) } + } + override suspend fun fetchMessage(messageId: String): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getMessage(messageId) }) { is ApiResult.Success -> { @@ -155,6 +195,78 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun refreshScheduled(): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.getScheduled() }) { + is ApiResult.Success -> { + val entities = result.data.data.mapIndexed { index, dto -> + dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong()) + } + messageDao.clearScheduled() + messageDao.insertAll(entities) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun cancelScheduled(messageId: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.deleteMessage(messageId) }) { + is ApiResult.Success -> { + messageDao.deleteById(messageId) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun report( + messageId: String, + reason: ReportReason, + detail: String?, + ): ApiResult = withContext(dispatchers.io) { + safeCall { + api.report( + id = messageId, + body = ReportRequest(reason = reason.wireValue, detail = detail?.takeIf { it.isNotBlank() }), + ) + } + } + + override suspend fun fetchMetadata(messageId: String): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.fetchMetadata(messageId) }) { + is ApiResult.Success -> { + val body = result.data + val preview = (body.message?.linkMetadata ?: body.linkMetadata)?.toDomain() + val existing = currentEntity(messageId) + val updated = when { + // Prefer the fully-formed message the endpoint may echo back. + body.message != null -> body.message.toDomain(currentUserId()) + .let { fresh -> + existing?.toDomain()?.copy( + linkPreview = fresh.linkPreview ?: preview, + ) ?: fresh + } + existing != null -> existing.toDomain().copy(linkPreview = preview) + else -> null + } + if (updated != null) { + messageDao.upsert(updated.toEntity(feedOrder = existingOrderOrTop(messageId))) + ApiResult.Success(updated) + } else { + ApiResult.Success( + Message( + id = messageId, content = "", authorId = "", authorUsername = "", + authorDisplayName = null, authorAvatarUrl = null, createdAt = null, + digCount = 0, replyCount = 0, dugByMe = false, parentId = null, + mine = false, linkPreview = preview, + ), + ) + } + } + is ApiResult.Failure -> result + } + } + override suspend fun search(query: String): ApiResult> = withContext(dispatchers.io) { when (val result = safeCall { api.search(query = query, limit = PaginationDto.DEFAULT_LIMIT, offset = 0) @@ -172,6 +284,35 @@ class DefaultMessagesRepository @Inject constructor( private fun currentUserId(): String? = sessionStore.userId + /** Shared multipart upload path; extracts the hosted URL from the response. */ + private suspend fun upload( + bytes: ByteArray, + fileName: String, + mimeType: String, + call: suspend (MultipartBody.Part) -> com.interlinedlist.android.feature.messages.data.remote.dto.MediaUploadResponse, + ): ApiResult { + val part = MultipartBody.Part.createFormData( + name = "file", + filename = fileName, + body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()), + ) + return when (val result = safeCall { call(part) }) { + is ApiResult.Success -> { + val url = result.data.hostedUrl + if (url.isNullOrBlank()) { + ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.Server( + "Upload succeeded but no media URL was returned.", + ), + ) + } else { + ApiResult.Success(url) + } + } + is ApiResult.Failure -> result + } + } + /** Current cached row for [id], or null. Snapshots the observe Flow. */ private suspend fun currentEntity(id: String) = messageDao.observeMessage(id).first() diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt index 08b041d..c5986f0 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.messages.data import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import kotlinx.coroutines.flow.Flow /** @@ -20,6 +21,9 @@ interface MessagesRepository { /** A single cached message (or null), re-emitting on change. */ fun observeMessage(messageId: String): Flow + /** Cached scheduled (not-yet-published) messages, soonest-first. */ + fun observeScheduled(): Flow> + /** * Refreshes the first page of the feed from the API and replaces the cached * feed. Returns whether more pages are available. @@ -32,8 +36,23 @@ interface MessagesRepository { */ suspend fun loadMoreFeed(currentCount: Int): ApiResult - /** Creates a new top-level message and caches it. */ - suspend fun createMessage(content: String): ApiResult + /** + * Creates a new top-level message and caches it. Optionally attaches already + * uploaded [imageUrls] / [videoUrls] and defers publishing to [scheduledAt] + * (ISO-8601). A scheduled message does not enter the feed cache. + */ + suspend fun createMessage( + content: String, + imageUrls: List = emptyList(), + videoUrls: List = emptyList(), + scheduledAt: String? = null, + ): ApiResult + + /** Uploads image [bytes] and returns the hosted URL to attach on compose. */ + suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult + + /** Uploads video [bytes] and returns the hosted URL to attach on compose. */ + suspend fun uploadVideo(bytes: ByteArray, fileName: String, mimeType: String): ApiResult /** Fetches a single message and caches it (for the detail screen). */ suspend fun fetchMessage(messageId: String): ApiResult @@ -50,6 +69,21 @@ interface MessagesRepository { /** Deletes one of the caller's own messages, removing it from the cache. */ suspend fun deleteMessage(messageId: String): ApiResult + /** Refreshes the caller's scheduled messages from the API into the cache. */ + suspend fun refreshScheduled(): ApiResult + + /** Cancels a scheduled message (deletes it), removing it from the cache. */ + suspend fun cancelScheduled(messageId: String): ApiResult + + /** Reports a message with a [reason] and optional free-text [detail]. */ + suspend fun report(messageId: String, reason: ReportReason, detail: String? = null): ApiResult + + /** + * Fetches link-preview metadata for [messageId]'s links and updates the cached + * message so the feed/detail can render a preview card. + */ + suspend fun fetchMetadata(messageId: String): ApiResult + /** Full-text search over top-level messages (does not touch the feed cache). */ suspend fun search(query: String): ApiResult> } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt new file mode 100644 index 0000000..c432702 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageConverters.kt @@ -0,0 +1,57 @@ +package com.interlinedlist.android.feature.messages.data.local + +import androidx.room.TypeConverter +import com.interlinedlist.android.feature.messages.domain.LinkPreview +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json + +/** + * Room [TypeConverter]s for the message table's composite columns. Media URL lists + * and the link-preview card are stored as JSON strings so a single flat table can + * still cache the richer message shape without extra tables/joins. + */ +class MessageConverters { + + @TypeConverter + fun stringListToJson(value: List): String = + json.encodeToString(ListSerializer(String.serializer()), value) + + @TypeConverter + fun jsonToStringList(value: String?): List = + if (value.isNullOrBlank()) emptyList() + else runCatching { json.decodeFromString(ListSerializer(String.serializer()), value) } + .getOrDefault(emptyList()) + + @TypeConverter + fun linkPreviewToJson(value: LinkPreview?): String? = + value?.let { json.encodeToString(LinkPreviewSurrogate.serializer(), it.toSurrogate()) } + + @TypeConverter + fun jsonToLinkPreview(value: String?): LinkPreview? = + if (value.isNullOrBlank()) null + else runCatching { + json.decodeFromString(LinkPreviewSurrogate.serializer(), value).toDomain() + }.getOrNull() + + private companion object { + val json = Json { ignoreUnknownKeys = true } + } +} + +/** + * Serializable mirror of the domain [LinkPreview] so the domain type can stay a + * plain data class (no serialization annotations leaking into the domain layer). + */ +@Serializable +private data class LinkPreviewSurrogate( + val url: String, + val title: String? = null, + val description: String? = null, + val imageUrl: String? = null, + val siteName: String? = null, +) + +private fun LinkPreview.toSurrogate() = LinkPreviewSurrogate(url, title, description, imageUrl, siteName) +private fun LinkPreviewSurrogate.toDomain() = LinkPreview(url, title, description, imageUrl, siteName) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt index 07beede..943547f 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt @@ -10,8 +10,12 @@ import kotlinx.coroutines.flow.Flow @Dao interface MessageDao { - /** Top-level feed messages in server order; re-emits on every change. */ - @Query("SELECT * FROM message WHERE parentId IS NULL ORDER BY feedOrder ASC") + /** + * Top-level feed messages in server order; re-emits on every change. + * Scheduled (not-yet-published) messages are excluded — they live in their + * own view, not the public feed. + */ + @Query("SELECT * FROM message WHERE parentId IS NULL AND scheduledAt IS NULL ORDER BY feedOrder ASC") fun observeFeed(): Flow> /** Direct replies to a message in server order. */ @@ -22,6 +26,10 @@ interface MessageDao { @Query("SELECT * FROM message WHERE id = :id") fun observeMessage(id: String): Flow + /** Cached scheduled messages, soonest first; re-emits on every change. */ + @Query("SELECT * FROM message WHERE scheduledAt IS NOT NULL ORDER BY scheduledAt ASC") + fun observeScheduled(): Flow> + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(messages: List) @@ -32,10 +40,14 @@ interface MessageDao { suspend fun deleteById(id: String) /** Clears the top-level feed (used before writing a fresh refresh page). */ - @Query("DELETE FROM message WHERE parentId IS NULL") + @Query("DELETE FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") suspend fun clearFeed() + /** Clears the cached scheduled messages (used before a fresh refresh). */ + @Query("DELETE FROM message WHERE scheduledAt IS NOT NULL") + suspend fun clearScheduled() + /** Largest feed-order position currently stored (for append/load-more). */ - @Query("SELECT MAX(feedOrder) FROM message WHERE parentId IS NULL") + @Query("SELECT MAX(feedOrder) FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") suspend fun maxFeedOrder(): Long? } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt index 4f1f94c..3d81d1c 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.messages.data.local import androidx.room.Entity import androidx.room.PrimaryKey +import com.interlinedlist.android.feature.messages.domain.LinkPreview import com.interlinedlist.android.feature.messages.domain.Message /** @@ -25,6 +26,14 @@ data class MessageEntity( val mine: Boolean, /** Server-relative ordering position captured at fetch time (feed order). */ val feedOrder: Long, + /** Attached image URLs, stored via [MessageConverters]. */ + val imageUrls: List = emptyList(), + /** Attached video URLs, stored via [MessageConverters]. */ + val videoUrls: List = emptyList(), + /** Link-preview card, stored via [MessageConverters]; null when none. */ + val linkPreview: LinkPreview? = null, + /** Future send time for a scheduled message; null for a normal message. */ + val scheduledAt: String? = null, ) fun MessageEntity.toDomain(): Message = Message( @@ -40,6 +49,10 @@ fun MessageEntity.toDomain(): Message = Message( dugByMe = dugByMe, parentId = parentId, mine = mine, + imageUrls = imageUrls, + videoUrls = videoUrls, + linkPreview = linkPreview, + scheduledAt = scheduledAt, ) fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( @@ -56,4 +69,8 @@ fun Message.toEntity(feedOrder: Long): MessageEntity = MessageEntity( parentId = parentId, mine = mine, feedOrder = feedOrder, + imageUrls = imageUrls, + videoUrls = videoUrls, + linkPreview = linkPreview, + scheduledAt = scheduledAt, ) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt index 93f58dc..a938405 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.messages.data.local import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.TypeConverters /** * This feature module's own Room cache, separate from `:core:database`'s @@ -9,9 +10,10 @@ import androidx.room.RoomDatabase */ @Database( entities = [MessageEntity::class], - version = 1, + version = 2, exportSchema = false, ) +@TypeConverters(MessageConverters::class) abstract class MessagesDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt index ce8402b..106bd1a 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -1,12 +1,19 @@ package com.interlinedlist.android.feature.messages.data.remote import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.MediaUploadResponse import com.interlinedlist.android.feature.messages.data.remote.dto.MessageResponse import com.interlinedlist.android.feature.messages.data.remote.dto.MessagesResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.MetadataResponse +import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.ScheduledMessagesResponse +import okhttp3.MultipartBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Multipart import retrofit2.http.POST +import retrofit2.http.Part import retrofit2.http.Path import retrofit2.http.Query @@ -55,4 +62,26 @@ interface MessagesApi { @Query("limit") limit: Int, @Query("offset") offset: Int, ): MessagesResponse + + /** Uploads an image and returns its hosted URL to attach on compose. */ + @Multipart + @POST("api/messages/images/upload") + suspend fun uploadImage(@Part file: MultipartBody.Part): MediaUploadResponse + + /** Uploads a video and returns its hosted URL to attach on compose. */ + @Multipart + @POST("api/messages/videos/upload") + suspend fun uploadVideo(@Part file: MultipartBody.Part): MediaUploadResponse + + /** The caller's scheduled (not-yet-published) messages. */ + @GET("api/messages/scheduled") + suspend fun getScheduled(): ScheduledMessagesResponse + + /** Reports a message with a reason (and optional free-text detail). */ + @POST("api/messages/{id}/report") + suspend fun report(@Path("id") id: String, @Body body: ReportRequest) + + /** Fetches and attaches link-preview metadata for a message's links. */ + @POST("api/messages/{id}/metadata") + suspend fun fetchMetadata(@Path("id") id: String): MetadataResponse } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt index 71c122a..1ea8366 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDto.kt @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.messages.data.remote.dto +import com.interlinedlist.android.feature.messages.domain.LinkPreview import com.interlinedlist.android.feature.messages.domain.Message import kotlinx.serialization.Serializable @@ -9,7 +10,7 @@ import kotlinx.serialization.Serializable * The OpenAPI extract does not pin the response schema, so this mirrors the web * feed's shape: an author sub-object plus body/timestamp/engagement fields. The * shared [kotlinx.serialization.json.Json] is configured with `ignoreUnknownKeys`, - * so extra fields (crossposting, metadata, media, …) are tolerated and dropped. + * so extra fields (crossposting, …) are tolerated and dropped. */ @Serializable data class MessageDto( @@ -23,6 +24,13 @@ data class MessageDto( val parentId: String? = null, /** Present on some payloads; used to flag the message as the caller's own. */ val isOwn: Boolean = false, + /** Attached media (uploaded via the image/video upload endpoints). */ + val imageUrls: List = emptyList(), + val videoUrls: List = emptyList(), + /** Fetched link-preview metadata for the first URL in the body, if any. */ + val linkMetadata: LinkMetadataDto? = null, + /** Future send time for a scheduled message; null once published. */ + val scheduledAt: String? = null, ) /** Author identity embedded in a message. */ @@ -34,6 +42,19 @@ data class MessageAuthorDto( val avatar: String? = null, ) +/** + * Link-preview metadata attached to a message. Populated by the metadata endpoint; + * mirrors the OpenGraph-style fields the web feed renders in its preview card. + */ +@Serializable +data class LinkMetadataDto( + val url: String? = null, + val title: String? = null, + val description: String? = null, + val image: String? = null, + val siteName: String? = null, +) + /** * Maps the wire model into the domain [Message]. [currentUserId] lets us flag * the caller's own messages (for delete) even when the API omits `isOwn`. @@ -51,4 +72,22 @@ fun MessageDto.toDomain(currentUserId: String?): Message = Message( dugByMe = dugByCurrentUser, parentId = parentId, mine = isOwn || (currentUserId != null && author?.id == currentUserId), + imageUrls = imageUrls, + videoUrls = videoUrls, + linkPreview = linkMetadata?.toDomain(), + scheduledAt = scheduledAt, ) + +/** Maps link-preview metadata into the domain, dropping empty previews. */ +fun LinkMetadataDto.toDomain(): LinkPreview? { + val link = url?.takeIf { it.isNotBlank() } ?: return null + // A preview with no title/description/image carries no useful content. + if (title.isNullOrBlank() && description.isNullOrBlank() && image.isNullOrBlank()) return null + return LinkPreview( + url = link, + title = title, + description = description, + imageUrl = image, + siteName = siteName, + ) +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt index 1189652..918b392 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessagesResponse.kt @@ -31,9 +31,61 @@ data class MessageResponse( val message: MessageDto, ) -/** Request body for creating a message or posting a reply. */ +/** + * Request body for creating a message or posting a reply. + * + * [imageUrls] / [videoUrls] carry media previously uploaded via the upload + * endpoints, and [scheduledAt] (ISO-8601) defers publishing to a future time. + * Only non-null fields are serialised (the shared Json uses `explicitNulls = + * false`), so a plain post still sends just `{ content }`. + */ @Serializable data class CreateMessageRequest( val content: String, val parentId: String? = null, + val imageUrls: List? = null, + val videoUrls: List? = null, + val scheduledAt: String? = null, +) + +/** + * Response from the image/video upload endpoints. The API returns the hosted URL + * of the stored media under one of a few common keys; all are optional so the + * repository can pick whichever the server populated. + */ +@Serializable +data class MediaUploadResponse( + val url: String? = null, + val imageUrl: String? = null, + val videoUrl: String? = null, +) { + /** The hosted media URL, whichever field the server used. */ + val hostedUrl: String? get() = url ?: imageUrl ?: videoUrl +} + +/** + * Response from the scheduled-messages endpoint. May be a bare list or wrapped in + * a `data` envelope depending on the server; the repository reads [messages]. + */ +@Serializable +data class ScheduledMessagesResponse( + val data: List = emptyList(), +) + +/** Request body for reporting a message: `{ reason, detail? }`. */ +@Serializable +data class ReportRequest( + val reason: String, + val detail: String? = null, +) + +/** + * Response from the metadata endpoint. The updated message (with its populated + * `linkMetadata`) is returned either at the top level or under `message`. + */ +@Serializable +data class MetadataResponse( + val message: MessageDto? = null, + val id: String? = null, + val linkMetadata: LinkMetadataDto? = null, ) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt index 52bb355..a7348f1 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/Message.kt @@ -23,7 +23,34 @@ data class Message( val parentId: String?, /** True when this message belongs to the signed-in user (enables delete). */ val mine: Boolean, + /** Attached image URLs, rendered inline in the feed/detail. */ + val imageUrls: List = emptyList(), + /** Attached video URLs, rendered as a tappable thumbnail/placeholder. */ + val videoUrls: List = emptyList(), + /** Link-preview card built from fetched metadata, when present. */ + val linkPreview: LinkPreview? = null, + /** + * ISO-8601 send time for a scheduled (not-yet-published) message; null for a + * normal message. Present on rows returned by the scheduled endpoint. + */ + val scheduledAt: String? = null, ) { /** Best available display label for the author. */ val authorLabel: String get() = authorDisplayName?.takeIf { it.isNotBlank() } ?: authorUsername + + /** True when any image or video media is attached. */ + val hasMedia: Boolean get() = imageUrls.isNotEmpty() || videoUrls.isNotEmpty() } + +/** + * Link-preview metadata for the first URL found in a message, fetched via the + * metadata endpoint and rendered as a card. All fields are best-effort; a preview + * is only shown when at least a [url] and a [title] are available. + */ +data class LinkPreview( + val url: String, + val title: String?, + val description: String?, + val imageUrl: String?, + val siteName: String?, +) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/ReportReason.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/ReportReason.kt new file mode 100644 index 0000000..54dc9fa --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/domain/ReportReason.kt @@ -0,0 +1,16 @@ +package com.interlinedlist.android.feature.messages.domain + +/** + * The reason a message is being reported. Mirrors the web app's `ReportReason` + * union; [wireValue] is the exact string the report endpoint expects, and [label] + * is the human-readable option shown in the report dialog. + */ +enum class ReportReason(val wireValue: String, val label: String) { + SPAM("spam", "Spam"), + HARASSMENT("harassment", "Harassment or bullying"), + HATE_SPEECH("hate_speech", "Hate speech"), + MISINFORMATION("misinformation", "Misinformation"), + SEXUAL_CONTENT("sexual_content", "Sexual content"), + VIOLENCE("violence", "Violence or threats"), + OTHER("other", "Something else"), +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MediaReader.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MediaReader.kt new file mode 100644 index 0000000..0a076e5 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/MediaReader.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.messages.ui + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns + +/** A picked media file read into memory, ready to upload. */ +data class PickedMedia( + val bytes: ByteArray, + val fileName: String, + val mimeType: String, +) + +/** + * Reads the bytes and display name of a picked media [uri] via the platform + * [android.content.ContentResolver]. Returns null when the URI can't be opened, so + * the caller can quietly skip an unreadable pick. Kept in the UI layer so the + * ViewModel/repository stay free of Android URI/ContentResolver dependencies. + */ +fun readMediaBytes(context: Context, uri: Uri, isVideo: Boolean): PickedMedia? { + val resolver = context.contentResolver + val bytes = runCatching { + resolver.openInputStream(uri)?.use { it.readBytes() } + }.getOrNull() ?: return null + + val mimeType = resolver.getType(uri) ?: if (isVideo) "video/*" else "image/*" + val fallbackName = if (isVideo) "video" else "image" + val name = queryDisplayName(context, uri) ?: "$fallbackName-${System.currentTimeMillis()}" + return PickedMedia(bytes = bytes, fileName = name, mimeType = mimeType) +} + +private fun queryDisplayName(context: Context, uri: Uri): String? = + runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0 && cursor.moveToFirst()) cursor.getString(index) else null + } + }.getOrNull() diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt index 96eb5d5..26ba171 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt @@ -45,12 +45,14 @@ object MessageCardTags { const val REPLY = "messageReply" const val MENU = "messageMenu" const val DELETE = "messageDelete" + const val REPORT = "messageReport" const val BODY = "messageBody" } /** - * One message in a feed or reply list: avatar, author + relative time, body, and - * the dig / reply engagement row. An overflow menu exposes delete for own messages. + * One message in a feed or reply list: avatar, author + relative time, body, + * attached media / link preview, and the dig / reply engagement row. An overflow + * menu exposes delete for own messages and report for everyone else's. */ @Composable fun MessageCard( @@ -59,6 +61,8 @@ fun MessageCard( onDig: () -> Unit, onDelete: () -> Unit, modifier: Modifier = Modifier, + onReport: () -> Unit = {}, + onOpenLink: (String) -> Unit = {}, ) { Row( modifier = modifier @@ -84,9 +88,7 @@ fun MessageCard( ) } Spacer(Modifier.weight(1f)) - if (message.mine) { - OwnMessageMenu(onDelete = onDelete) - } + MessageMenu(isMine = message.mine, onDelete = onDelete, onReport = onReport) } Spacer(Modifier.size(4.dp)) Text( @@ -94,6 +96,10 @@ fun MessageCard( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag(MessageCardTags.BODY), ) + if (message.hasMedia || message.linkPreview != null) { + Spacer(Modifier.size(8.dp)) + MessageMedia(message = message, onOpenLink = onOpenLink) + } Spacer(Modifier.size(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { Engagement( @@ -118,8 +124,12 @@ fun MessageCard( } } +/** + * Overflow menu: own messages offer Delete; everyone else's offer Report. Renders + * nothing when there is no applicable action (defensive; both branches are covered). + */ @Composable -private fun OwnMessageMenu(onDelete: () -> Unit) { +private fun MessageMenu(isMine: Boolean, onDelete: () -> Unit, onReport: () -> Unit) { var expanded by remember { mutableStateOf(false) } Box { IconButton( @@ -129,14 +139,25 @@ private fun OwnMessageMenu(onDelete: () -> Unit) { Icon(Icons.Filled.MoreVert, contentDescription = "More options") } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text("Delete") }, - onClick = { - expanded = false - onDelete() - }, - modifier = Modifier.testTag(MessageCardTags.DELETE), - ) + if (isMine) { + DropdownMenuItem( + text = { Text("Delete") }, + onClick = { + expanded = false + onDelete() + }, + modifier = Modifier.testTag(MessageCardTags.DELETE), + ) + } else { + DropdownMenuItem( + text = { Text("Report") }, + onClick = { + expanded = false + onReport() + }, + modifier = Modifier.testTag(MessageCardTags.REPORT), + ) + } } } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageMedia.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageMedia.kt new file mode 100644 index 0000000..2f452f6 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageMedia.kt @@ -0,0 +1,150 @@ +package com.interlinedlist.android.feature.messages.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.interlinedlist.android.feature.messages.domain.LinkPreview +import com.interlinedlist.android.feature.messages.domain.Message + +/** Stable test tags for message media and link-preview rendering. */ +object MessageMediaTags { + const val IMAGE = "messageMediaImage" + const val VIDEO = "messageMediaVideo" + const val LINK_PREVIEW = "messageLinkPreview" +} + +/** + * Renders a message's attached media (Coil images inline, a play-button + * placeholder for videos) followed by a link-preview card when metadata exists. + * Draws nothing when the message has neither, so callers can invoke it freely. + */ +@Composable +fun MessageMedia( + message: Message, + onOpenLink: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val hasPreview = message.linkPreview != null + if (!message.hasMedia && !hasPreview) return + + Column(modifier = modifier.fillMaxWidth()) { + if (message.hasMedia) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(message.imageUrls, key = { "img-$it" }) { url -> + AsyncImage( + model = url, + contentDescription = "Attached image", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(160.dp) + .clip(RoundedCornerShape(12.dp)) + .testTag(MessageMediaTags.IMAGE), + ) + } + items(message.videoUrls, key = { "vid-$it" }) { + VideoThumbnail() + } + } + } + message.linkPreview?.let { preview -> + if (message.hasMedia) Spacer(Modifier.height(8.dp)) + LinkPreviewCard(preview = preview, onClick = { onOpenLink(preview.url) }) + } + } +} + +/** A simple placeholder for a video attachment (no in-app player yet). */ +@Composable +private fun VideoThumbnail() { + Box( + modifier = Modifier + .size(160.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .testTag(MessageMediaTags.VIDEO), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = "Video attachment", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + } +} + +/** A tappable link-preview card built from fetched metadata. */ +@Composable +private fun LinkPreviewCard(preview: LinkPreview, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .testTag(MessageMediaTags.LINK_PREVIEW), + ) { + if (!preview.imageUrl.isNullOrBlank()) { + AsyncImage( + model = preview.imageUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(88.dp) + .aspectRatio(1f), + ) + } + Column(Modifier.padding(12.dp)) { + val site = preview.siteName?.takeIf { it.isNotBlank() } + if (site != null) { + Text( + text = site, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = preview.title ?: preview.url, + style = MaterialTheme.typography.titleSmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + preview.description?.takeIf { it.isNotBlank() }?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ReportDialog.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ReportDialog.kt new file mode 100644 index 0000000..522443d --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/ReportDialog.kt @@ -0,0 +1,102 @@ +package com.interlinedlist.android.feature.messages.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.messages.domain.ReportReason + +/** Stable test tags for the report dialog. */ +object ReportDialogTags { + const val DIALOG = "reportDialog" + const val DETAIL = "reportDetail" + const val SUBMIT = "reportSubmit" +} + +/** + * Report dialog: pick a [ReportReason] and optionally add free-text detail, then + * submit. Reason selection is required to enable the submit button. + * + * @param onSubmit invoked with the chosen reason and (possibly blank) detail. + */ +@Composable +fun ReportDialog( + onDismiss: () -> Unit, + onSubmit: (ReportReason, String) -> Unit, + isSubmitting: Boolean = false, +) { + var reason by remember { mutableStateOf(null) } + var detail by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(ReportDialogTags.DIALOG), + title = { Text("Report message") }, + text = { + Column { + Text( + text = "Why are you reporting this?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + ReportReason.entries.forEach { option -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = reason == option, + onClick = { reason = option }, + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = reason == option, onClick = { reason = option }) + Spacer(Modifier.height(0.dp)) + Text(option.label, style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = detail, + onValueChange = { detail = it }, + placeholder = { Text("Add details (optional)") }, + enabled = !isSubmitting, + modifier = Modifier + .fillMaxWidth() + .testTag(ReportDialogTags.DETAIL), + ) + } + }, + confirmButton = { + TextButton( + onClick = { reason?.let { onSubmit(it, detail) } }, + enabled = reason != null && !isSubmitting, + modifier = Modifier.testTag(ReportDialogTags.SUBMIT), + ) { + Text("Report") + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isSubmitting) { Text("Cancel") } + }, + ) +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt index f26c8f3..2f21587 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt @@ -37,7 +37,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.components.MessageCard +import com.interlinedlist.android.feature.messages.ui.components.ReportDialog /** Stable test tags for the detail screen. */ object MessageDetailTags { @@ -73,6 +75,10 @@ fun MessageDetailRoute( onReplyTextChange = viewModel::onReplyTextChange, onPostReply = viewModel::postReply, onRetry = viewModel::load, + onReport = viewModel::openReport, + onFetchMetadata = { viewModel.onFetchMetadata() }, + onDismissReport = viewModel::dismissReport, + onSubmitReport = viewModel::submitReport, modifier = modifier, ) } @@ -89,6 +95,10 @@ fun MessageDetailScreen( onPostReply: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, + onReport: (Message) -> Unit = {}, + onFetchMetadata: (Message) -> Unit = {}, + onDismissReport: () -> Unit = {}, + onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> }, ) { Scaffold( modifier = modifier @@ -117,9 +127,19 @@ fun MessageDetailScreen( onDig = onDig, onReplyTextChange = onReplyTextChange, onPostReply = onPostReply, + onReport = onReport, + onFetchMetadata = onFetchMetadata, ) } } + + state.reportTarget?.let { + ReportDialog( + onDismiss = onDismissReport, + onSubmit = onSubmitReport, + isSubmitting = state.isReporting, + ) + } } @Composable @@ -130,6 +150,8 @@ private fun Content( onDig: () -> Unit, onReplyTextChange: (String) -> Unit, onPostReply: () -> Unit, + onReport: (Message) -> Unit, + onFetchMetadata: (Message) -> Unit, ) { val message = state.message Column( @@ -149,6 +171,8 @@ private fun Content( onClick = {}, onDig = onDig, onDelete = {}, + onReport = { onReport(message) }, + onOpenLink = { onFetchMetadata(message) }, ) HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant) Text( @@ -164,6 +188,8 @@ private fun Content( onClick = { onOpenMessage(reply.id) }, onDig = {}, onDelete = {}, + onReport = { onReport(reply) }, + onOpenLink = { onFetchMetadata(reply) }, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt index 40e8c23..615805c 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModel.kt @@ -7,6 +7,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.MessagesRepository import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,6 +32,9 @@ data class MessageDetailUiState( val subscriptionRequired: Boolean = false, val replyText: String = "", val isPostingReply: Boolean = false, + /** The message (root or a reply) being reported, if any. */ + val reportTarget: Message? = null, + val isReporting: Boolean = false, ) { val canReply: Boolean get() = replyText.isNotBlank() && !isPostingReply } @@ -41,6 +45,8 @@ private data class DetailTransientState( val subscriptionRequired: Boolean = false, val replyText: String = "", val isPostingReply: Boolean = false, + val reportTarget: Message? = null, + val isReporting: Boolean = false, ) @HiltViewModel @@ -69,6 +75,8 @@ class MessageDetailViewModel @Inject constructor( subscriptionRequired = t.subscriptionRequired, replyText = t.replyText, isPostingReply = t.isPostingReply, + reportTarget = t.reportTarget, + isReporting = t.isReporting, ) }.stateIn( scope = viewModelScope, @@ -125,6 +133,38 @@ class MessageDetailViewModel @Inject constructor( } } + // --- report ------------------------------------------------------------ + + fun openReport(message: Message) = transient.update { it.copy(reportTarget = message, errorMessage = null) } + + fun dismissReport() = transient.update { it.copy(reportTarget = null, isReporting = false) } + + fun submitReport(reason: ReportReason, detail: String) { + val target = transient.value.reportTarget ?: return + transient.update { it.copy(isReporting = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.report(target.id, reason, detail)) { + is ApiResult.Success -> transient.update { it.copy(isReporting = false, reportTarget = null) } + is ApiResult.Failure -> transient.update { + it.copy(isReporting = false, reportTarget = null).withError(result.error) + } + } + } + } + + // --- link metadata ----------------------------------------------------- + + /** Fetches link-preview metadata for the current message; cache re-emits it. */ + fun onFetchMetadata() { + val current = uiState.value.message ?: return + viewModelScope.launch { + val result = repository.fetchMetadata(current.id) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + fun dismissError() = transient.update { it.copy(errorMessage = null, subscriptionRequired = false) } private fun DetailTransientState.withError(error: AppError): DetailTransientState = diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt index 6ab8670..f5feb47 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt @@ -1,5 +1,8 @@ package com.interlinedlist.android.feature.messages.ui.feed +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -11,17 +14,24 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Videocam +import androidx.compose.material3.AssistChip import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField @@ -36,6 +46,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -44,7 +55,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.components.MessageCard +import com.interlinedlist.android.feature.messages.ui.components.ReportDialog +import com.interlinedlist.android.feature.messages.ui.readMediaBytes +import java.time.Instant +import java.time.temporal.ChronoUnit /** Stable test tags for the feed screen. */ object MessagesFeedTags { @@ -56,31 +72,52 @@ object MessagesFeedTags { const val FAB = "messagesFeedFab" const val COMPOSE_INPUT = "messagesComposeInput" const val COMPOSE_SUBMIT = "messagesComposeSubmit" + const val COMPOSE_ADD_IMAGE = "messagesComposeAddImage" + const val COMPOSE_ADD_VIDEO = "messagesComposeAddVideo" + const val COMPOSE_SCHEDULE = "messagesComposeSchedule" + const val SCHEDULED_ACTION = "messagesFeedScheduledAction" } /** * Hilt-wired feed entry point. The app's NavHost hosts this as the Messages tab. * * @param onOpenMessage navigates to the detail screen for the given message id. + * @param onOpenScheduled navigates to the Scheduled messages screen. */ @Composable fun MessagesRoute( onOpenMessage: (String) -> Unit, + onOpenScheduled: () -> Unit, modifier: Modifier = Modifier, viewModel: MessagesFeedViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current MessagesFeedScreen( state = state, onRefresh = viewModel::refresh, onLoadMore = viewModel::loadMore, onOpenMessage = onOpenMessage, + onOpenScheduled = onOpenScheduled, onDig = viewModel::onDig, onDelete = viewModel::onDelete, + onReport = viewModel::openReport, + onFetchMetadata = viewModel::onFetchMetadata, onOpenCompose = viewModel::openCompose, onDismissCompose = viewModel::dismissCompose, onComposeTextChange = viewModel::onComposeTextChange, onPost = viewModel::post, + onAttachMedia = { uri, isVideo -> + // Read the picked media at the UI layer; the ViewModel stays URI-free. + val media = readMediaBytes(context, uri, isVideo) + if (media != null) { + viewModel.onAttachMedia(media.bytes, media.fileName, media.mimeType, isVideo) + } + }, + onRemoveAttachment = viewModel::onRemoveAttachment, + onScheduleChange = viewModel::onScheduleChange, + onDismissReport = viewModel::dismissReport, + onSubmitReport = viewModel::submitReport, modifier = modifier, ) } @@ -100,10 +137,30 @@ fun MessagesFeedScreen( onComposeTextChange: (String) -> Unit, onPost: () -> Unit, modifier: Modifier = Modifier, + onOpenScheduled: () -> Unit = {}, + onReport: (Message) -> Unit = {}, + onFetchMetadata: (Message) -> Unit = {}, + onAttachMedia: (Uri, Boolean) -> Unit = { _, _ -> }, + onRemoveAttachment: (PendingAttachment) -> Unit = {}, + onScheduleChange: (String?) -> Unit = {}, + onDismissReport: () -> Unit = {}, + onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> }, ) { Scaffold( modifier = modifier.fillMaxSize(), - topBar = { TopAppBar(title = { Text("Messages") }) }, + topBar = { + TopAppBar( + title = { Text("Messages") }, + actions = { + IconButton( + onClick = onOpenScheduled, + modifier = Modifier.testTag(MessagesFeedTags.SCHEDULED_ACTION), + ) { + Icon(Icons.Filled.Schedule, contentDescription = "Scheduled messages") + } + }, + ) + }, floatingActionButton = { if (!state.subscriptionRequired) { FloatingActionButton( @@ -128,18 +185,29 @@ fun MessagesFeedScreen( onOpenMessage = onOpenMessage, onDig = onDig, onDelete = onDelete, + onReport = onReport, + onFetchMetadata = onFetchMetadata, ) } } if (state.isComposeOpen) { ComposeSheet( - text = state.composeText, - isPosting = state.isPosting, - canPost = state.canPost, + state = state, onTextChange = onComposeTextChange, onDismiss = onDismissCompose, onPost = onPost, + onAttachMedia = onAttachMedia, + onRemoveAttachment = onRemoveAttachment, + onScheduleChange = onScheduleChange, + ) + } + + state.reportTarget?.let { + ReportDialog( + onDismiss = onDismissReport, + onSubmit = onSubmitReport, + isSubmitting = state.isReporting, ) } } @@ -154,6 +222,8 @@ private fun FeedContent( onOpenMessage: (String) -> Unit, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, + onReport: (Message) -> Unit, + onFetchMetadata: (Message) -> Unit, ) { PullToRefreshBox( isRefreshing = state.isRefreshing, @@ -172,6 +242,8 @@ private fun FeedContent( onOpenMessage = onOpenMessage, onDig = onDig, onDelete = onDelete, + onReport = onReport, + onFetchMetadata = onFetchMetadata, ) } } @@ -184,6 +256,8 @@ private fun FeedList( onOpenMessage: (String) -> Unit, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, + onReport: (Message) -> Unit, + onFetchMetadata: (Message) -> Unit, ) { val listState = rememberLazyListState() // Trigger load-more when the last item scrolls into view. @@ -207,6 +281,8 @@ private fun FeedList( onClick = { onOpenMessage(message.id) }, onDig = { onDig(message) }, onDelete = { onDelete(message) }, + onReport = { onReport(message) }, + onOpenLink = { onFetchMetadata(message) }, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -284,13 +360,21 @@ private fun LockedState(message: String?, modifier: Modifier = Modifier) { @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ComposeSheet( - text: String, - isPosting: Boolean, - canPost: Boolean, + state: MessagesFeedUiState, onTextChange: (String) -> Unit, onDismiss: () -> Unit, onPost: () -> Unit, + onAttachMedia: (Uri, Boolean) -> Unit, + onRemoveAttachment: (PendingAttachment) -> Unit, + onScheduleChange: (String?) -> Unit, ) { + val imagePicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri -> uri?.let { onAttachMedia(it, false) } } + val videoPicker = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent(), + ) { uri -> uri?.let { onAttachMedia(it, true) } } + ModalBottomSheet(onDismissRequest = onDismiss) { Column( modifier = Modifier @@ -298,35 +382,67 @@ private fun ComposeSheet( .imePadding() .padding(horizontal = 20.dp, vertical = 12.dp), ) { - Text("New message", style = MaterialTheme.typography.titleMedium) + Text( + text = if (state.isScheduled) "Schedule message" else "New message", + style = MaterialTheme.typography.titleMedium, + ) Spacer(Modifier.height(12.dp)) OutlinedTextField( - value = text, + value = state.composeText, onValueChange = onTextChange, placeholder = { Text("What's on your mind?") }, - enabled = !isPosting, + enabled = !state.isPosting, minLines = 3, modifier = Modifier .fillMaxWidth() .testTag(MessagesFeedTags.COMPOSE_INPUT), ) + + if (state.hasAttachments) { + Spacer(Modifier.height(8.dp)) + AttachmentRow(state.attachments, onRemoveAttachment) + } + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + IconButton( + onClick = { imagePicker.launch("image/*") }, + enabled = !state.isPosting, + modifier = Modifier.testTag(MessagesFeedTags.COMPOSE_ADD_IMAGE), + ) { + Icon(Icons.Filled.Image, contentDescription = "Attach image") + } + IconButton( + onClick = { videoPicker.launch("video/*") }, + enabled = !state.isPosting, + modifier = Modifier.testTag(MessagesFeedTags.COMPOSE_ADD_VIDEO), + ) { + Icon(Icons.Filled.Videocam, contentDescription = "Attach video") + } + ScheduleChip( + scheduledAt = state.scheduledAt, + enabled = !state.isPosting, + onSchedule = onScheduleChange, + ) + } + Spacer(Modifier.height(12.dp)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss, enabled = !isPosting) { Text("Cancel") } + TextButton(onClick = onDismiss, enabled = !state.isPosting) { Text("Cancel") } Spacer(Modifier.height(8.dp)) Button( onClick = onPost, - enabled = canPost, + enabled = state.canPost, modifier = Modifier.testTag(MessagesFeedTags.COMPOSE_SUBMIT), ) { - if (isPosting) { + if (state.isPosting || state.isUploading) { CircularProgressIndicator( modifier = Modifier.height(20.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary, ) } else { - Text("Post") + Text(if (state.isScheduled) "Schedule" else "Post") } } } @@ -335,6 +451,73 @@ private fun ComposeSheet( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AttachmentRow( + attachments: List, + onRemove: (PendingAttachment) -> Unit, +) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + attachments.forEach { attachment -> + AssistChip( + onClick = { onRemove(attachment) }, + label = { + Text( + text = when { + attachment.isUploading -> "Uploading…" + attachment.isVideo -> "Video" + else -> "Image" + }, + ) + }, + leadingIcon = { + if (attachment.isUploading) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Icon( + if (attachment.isVideo) Icons.Filled.Videocam else Icons.Filled.Image, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + }, + trailingIcon = { + Icon(Icons.Filled.Close, contentDescription = "Remove", modifier = Modifier.size(16.dp)) + }, + ) + } + } +} + +/** + * Toggle chip for scheduling. To stay device- and dialog-independent (and easily + * testable), tapping sets a fixed "1 hour from now" ISO time; tapping again clears + * it. A full date/time picker can replace this without touching the ViewModel. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ScheduleChip( + scheduledAt: String?, + enabled: Boolean, + onSchedule: (String?) -> Unit, +) { + AssistChip( + onClick = { + if (scheduledAt != null) { + onSchedule(null) + } else { + onSchedule(Instant.now().plus(1, ChronoUnit.HOURS).toString()) + } + }, + enabled = enabled, + label = { Text(if (scheduledAt != null) "Scheduled" else "Schedule") }, + leadingIcon = { + Icon(Icons.Filled.Schedule, contentDescription = null, modifier = Modifier.size(16.dp)) + }, + modifier = Modifier.testTag(MessagesFeedTags.COMPOSE_SCHEDULE), + ) +} + @Preview(showBackground = true) @Composable private fun MessagesFeedPreview() { diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt index 2da7b35..f0b2e05 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.MessagesRepository import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -18,6 +19,15 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject +/** A pending media attachment being uploaded, or already uploaded, for a compose. */ +data class PendingAttachment( + val fileName: String, + val isVideo: Boolean, + /** Set once the upload finishes; null while [isUploading]. */ + val hostedUrl: String? = null, + val isUploading: Boolean = true, +) + /** Feed screen state: the cached messages plus transient network/compose flags. */ data class MessagesFeedUiState( val messages: List = emptyList(), @@ -30,9 +40,21 @@ data class MessagesFeedUiState( val isComposeOpen: Boolean = false, val composeText: String = "", val isPosting: Boolean = false, + /** Media attached to the in-progress compose. */ + val attachments: List = emptyList(), + /** Optional future send time (ISO-8601) for the in-progress compose. */ + val scheduledAt: String? = null, + /** The message currently being reported (drives the report dialog), if any. */ + val reportTarget: Message? = null, + val isReporting: Boolean = false, ) { val isEmpty: Boolean get() = messages.isEmpty() - val canPost: Boolean get() = composeText.isNotBlank() && !isPosting + val hasAttachments: Boolean get() = attachments.isNotEmpty() + val isUploading: Boolean get() = attachments.any { it.isUploading } + val isScheduled: Boolean get() = scheduledAt != null + val canPost: Boolean + get() = (composeText.isNotBlank() || attachments.any { it.hostedUrl != null }) && + !isPosting && !isUploading } /** Transient (non-cached) UI flags kept separate from the Room-backed message list. */ @@ -45,6 +67,10 @@ private data class FeedTransientState( val isComposeOpen: Boolean = false, val composeText: String = "", val isPosting: Boolean = false, + val attachments: List = emptyList(), + val scheduledAt: String? = null, + val reportTarget: Message? = null, + val isReporting: Boolean = false, ) @HiltViewModel @@ -70,6 +96,10 @@ class MessagesFeedViewModel @Inject constructor( isComposeOpen = t.isComposeOpen, composeText = t.composeText, isPosting = t.isPosting, + attachments = t.attachments, + scheduledAt = t.scheduledAt, + reportTarget = t.reportTarget, + isReporting = t.isReporting, ) }.stateIn( scope = viewModelScope, @@ -134,18 +164,84 @@ class MessagesFeedViewModel @Inject constructor( fun openCompose() = transient.update { it.copy(isComposeOpen = true, errorMessage = null) } - fun dismissCompose() = transient.update { it.copy(isComposeOpen = false, composeText = "") } + fun dismissCompose() = transient.update { + it.copy(isComposeOpen = false, composeText = "", attachments = emptyList(), scheduledAt = null) + } fun onComposeTextChange(value: String) = transient.update { it.copy(composeText = value) } + /** Sets (or clears with null) the future send time for the in-progress compose. */ + fun onScheduleChange(isoTimestamp: String?) = transient.update { it.copy(scheduledAt = isoTimestamp) } + + /** + * Uploads a picked media file and attaches it to the compose. [bytes] and the + * file metadata come from the platform picker at the UI layer, keeping this + * ViewModel free of Android URI/ContentResolver dependencies. + */ + fun onAttachMedia(bytes: ByteArray, fileName: String, mimeType: String, isVideo: Boolean) { + val placeholder = PendingAttachment(fileName = fileName, isVideo = isVideo) + transient.update { it.copy(attachments = it.attachments + placeholder, errorMessage = null) } + viewModelScope.launch { + val result = if (isVideo) { + repository.uploadVideo(bytes, fileName, mimeType) + } else { + repository.uploadImage(bytes, fileName, mimeType) + } + when (result) { + is ApiResult.Success -> transient.update { state -> + state.copy( + attachments = state.attachments.map { + if (it === placeholder || (it.fileName == fileName && it.isUploading)) { + it.copy(hostedUrl = result.data, isUploading = false) + } else { + it + } + }, + ) + } + is ApiResult.Failure -> transient.update { state -> + // Drop the failed placeholder and surface the error. + state.copy( + attachments = state.attachments.filterNot { + it.fileName == fileName && it.isUploading + }, + ).withError(result.error) + } + } + } + } + + /** Removes a not-yet-posted attachment from the compose. */ + fun onRemoveAttachment(attachment: PendingAttachment) = transient.update { + it.copy(attachments = it.attachments - attachment) + } + fun post() { - val text = transient.value.composeText.trim() - if (text.isBlank()) return + val snapshot = transient.value + val text = snapshot.composeText.trim() + val ready = snapshot.attachments.mapNotNull { it.hostedUrl } + if (text.isBlank() && ready.isEmpty()) return + if (snapshot.attachments.any { it.isUploading }) return + val images = snapshot.attachments.filterNot { it.isVideo }.mapNotNull { it.hostedUrl } + val videos = snapshot.attachments.filter { it.isVideo }.mapNotNull { it.hostedUrl } transient.update { it.copy(isPosting = true, errorMessage = null) } viewModelScope.launch { - when (val result = repository.createMessage(text)) { + when ( + val result = repository.createMessage( + content = text, + imageUrls = images, + videoUrls = videos, + scheduledAt = snapshot.scheduledAt, + ) + ) { is ApiResult.Success -> transient.update { - it.copy(isPosting = false, isComposeOpen = false, composeText = "") + it.copy( + isPosting = false, + isComposeOpen = false, + composeText = "", + attachments = emptyList(), + scheduledAt = null, + ) } is ApiResult.Failure -> transient.update { it.copy(isPosting = false).withError(result.error) @@ -154,6 +250,39 @@ class MessagesFeedViewModel @Inject constructor( } } + // --- report ------------------------------------------------------------ + + fun openReport(message: Message) = transient.update { it.copy(reportTarget = message, errorMessage = null) } + + fun dismissReport() = transient.update { it.copy(reportTarget = null, isReporting = false) } + + fun submitReport(reason: ReportReason, detail: String) { + val target = transient.value.reportTarget ?: return + transient.update { it.copy(isReporting = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.report(target.id, reason, detail)) { + is ApiResult.Success -> transient.update { + it.copy(isReporting = false, reportTarget = null) + } + is ApiResult.Failure -> transient.update { + it.copy(isReporting = false, reportTarget = null).withError(result.error) + } + } + } + } + + // --- link metadata ----------------------------------------------------- + + /** Fetches link-preview metadata for a message; the cache Flow re-emits it. */ + fun onFetchMetadata(message: Message) { + viewModelScope.launch { + val result = repository.fetchMetadata(message.id) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + fun dismissError() = transient.update { it.copy(errorMessage = null, subscriptionRequired = false) } private fun FeedTransientState.withError(error: AppError?): FeedTransientState = diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreen.kt new file mode 100644 index 0000000..524d4a6 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesScreen.kt @@ -0,0 +1,253 @@ +package com.interlinedlist.android.feature.messages.ui.scheduled + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.relativeTime + +/** Stable test tags for the scheduled-messages screen. */ +object ScheduledMessagesTags { + const val LIST = "scheduledList" + const val EMPTY = "scheduledEmpty" + const val ERROR = "scheduledError" + const val LOCKED = "scheduledLocked" + const val PROGRESS = "scheduledProgress" + const val CANCEL = "scheduledCancel" +} + +/** + * Hilt-wired scheduled-messages entry point, pushed onto the back stack from the + * feed (mirrors the drill-down nav pattern). + * + * @param onBack pops this screen off the back stack. + */ +@Composable +fun ScheduledMessagesRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ScheduledMessagesViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ScheduledMessagesScreen( + state = state, + onBack = onBack, + onRefresh = viewModel::refresh, + onCancel = viewModel::cancel, + modifier = modifier, + ) +} + +/** Stateless scheduled-messages UI: list / empty / error / locked states. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ScheduledMessagesScreen( + state: ScheduledUiState, + onBack: () -> Unit, + onRefresh: () -> Unit, + onCancel: (Message) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Scheduled") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> Locked(state.errorMessage, Modifier.padding(padding)) + else -> Content(state = state, contentPadding = padding, onRefresh = onRefresh, onCancel = onCancel) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content( + state: ScheduledUiState, + contentPadding: PaddingValues, + onRefresh: () -> Unit, + onCancel: (Message) -> Unit, +) { + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + when { + state.isEmpty && state.isRefreshing -> Loading() + state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) + state.isEmpty -> EmptyState() + else -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(ScheduledMessagesTags.LIST), + ) { + items(state.messages, key = { it.id }) { message -> + ScheduledRow(message = message, onCancel = { onCancel(message) }) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } +} + +@Composable +private fun ScheduledRow(message: Message, onCancel: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Filled.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.height(16.dp), + ) + Spacer(Modifier.height(0.dp)) + Text( + text = " " + sendLabel(message.scheduledAt), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = message.content.ifBlank { "(media only)" }, + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + ) + } + TextButton( + onClick = onCancel, + modifier = Modifier.testTag(ScheduledMessagesTags.CANCEL), + ) { + Text("Cancel") + } + } +} + +/** A short "sends in …" label derived from the scheduled time. */ +private fun sendLabel(scheduledAt: String?): String { + val rel = relativeTime(scheduledAt) + return if (rel.isBlank()) "Scheduled" else "Sends in $rel" +} + +@Composable +private fun Loading() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(ScheduledMessagesTags.PROGRESS)) + } +} + +@Composable +private fun EmptyState() { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = "No scheduled messages. Schedule one from the compose sheet.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ScheduledMessagesTags.EMPTY), + ) + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(ScheduledMessagesTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + androidx.compose.material3.Button(onClick = onRetry) { Text("Retry") } + } +} + +@Composable +private fun Locked(message: String?, modifier: Modifier = Modifier) { + Box(modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = message ?: "Scheduled messages require an active subscription.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(ScheduledMessagesTags.LOCKED), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ScheduledPreview() { + InterlinedListTheme { + ScheduledMessagesScreen( + state = ScheduledUiState( + messages = listOf( + Message( + id = "1", content = "Goes out tomorrow morning.", + authorId = "u1", authorUsername = "adron", authorDisplayName = "Adron", + authorAvatarUrl = null, createdAt = null, digCount = 0, replyCount = 0, + dugByMe = false, parentId = null, mine = true, + scheduledAt = "2026-07-19T09:00:00Z", + ), + ), + ), + onBack = {}, onRefresh = {}, onCancel = {}, + ) + } +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModel.kt new file mode 100644 index 0000000..c539784 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModel.kt @@ -0,0 +1,89 @@ +package com.interlinedlist.android.feature.messages.ui.scheduled + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.data.MessagesRepository +import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate +import com.interlinedlist.android.feature.messages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** State for the Scheduled messages screen: the cached scheduled list + flags. */ +data class ScheduledUiState( + val messages: List = emptyList(), + val isRefreshing: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, +) { + val isEmpty: Boolean get() = messages.isEmpty() +} + +private data class ScheduledTransientState( + val isRefreshing: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, +) + +@HiltViewModel +class ScheduledMessagesViewModel @Inject constructor( + private val repository: MessagesRepository, +) : ViewModel() { + + private val transient = MutableStateFlow(ScheduledTransientState()) + + /** Room is the source of truth: scheduled rows come from the cache Flow. */ + val uiState: StateFlow = + combine(repository.observeScheduled(), transient) { messages, t -> + ScheduledUiState( + messages = messages, + isRefreshing = t.isRefreshing, + errorMessage = t.errorMessage, + subscriptionRequired = t.subscriptionRequired, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = ScheduledUiState(), + ) + + init { + refresh() + } + + fun refresh() { + transient.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.refreshScheduled()) { + is ApiResult.Success -> transient.update { it.copy(isRefreshing = false) } + is ApiResult.Failure -> transient.update { + it.copy(isRefreshing = false).withError(result.error) + } + } + } + } + + /** Cancels (deletes) a scheduled message; the cache Flow drops it. */ + fun cancel(message: Message) { + viewModelScope.launch { + val result = repository.cancelScheduled(message.id) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + fun dismissError() = transient.update { it.copy(errorMessage = null, subscriptionRequired = false) } + + private fun ScheduledTransientState.withError(error: AppError): ScheduledTransientState = + copy(errorMessage = error.toUserMessage(), subscriptionRequired = error.isSubscriptionGate) +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt index 2d7f15a..73cbd0a 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first @@ -236,4 +237,151 @@ class DefaultMessagesRepositoryTest { assertThat((result as ApiResult.Success).data.map { it.id }).containsExactly("s1") assertThat(repo.observeFeed().first()).isEmpty() } + + @Test + fun `uploadImage returns the hosted url and posts to the images endpoint`() = runTest(dispatcher) { + enqueueJson(201, """{ "url": "https://cdn/pic.png" }""") + val repo = repository() + + val result = repo.uploadImage("bytes".toByteArray(), "pic.png", "image/png") + + assertThat((result as ApiResult.Success).data).isEqualTo("https://cdn/pic.png") + assertThat(server.takeRequest().path).contains("api/messages/images/upload") + } + + @Test + fun `uploadVideo falls back to the videoUrl field`() = runTest(dispatcher) { + enqueueJson(201, """{ "videoUrl": "https://cdn/clip.mp4" }""") + val repo = repository() + + val result = repo.uploadVideo("bytes".toByteArray(), "clip.mp4", "video/mp4") + + assertThat((result as ApiResult.Success).data).isEqualTo("https://cdn/clip.mp4") + assertThat(server.takeRequest().path).contains("api/messages/videos/upload") + } + + @Test + fun `upload with no url in the response is a failure`() = runTest(dispatcher) { + enqueueJson(201, """{ }""") + val repo = repository() + + val result = repo.uploadImage("bytes".toByteArray(), "pic.png", "image/png") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + } + + @Test + fun `createMessage with media sends the attached urls`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": { "id": "m1", "content": "with media", + "imageUrls": ["https://cdn/a.png"] } }""", + ) + val repo = repository() + + val result = repo.createMessage( + content = "with media", + imageUrls = listOf("https://cdn/a.png"), + ) + + assertThat((result as ApiResult.Success).data.imageUrls).containsExactly("https://cdn/a.png") + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("https://cdn/a.png") + assertThat(body).contains("imageUrls") + } + + @Test + fun `createMessage scheduled is cached in the scheduled view not the feed`() = runTest(dispatcher) { + enqueueJson( + 201, + """{ "message": { "id": "sch1", "content": "later", + "scheduledAt": "2026-07-19T09:00:00Z" } }""", + ) + val repo = repository() + + val result = repo.createMessage(content = "later", scheduledAt = "2026-07-19T09:00:00Z") + + assertThat((result as ApiResult.Success).data.scheduledAt).isEqualTo("2026-07-19T09:00:00Z") + assertThat(repo.observeFeed().first()).isEmpty() + assertThat(repo.observeScheduled().first().map { it.id }).containsExactly("sch1") + } + + @Test + fun `refreshScheduled caches the scheduled messages`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "s1", "content": "one", "scheduledAt": "2026-07-19T09:00:00Z" }, + { "id": "s2", "content": "two", "scheduledAt": "2026-07-20T09:00:00Z" } ] }""", + ) + val repo = repository() + + val result = repo.refreshScheduled() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo.observeScheduled().first().map { it.id }).containsExactly("s1", "s2").inOrder() + } + + @Test + fun `cancelScheduled removes the scheduled message from the cache`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "s1", "content": "one", "scheduledAt": "2026-07-19T09:00:00Z" } ] }""", + ) + enqueueJson(200, "") + val repo = repository() + repo.refreshScheduled() + + val result = repo.cancelScheduled("s1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo.observeScheduled().first()).isEmpty() + } + + @Test + fun `report posts the reason and detail`() = runTest(dispatcher) { + enqueueJson(201, "") + val repo = repository() + + val result = repo.report("m1", ReportReason.SPAM, detail = "obvious spam") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.path).contains("api/messages/m1/report") + val body = request.body.readUtf8() + assertThat(body).contains("\"reason\":\"spam\"") + assertThat(body).contains("obvious spam") + } + + @Test + fun `report omits blank detail`() = runTest(dispatcher) { + enqueueJson(201, "") + val repo = repository() + + repo.report("m1", ReportReason.OTHER, detail = " ") + + val body = server.takeRequest().body.readUtf8() + assertThat(body).doesNotContain("detail") + } + + @Test + fun `fetchMetadata attaches a link preview to the cached message`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "m1", "content": "see https://example.com" } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson( + 201, + """{ "linkMetadata": { "url": "https://example.com", "title": "Example", + "description": "A page" } }""", + ) + val repo = repository() + repo.refreshFeed() + + val result = repo.fetchMetadata("m1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val cached = repo.observeMessage("m1").first() + assertThat(cached?.linkPreview?.title).isEqualTo("Example") + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt index 978671a..ca684ea 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt @@ -19,7 +19,9 @@ class FakeMessageDao : MessageDao { rows.value.values.filter(predicate).sortedBy { it.feedOrder } override fun observeFeed(): Flow> = - rows.map { map -> map.values.filter { it.parentId == null }.sortedBy { it.feedOrder } } + rows.map { map -> + map.values.filter { it.parentId == null && it.scheduledAt == null }.sortedBy { it.feedOrder } + } override fun observeReplies(parentId: String): Flow> = rows.map { map -> map.values.filter { it.parentId == parentId }.sortedBy { it.feedOrder } } @@ -27,6 +29,9 @@ class FakeMessageDao : MessageDao { override fun observeMessage(id: String): Flow = rows.map { it[id] } + override fun observeScheduled(): Flow> = + rows.map { map -> map.values.filter { it.scheduledAt != null }.sortedBy { it.scheduledAt } } + override suspend fun insertAll(messages: List) { rows.value = rows.value.toMutableMap().apply { messages.forEach { put(it.id, it) } @@ -42,12 +47,16 @@ class FakeMessageDao : MessageDao { } override suspend fun clearFeed() { - rows.value = rows.value.filterValues { it.parentId != null } + rows.value = rows.value.filterValues { it.parentId != null || it.scheduledAt != null } + } + + override suspend fun clearScheduled() { + rows.value = rows.value.filterValues { it.scheduledAt == null } } override suspend fun maxFeedOrder(): Long? = - sorted { it.parentId == null }.maxOfOrNull { it.feedOrder } + sorted { it.parentId == null && it.scheduledAt == null }.maxOfOrNull { it.feedOrder } /** Test helper: current feed snapshot. */ - fun feedSnapshot(): List = sorted { it.parentId == null } + fun feedSnapshot(): List = sorted { it.parentId == null && it.scheduledAt == null } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt index d616749..f60a9e1 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/remote/dto/MessageDtoMapperTest.kt @@ -70,4 +70,62 @@ class MessageDtoMapperTest { ).toDomain(currentUserId = null) assertThat(message.authorLabel).isEqualTo("handle") } + + @Test + fun `maps attached media and scheduled time`() { + val message = MessageDto( + id = "m4", + content = "look", + imageUrls = listOf("a.png", "b.png"), + videoUrls = listOf("v.mp4"), + scheduledAt = "2026-07-19T09:00:00Z", + ).toDomain(currentUserId = null) + + assertThat(message.imageUrls).containsExactly("a.png", "b.png").inOrder() + assertThat(message.videoUrls).containsExactly("v.mp4") + assertThat(message.hasMedia).isTrue() + assertThat(message.scheduledAt).isEqualTo("2026-07-19T09:00:00Z") + } + + @Test + fun `maps link metadata into a preview when it carries content`() { + val message = MessageDto( + id = "m5", + content = "read this", + linkMetadata = LinkMetadataDto( + url = "https://example.com", + title = "Example", + description = "A page", + image = "https://example.com/og.png", + siteName = "Example", + ), + ).toDomain(currentUserId = null) + + val preview = message.linkPreview + assertThat(preview).isNotNull() + assertThat(preview!!.url).isEqualTo("https://example.com") + assertThat(preview.title).isEqualTo("Example") + } + + @Test + fun `drops an empty link preview`() { + val message = MessageDto( + id = "m6", + content = "no preview", + linkMetadata = LinkMetadataDto(url = "https://example.com"), + ).toDomain(currentUserId = null) + + assertThat(message.linkPreview).isNull() + } + + @Test + fun `drops a link preview without a url`() { + val message = MessageDto( + id = "m7", + content = "x", + linkMetadata = LinkMetadataDto(title = "no url"), + ).toDomain(currentUserId = null) + + assertThat(message.linkPreview).isNull() + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt index ceb9ced..44cb89f 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -4,6 +4,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.messages.data.MessagesRepository import com.interlinedlist.android.feature.messages.domain.Message +import com.interlinedlist.android.feature.messages.domain.ReportReason import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -18,6 +19,7 @@ class FakeMessagesRepository : MessagesRepository { private val feed = MutableStateFlow>(emptyList()) private val replies = MutableStateFlow>>(emptyMap()) private val single = MutableStateFlow>(emptyMap()) + private val scheduled = MutableStateFlow>(emptyList()) var refreshResult: ApiResult = ApiResult.Success(false) var loadMoreResult: ApiResult = ApiResult.Success(false) @@ -28,17 +30,42 @@ class FakeMessagesRepository : MessagesRepository { var setDugResult: ApiResult = ApiResult.Success(Unit) var deleteResult: ApiResult = ApiResult.Success(Unit) var searchResult: ApiResult> = ApiResult.Success(emptyList()) + var uploadImageResult: ApiResult = ApiResult.Success("https://cdn/image.png") + var uploadVideoResult: ApiResult = ApiResult.Success("https://cdn/video.mp4") + var refreshScheduledResult: ApiResult = ApiResult.Success(Unit) + var cancelScheduledResult: ApiResult = ApiResult.Success(Unit) + var reportResult: ApiResult = ApiResult.Success(Unit) + var metadataResult: ApiResult? = null var refreshCount = 0 var loadMoreCount = 0 var lastSetDug: Pair? = null var deletedIds = mutableListOf() + var lastCreate: CreateArgs? = null + var uploadedImages = 0 + var uploadedVideos = 0 + var refreshScheduledCount = 0 + var cancelledScheduledIds = mutableListOf() + var lastReport: ReportArgs? = null + var metadataFetchedIds = mutableListOf() + + /** Snapshot of the arguments passed to the last [createMessage] call. */ + data class CreateArgs( + val content: String, + val imageUrls: List, + val videoUrls: List, + val scheduledAt: String?, + ) + + /** Snapshot of the arguments passed to the last [report] call. */ + data class ReportArgs(val messageId: String, val reason: ReportReason, val detail: String?) fun emitFeed(messages: List) { feed.value = messages } fun emitReplies(parentId: String, messages: List) { replies.value = replies.value + (parentId to messages) } fun emitMessage(message: Message) { single.value = single.value + (message.id to message) } + fun emitScheduled(messages: List) { scheduled.value = messages } override fun observeFeed(): Flow> = feed @@ -48,6 +75,8 @@ class FakeMessagesRepository : MessagesRepository { override fun observeMessage(messageId: String): Flow = single.map { it[messageId] } + override fun observeScheduled(): Flow> = scheduled + override suspend fun refreshFeed(): ApiResult { refreshCount++ return refreshResult @@ -58,8 +87,25 @@ class FakeMessagesRepository : MessagesRepository { return loadMoreResult } - override suspend fun createMessage(content: String): ApiResult = - createResult ?: ApiResult.Failure(AppError.Unknown("createResult not set")) + override suspend fun createMessage( + content: String, + imageUrls: List, + videoUrls: List, + scheduledAt: String?, + ): ApiResult { + lastCreate = CreateArgs(content, imageUrls, videoUrls, scheduledAt) + return createResult ?: ApiResult.Failure(AppError.Unknown("createResult not set")) + } + + override suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { + uploadedImages++ + return uploadImageResult + } + + override suspend fun uploadVideo(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { + uploadedVideos++ + return uploadVideoResult + } override suspend fun fetchMessage(messageId: String): ApiResult = fetchResult ?: ApiResult.Failure(AppError.Unknown("fetchResult not set")) @@ -79,6 +125,26 @@ class FakeMessagesRepository : MessagesRepository { return deleteResult } + override suspend fun refreshScheduled(): ApiResult { + refreshScheduledCount++ + return refreshScheduledResult + } + + override suspend fun cancelScheduled(messageId: String): ApiResult { + cancelledScheduledIds += messageId + return cancelScheduledResult + } + + override suspend fun report(messageId: String, reason: ReportReason, detail: String?): ApiResult { + lastReport = ReportArgs(messageId, reason, detail) + return reportResult + } + + override suspend fun fetchMetadata(messageId: String): ApiResult { + metadataFetchedIds += messageId + return metadataResult ?: ApiResult.Failure(AppError.Unknown("metadataResult not set")) + } + override suspend fun search(query: String): ApiResult> = searchResult } @@ -91,6 +157,9 @@ fun sampleMessage( digCount: Int = 0, replyCount: Int = 0, parentId: String? = null, + imageUrls: List = emptyList(), + videoUrls: List = emptyList(), + scheduledAt: String? = null, ) = Message( id = id, content = content, @@ -104,4 +173,7 @@ fun sampleMessage( dugByMe = dugByMe, parentId = parentId, mine = mine, + imageUrls = imageUrls, + videoUrls = videoUrls, + scheduledAt = scheduledAt, ) diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt index 5064f04..45450d6 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailViewModelTest.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository import com.interlinedlist.android.feature.messages.ui.sampleMessage import kotlinx.coroutines.Dispatchers @@ -124,4 +125,43 @@ class MessageDetailViewModelTest { assertThat(repo.lastSetDug).isEqualTo("m1" to true) } + + @Test + fun `report opens the dialog and submits via the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + } + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + val reply = sampleMessage(id = "r1", parentId = "m1") + vm.openReport(reply) + advanceUntilIdle() + assertThat(vm.uiState.value.reportTarget?.id).isEqualTo("r1") + + vm.submitReport(ReportReason.SPAM, "") + advanceUntilIdle() + + assertThat(repo.lastReport?.messageId).isEqualTo("r1") + assertThat(repo.lastReport?.reason).isEqualTo(ReportReason.SPAM) + assertThat(vm.uiState.value.reportTarget).isNull() + } + + @Test + fun `fetchMetadata delegates for the current message`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + fetchResult = ApiResult.Success(sampleMessage(id = "m1")) + metadataResult = ApiResult.Success(sampleMessage(id = "m1")) + } + repo.emitMessage(sampleMessage(id = "m1")) + val vm = MessageDetailViewModel(repo, handle("m1")) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onFetchMetadata() + advanceUntilIdle() + + assertThat(repo.metadataFetchedIds).containsExactly("m1") + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt index fa62952..c3e1616 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository import com.interlinedlist.android.feature.messages.ui.sampleMessage import kotlinx.coroutines.Dispatchers @@ -18,6 +19,8 @@ import org.junit.After import org.junit.Before import org.junit.Test +// PendingAttachment and MessagesFeedUiState live in this package. + @OptIn(ExperimentalCoroutinesApi::class) class MessagesFeedViewModelTest { @@ -176,4 +179,125 @@ class MessagesFeedViewModelTest { assertThat(repo.deletedIds).containsExactly("9") } + + @Test + fun `attaching media uploads and records the hosted url`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + uploadImageResult = ApiResult.Success("https://cdn/a.png") + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onAttachMedia("bytes".toByteArray(), "a.png", "image/png", isVideo = false) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(repo.uploadedImages).isEqualTo(1) + assertThat(state.attachments).hasSize(1) + assertThat(state.attachments.first().hostedUrl).isEqualTo("https://cdn/a.png") + assertThat(state.attachments.first().isUploading).isFalse() + } + + @Test + fun `a failed upload is dropped and surfaces an error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + uploadImageResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onAttachMedia("bytes".toByteArray(), "a.png", "image/png", isVideo = false) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.attachments).isEmpty() + assertThat(state.errorMessage).isNotEmpty() + } + + @Test + fun `post forwards attached media and schedule to the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + uploadImageResult = ApiResult.Success("https://cdn/a.png") + createResult = ApiResult.Success(sampleMessage(id = "new")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.openCompose() + vm.onComposeTextChange("with media") + vm.onAttachMedia("bytes".toByteArray(), "a.png", "image/png", isVideo = false) + vm.onScheduleChange("2026-07-19T09:00:00Z") + advanceUntilIdle() + vm.post() + advanceUntilIdle() + + val create = repo.lastCreate!! + assertThat(create.content).isEqualTo("with media") + assertThat(create.imageUrls).containsExactly("https://cdn/a.png") + assertThat(create.scheduledAt).isEqualTo("2026-07-19T09:00:00Z") + // Compose is reset after a successful post. + assertThat(vm.uiState.value.attachments).isEmpty() + assertThat(vm.uiState.value.scheduledAt).isNull() + } + + @Test + fun `canPost is false while an attachment is still uploading`() { + // Pure state logic: an in-flight upload blocks posting even with text. + val uploading = MessagesFeedUiState( + composeText = "text", + attachments = listOf(PendingAttachment(fileName = "a.png", isVideo = false, isUploading = true)), + ) + assertThat(uploading.isUploading).isTrue() + assertThat(uploading.canPost).isFalse() + + // Once the upload completes, posting is allowed. + val ready = uploading.copy( + attachments = listOf( + PendingAttachment(fileName = "a.png", isVideo = false, hostedUrl = "u", isUploading = false), + ), + ) + assertThat(ready.canPost).isTrue() + } + + @Test + fun `report opens the dialog and submits the chosen reason`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + val target = sampleMessage(id = "abusive") + vm.openReport(target) + advanceUntilIdle() + assertThat(vm.uiState.value.reportTarget?.id).isEqualTo("abusive") + + vm.submitReport(ReportReason.HARASSMENT, "please review") + advanceUntilIdle() + + val report = repo.lastReport!! + assertThat(report.messageId).isEqualTo("abusive") + assertThat(report.reason).isEqualTo(ReportReason.HARASSMENT) + assertThat(report.detail).isEqualTo("please review") + assertThat(vm.uiState.value.reportTarget).isNull() + } + + @Test + fun `fetchMetadata delegates to the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + metadataResult = ApiResult.Success(sampleMessage(id = "m1")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onFetchMetadata(sampleMessage(id = "m1")) + advanceUntilIdle() + + assertThat(repo.metadataFetchedIds).containsExactly("m1") + } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModelTest.kt new file mode 100644 index 0000000..672e668 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/scheduled/ScheduledMessagesViewModelTest.kt @@ -0,0 +1,83 @@ +package com.interlinedlist.android.feature.messages.ui.scheduled + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ScheduledMessagesViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `refreshes on init and emits the cached scheduled messages`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + repo.emitScheduled(listOf(sampleMessage(id = "s1", scheduledAt = "2026-07-19T09:00:00Z"))) + val vm = ScheduledMessagesViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(repo.refreshScheduledCount).isEqualTo(1) + assertThat(state.messages.map { it.id }).containsExactly("s1") + assertThat(state.isRefreshing).isFalse() + } + } + + @Test + fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshScheduledResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = ScheduledMessagesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + } + + @Test + fun `cancel delegates to the repository`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = ScheduledMessagesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.cancel(sampleMessage(id = "s7", scheduledAt = "2026-07-19T09:00:00Z")) + advanceUntilIdle() + + assertThat(repo.cancelledScheduledIds).containsExactly("s7") + } + + @Test + fun `cancel failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + cancelScheduledResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = ScheduledMessagesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.cancel(sampleMessage(id = "s7", scheduledAt = "2026-07-19T09:00:00Z")) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotEmpty() + } +} diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index 614f1a9..fc967b2 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) + // Avatar picker uses rememberLauncherForActivityResult from activity-compose. + implementation(libs.androidx.activity.compose) // This module owns its own Room cache (see DocumentsDatabase) — it must not // reuse the shared :core:database, so it pulls Room in directly. @@ -54,6 +56,8 @@ dependencies { implementation(libs.coil.compose) implementation(libs.retrofit.core) + // Multipart avatar upload needs OkHttp's MultipartBody/RequestBody directly. + implementation(libs.okhttp.core) implementation(libs.kotlinx.serialization.json) // Unit tests diff --git a/feature/profile/src/androidTest/AndroidManifest.xml b/feature/profile/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/profile/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt new file mode 100644 index 0000000..d9839fd --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt @@ -0,0 +1,131 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import com.interlinedlist.android.feature.profile.ui.profile.ProfileScreen +import com.interlinedlist.android.feature.profile.ui.profile.ProfileTestTags +import com.interlinedlist.android.feature.profile.ui.profile.ProfileUiState +import com.interlinedlist.android.feature.profile.ui.search.UserSearchScreen +import com.interlinedlist.android.feature.profile.ui.search.UserSearchTestTags +import com.interlinedlist.android.feature.profile.ui.search.UserSearchUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ProfileScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun sampleUser( + customerStatus: CustomerStatus = CustomerStatus.SUBSCRIBER, + ) = ProfileUser( + id = "1", + username = "adron", + displayName = "Adron Hall", + avatarUrl = null, + bio = "Building things.", + customerStatus = customerStatus, + isCurrentUser = true, + ) + + @Test + fun profile_showsNameUsernameAndSubscriberBadge() { + composeRule.setContent { + InterlinedListTheme { + ProfileScreen( + state = ProfileUiState(user = sampleUser(), isLoading = false), + onEditProfile = {}, + onSearchUsers = {}, + onSignOut = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(ProfileTestTags.DISPLAY_NAME).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileTestTags.USERNAME).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileTestTags.SUBSCRIBER_BADGE).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileTestTags.BIO).assertIsDisplayed() + } + + @Test + fun profile_editSearchAndSignOut_invokeCallbacks() { + var edit = false + var search = false + var signOut = false + composeRule.setContent { + InterlinedListTheme { + ProfileScreen( + state = ProfileUiState(user = sampleUser(), isLoading = false), + onEditProfile = { edit = true }, + onSearchUsers = { search = true }, + onSignOut = { signOut = true }, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(ProfileTestTags.EDIT).performClick() + composeRule.onNodeWithTag(ProfileTestTags.SEARCH).performClick() + composeRule.onNodeWithTag(ProfileTestTags.SIGN_OUT).performClick() + + assert(edit) + assert(search) + assert(signOut) + } + + @Test + fun profile_showsProgress_whileLoadingWithNoCache() { + composeRule.setContent { + InterlinedListTheme { + ProfileScreen( + state = ProfileUiState(user = null, isLoading = true), + onEditProfile = {}, + onSearchUsers = {}, + onSignOut = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(ProfileTestTags.PROGRESS).assertIsDisplayed() + } + + @Test + fun search_typingRunsAndRowsOpenProfile() { + var opened: String? = null + var typed: String? = null + composeRule.setContent { + InterlinedListTheme { + UserSearchScreen( + state = UserSearchUiState( + query = "ad", + results = listOf( + UserSearchResult("1", "ada", "Ada Lovelace", null), + UserSearchResult("2", "adron", "Adron Hall", null), + ), + ), + onQueryChange = { typed = it }, + onOpenUser = { opened = it }, + onBack = {}, + ) + } + } + + composeRule.onNodeWithTag(UserSearchTestTags.QUERY).performTextInput("a") + assert(typed != null) + + composeRule.onNodeWithTag(UserSearchTestTags.row("ada")).performClick() + assert(opened == "ada") + } +} diff --git a/feature/profile/src/main/AndroidManifest.xml b/feature/profile/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/profile/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt new file mode 100644 index 0000000..cbc7088 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt @@ -0,0 +1,141 @@ +package com.interlinedlist.android.feature.profile.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.profile.data.local.ProfileDao +import com.interlinedlist.android.feature.profile.data.local.toDomain +import com.interlinedlist.android.feature.profile.data.local.toEntity +import com.interlinedlist.android.feature.profile.data.mapper.toProfileUser +import com.interlinedlist.android.feature.profile.data.mapper.toSearchResult +import com.interlinedlist.android.feature.profile.data.remote.ProfileApi +import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject + +/** + * Room-backed, offline-first implementation. Reads observe Room; refreshes and + * mutations call the API and write through to Room so the UI updates reactively. + */ +class DefaultProfileRepository @Inject constructor( + private val api: ProfileApi, + private val profileDao: ProfileDao, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : ProfileRepository { + + override fun observeCurrentUser(): Flow = + profileDao.observeCurrentUser().map { it?.toDomain() } + + override fun observeUser(username: String): Flow = + profileDao.observeByUsername(username).map { it?.toDomain() } + + override suspend fun refreshCurrentUser(): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getCurrentUser().userOrSelf }) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.Unknown("No user in response")) + ApiResult.Success(cacheCurrentUser(dto)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun refreshUser(username: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getUserByUsername(username).userOrSelf }) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure(AppError.NotFound("User not found")) + // A viewed user is never flagged as the current user, so the account + // tab keeps observing "my" row rather than the last one viewed. + val domain = dto.toProfileUser(isCurrentUser = false) + profileDao.upsert(domain.toEntity()) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun updateProfile( + displayName: String, + bio: String, + ): ApiResult = withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.updateProfile( + UpdateProfileRequest(displayName = displayName, bio = bio), + ).userOrSelf + } + when (result) { + is ApiResult.Success -> { + // The server may echo a thin body; fall back to a re-fetch so the + // cache always ends up with the authoritative, complete profile. + val dto = result.data + if (dto != null) { + ApiResult.Success(cacheCurrentUser(dto)) + } else { + refreshCurrentUser() + } + } + is ApiResult.Failure -> result + } + } + + override suspend fun setAvatarFromUrl(url: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.setAvatarFromUrl(AvatarFromUrlRequest(url)) }) { + is ApiResult.Success -> + // The avatar endpoint returns just the new URL, so re-fetch the + // full user to refresh the cache consistently. + refreshCurrentUser() + is ApiResult.Failure -> result + } + } + + override suspend fun uploadAvatar( + bytes: ByteArray, + fileName: String, + mimeType: String, + ): ApiResult = withContext(dispatchers.io) { + val part = MultipartBody.Part.createFormData( + name = "file", + filename = fileName, + body = bytes.toRequestBody(mimeType.toMediaTypeOrNull()), + ) + when (val result = safeApiCall(json) { api.uploadAvatar(part) }) { + is ApiResult.Success -> refreshCurrentUser() + is ApiResult.Failure -> result + } + } + + override suspend fun searchUsers(query: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.searchUsers(query, limit = SEARCH_LIMIT) } + .map { response -> response.usersOrEmpty.map { it.toSearchResult() } } + } + + /** Caches [dto] as the current user, clearing the flag from any stale row first. */ + private suspend fun cacheCurrentUser(dto: ProfileUserDto): ProfileUser { + val domain = dto.toProfileUser(isCurrentUser = true) + profileDao.clearCurrentUserFlag() + profileDao.upsert(domain.toEntity()) + return domain + } + + private companion object { + const val SEARCH_LIMIT = 20 + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt new file mode 100644 index 0000000..aa2fc8b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt @@ -0,0 +1,54 @@ +package com.interlinedlist.android.feature.profile.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to user profiles. The current user and viewed users are + * served as [Flow]s from Room (the source of truth); the `refresh*` calls pull + * from the API and upsert into the cache. Mutations write through to the API and + * update the cache so the observing UI reflects the change immediately. Search is + * one-shot and not cached. + */ +interface ProfileRepository { + + /** The current signed-in user's cached profile (null until first loaded). */ + fun observeCurrentUser(): Flow + + /** A cached profile by username (null until first loaded). */ + fun observeUser(username: String): Flow + + /** Fetches the current user from `GET /api/user` and caches it. */ + suspend fun refreshCurrentUser(): ApiResult + + /** Fetches another user from `GET /api/users/{username}` and caches it. */ + suspend fun refreshUser(username: String): ApiResult + + /** + * Updates the current user's editable fields via `PATCH /api/user/update` and + * writes the result through to the cache. + */ + suspend fun updateProfile( + displayName: String, + bio: String, + ): ApiResult + + /** Sets the current user's avatar from a remote URL and refreshes the cache. */ + suspend fun setAvatarFromUrl(url: String): ApiResult + + /** + * Uploads a new avatar image (raw bytes + mime type) and refreshes the cache. + * The caller resolves the picked image to bytes; the repository stays free of + * Android URI/ContentResolver concerns. + */ + suspend fun uploadAvatar( + bytes: ByteArray, + fileName: String, + mimeType: String, + ): ApiResult + + /** One-shot user search against `GET /api/users/search` (not cached). */ + suspend fun searchUsers(query: String): ApiResult> +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDao.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDao.kt new file mode 100644 index 0000000..d626592 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDao.kt @@ -0,0 +1,32 @@ +package com.interlinedlist.android.feature.profile.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ProfileDao { + + /** Emits the current signed-in user's cached profile (or null before first load). */ + @Query("SELECT * FROM profile WHERE isCurrentUser = 1 LIMIT 1") + fun observeCurrentUser(): Flow + + /** Emits a cached profile by username (or null) and re-emits on every change. */ + @Query("SELECT * FROM profile WHERE username = :username LIMIT 1") + fun observeByUsername(username: String): Flow + + @Query("SELECT * FROM profile WHERE username = :username LIMIT 1") + suspend fun getByUsername(username: String): ProfileEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(profile: ProfileEntity) + + /** Clears the current-user flag from any prior row before a fresh sign-in caches a new one. */ + @Query("UPDATE profile SET isCurrentUser = 0 WHERE isCurrentUser = 1") + suspend fun clearCurrentUserFlag() + + @Query("DELETE FROM profile") + suspend fun clear() +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDatabase.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDatabase.kt new file mode 100644 index 0000000..eac7646 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileDatabase.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.profile.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * This feature's own Room cache — kept separate from the shared + * `InterlinedListDatabase` so the module stays self-contained (see the + * engineering brief). Disposable during development via destructive migration. + */ +@Database( + entities = [ProfileEntity::class], + version = 1, + exportSchema = false, +) +abstract class ProfileDatabase : RoomDatabase() { + abstract fun profileDao(): ProfileDao +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileEntity.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileEntity.kt new file mode 100644 index 0000000..ae57f2b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/local/ProfileEntity.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.profile.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.ProfileUser + +/** + * Locally cached profile. Keyed by [username] (the stable handle the UI navigates + * by) so both the current user and viewed users share one table. [isCurrentUser] + * lets the account screen observe "my" profile without knowing the id up front. + */ +@Entity(tableName = "profile") +data class ProfileEntity( + @PrimaryKey val username: String, + val id: String, + val displayName: String?, + val avatarUrl: String?, + val bio: String?, + val customerStatus: String, + val isCurrentUser: Boolean, +) + +fun ProfileEntity.toDomain(): ProfileUser = ProfileUser( + id = id, + username = username, + displayName = displayName, + avatarUrl = avatarUrl, + bio = bio, + customerStatus = CustomerStatus.fromApiValue(customerStatus), + isCurrentUser = isCurrentUser, +) + +fun ProfileUser.toEntity(): ProfileEntity = ProfileEntity( + username = username, + id = id, + displayName = displayName, + avatarUrl = avatarUrl, + bio = bio, + customerStatus = customerStatus.apiValue, + isCurrentUser = isCurrentUser, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ProfileMappers.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ProfileMappers.kt new file mode 100644 index 0000000..892f17b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ProfileMappers.kt @@ -0,0 +1,29 @@ +package com.interlinedlist.android.feature.profile.data.mapper + +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.UserSearchResult + +/** + * Maps a wire user into the domain [ProfileUser]. [isCurrentUser] is decided by the + * repository (the current user knows their own id) rather than the wire, since the + * same DTO shape is used for both `GET /api/user` and `GET /api/users/{username}`. + */ +fun ProfileUserDto.toProfileUser(isCurrentUser: Boolean): ProfileUser = ProfileUser( + id = id, + username = username, + displayName = displayName, + avatarUrl = avatarOrNull, + bio = bio, + customerStatus = CustomerStatus.fromApiValue(customerStatus), + isCurrentUser = isCurrentUser, +) + +/** Maps a wire user into a lightweight [UserSearchResult]. */ +fun ProfileUserDto.toSearchResult(): UserSearchResult = UserSearchResult( + id = id, + username = username, + displayName = displayName, + avatarUrl = avatarOrNull, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt new file mode 100644 index 0000000..d14783b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.profile.data.remote + +import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.UserSearchResponse +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the Users & Profile endpoints. The shared Retrofit + * instance already carries the base URL and Bearer token, so these calls are + * authed. Endpoint paths were verified live (the roadmap's `/api/users/me` is + * wrong — the current user is `GET /api/user`). + */ +interface ProfileApi { + + /** The current signed-in user: `{ "user": { ... } }`. */ + @GET("api/user") + suspend fun getCurrentUser(): ProfileResponse + + /** Updates the current user's editable profile fields. */ + @PATCH("api/user/update") + suspend fun updateProfile(@Body body: UpdateProfileRequest): ProfileResponse + + /** Sets the current user's avatar from a remote URL. */ + @POST("api/user/avatar/from-url") + suspend fun setAvatarFromUrl(@Body body: AvatarFromUrlRequest): AvatarResponse + + /** Uploads a new avatar image as multipart form data. */ + @Multipart + @POST("api/user/avatar/upload") + suspend fun uploadAvatar(@Part file: MultipartBody.Part): AvatarResponse + + /** A public profile for another user by username. */ + @GET("api/users/{username}") + suspend fun getUserByUsername(@Path("username") username: String): ProfileResponse + + /** Searches users by free-text query. */ + @GET("api/users/search") + suspend fun searchUsers( + @Query("q") query: String, + @Query("limit") limit: Int? = null, + ): UserSearchResponse +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt new file mode 100644 index 0000000..271b39c --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt @@ -0,0 +1,21 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Body for `PATCH /api/user/update`. Only the profile fields this module edits are + * modelled; null fields are omitted (see the module's `explicitNulls = false` + * JSON config) so the server leaves the rest unchanged. + */ +@Serializable +data class UpdateProfileRequest( + val displayName: String? = null, + val bio: String? = null, + val avatar: String? = null, +) + +/** Body for `POST /api/user/avatar/from-url`. */ +@Serializable +data class AvatarFromUrlRequest( + val url: String, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt new file mode 100644 index 0000000..0365fa6 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt @@ -0,0 +1,63 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/user` and `PATCH /api/user/update` wrap the user in `{ "user": {...} }`. + * [userOrSelf] tolerates a bare top-level user object as well, since some endpoints + * inline it. + */ +@Serializable +data class ProfileResponse( + val user: ProfileUserDto? = null, + val id: String? = null, + val username: String? = null, + val displayName: String? = null, + val avatarUrl: String? = null, + val avatar: String? = null, + val bio: String? = null, + val customerStatus: String? = null, +) { + /** The user payload, whether wrapped under `user` or inlined at the top level. */ + val userOrSelf: ProfileUserDto? + get() = user ?: id?.let { + ProfileUserDto( + id = it, + username = username ?: "", + displayName = displayName, + avatarUrl = avatarUrl, + avatar = avatar, + bio = bio, + customerStatus = customerStatus, + ) + } +} + +/** + * `GET /api/users/search` and `GET /api/users/lookup`. Results may arrive under + * `users` or the generic `data` envelope; both are accepted by [usersOrEmpty]. + */ +@Serializable +data class UserSearchResponse( + val users: List? = null, + val data: List? = null, +) { + val usersOrEmpty: List get() = users ?: data ?: emptyList() +} + +/** + * `POST /api/user/avatar/from-url` and `POST /api/user/avatar/upload` return the + * new avatar location. The field name varies, so both are accepted; the updated + * user object may also be echoed back under `user`. + */ +@Serializable +data class AvatarResponse( + val avatarUrl: String? = null, + val avatar: String? = null, + val url: String? = null, + val user: ProfileUserDto? = null, +) { + /** The resolved avatar URL from whichever field the endpoint populated. */ + val avatarOrNull: String? + get() = avatarUrl ?: avatar ?: url ?: user?.avatarOrNull +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt new file mode 100644 index 0000000..cfc8a8c --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire model for a user object returned by the profile endpoints + * (`GET /api/user`, `GET /api/users/{username}`). The API is inconsistent about + * the avatar field name — some responses use `avatarUrl`, others `avatar` — so + * both are accepted and resolved by [avatar]. + */ +@Serializable +data class ProfileUserDto( + val id: String, + val username: String = "", + val displayName: String? = null, + val avatarUrl: String? = null, + val avatar: String? = null, + val bio: String? = null, + val customerStatus: String? = null, +) { + /** The avatar URL under whichever field the endpoint populated. */ + val avatarOrNull: String? get() = avatarUrl ?: avatar +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt new file mode 100644 index 0000000..72b4cfc --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/di/ProfileModule.kt @@ -0,0 +1,52 @@ +package com.interlinedlist.android.feature.profile.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.feature.profile.data.DefaultProfileRepository +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.data.local.ProfileDao +import com.interlinedlist.android.feature.profile.data.local.ProfileDatabase +import com.interlinedlist.android.feature.profile.data.remote.ProfileApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its default implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class ProfileRepositoryModule { + + @Binds + @Singleton + abstract fun bindProfileRepository(impl: DefaultProfileRepository): ProfileRepository +} + +/** Provides this feature's API, its own Room database, and DAO. */ +@Module +@InstallIn(SingletonComponent::class) +object ProfileDataModule { + + @Provides + @Singleton + fun provideProfileApi(retrofit: Retrofit): ProfileApi = + retrofit.create(ProfileApi::class.java) + + @Provides + @Singleton + fun provideProfileDatabase(@ApplicationContext context: Context): ProfileDatabase = + Room.databaseBuilder( + context, + ProfileDatabase::class.java, + "interlinedlist-profile.db", + ) + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideProfileDao(db: ProfileDatabase): ProfileDao = db.profileDao() +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/ProfileUser.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/ProfileUser.kt new file mode 100644 index 0000000..7b44f9c --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/ProfileUser.kt @@ -0,0 +1,29 @@ +package com.interlinedlist.android.feature.profile.domain + +import com.interlinedlist.android.core.model.CustomerStatus + +/** + * A profile as shown in this feature: the current signed-in user or another user + * viewed by username. Wraps the shared [com.interlinedlist.android.core.model.User] + * fields plus a `isCurrentUser` flag so the UI can decide whether to show the edit + * and sign-out affordances. + * + * Kept distinct from the shared `User` so the module can carry profile-only fields + * (e.g. [isCurrentUser]) without touching `:core:model`. + */ +data class ProfileUser( + val id: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, + val bio: String?, + val customerStatus: CustomerStatus, + val isCurrentUser: Boolean, +) { + /** The best label to show for the user: display name if set, else the @username. */ + val displayLabel: String + get() = displayName?.takeIf { it.isNotBlank() } ?: "@$username" + + /** True when the account has any active paid subscription. */ + val isSubscriber: Boolean get() = customerStatus.isSubscriber +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSearchResult.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSearchResult.kt new file mode 100644 index 0000000..b005b6d --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/UserSearchResult.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * A lightweight user entry returned by `GET /api/users/search`. Search results are + * one-shot (not cached); tapping one drills down into that user's full profile by + * [username]. + */ +data class UserSearchResult( + val id: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) { + /** The best label to show for the user: display name if set, else the @username. */ + val displayLabel: String + get() = displayName?.takeIf { it.isNotBlank() } ?: "@$username" +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt new file mode 100644 index 0000000..233ee70 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt @@ -0,0 +1,79 @@ +package com.interlinedlist.android.feature.profile.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.interlinedlist.android.core.designsystem.theme.AmberGold +import com.interlinedlist.android.core.designsystem.theme.OceanBlue + +/** + * A circular user avatar. Loads [avatarUrl] with Coil when present, otherwise falls + * back to a coloured monogram derived from [seedLabel] so every user still has a + * recognisable mark. + */ +@Composable +fun UserAvatar( + avatarUrl: String?, + seedLabel: String, + modifier: Modifier = Modifier, + size: Dp = 96.dp, +) { + val shape = CircleShape + if (!avatarUrl.isNullOrBlank()) { + AsyncImage( + model = avatarUrl, + contentDescription = "Avatar for $seedLabel", + contentScale = ContentScale.Crop, + modifier = modifier + .size(size) + .clip(shape), + ) + } else { + Box( + modifier = modifier + .size(size) + .clip(shape) + .background(OceanBlue), + contentAlignment = Alignment.Center, + ) { + Text( + text = seedLabel.trimStart('@').firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + ) + } + } +} + +/** An amber "Subscriber" pill shown for paid accounts. */ +@Composable +fun SubscriberBadge(modifier: Modifier = Modifier) { + Surface( + color = AmberGold, + shape = MaterialTheme.shapes.small, + modifier = modifier, + ) { + Text( + text = "Subscriber", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileErrorMessages.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileErrorMessages.kt new file mode 100644 index 0000000..1ebed72 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileErrorMessages.kt @@ -0,0 +1,14 @@ +package com.interlinedlist.android.feature.profile.ui.common + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the profile UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.SubscriptionRequired -> message ?: "This feature requires an active subscription." + is AppError.NotFound -> message ?: "That user could not be found." + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileScreen.kt new file mode 100644 index 0000000..3799a9f --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileScreen.kt @@ -0,0 +1,268 @@ +package com.interlinedlist.android.feature.profile.ui.edit + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.ui.common.UserAvatar + +/** Stable test tags for the edit-profile form. */ +object EditProfileTestTags { + const val DISPLAY_NAME = "editDisplayName" + const val BIO = "editBio" + const val AVATAR_URL = "editAvatarUrl" + const val SET_AVATAR_URL = "editSetAvatarUrl" + const val PICK_AVATAR = "editPickAvatar" + const val SAVE = "editSave" + const val BACK = "editBack" + const val ERROR = "editError" + const val PROGRESS = "editProgress" + const val SAVE_PROGRESS = "editSaveProgress" +} + +/** + * Edit-profile route. Resolves a picked image to bytes via [android.content.ContentResolver] + * here (keeping the ViewModel/repository free of Android I/O), then hands the bytes + * to the ViewModel for upload. + * + * @param onBack pop back to the account screen. + * @param onSaved invoked after a successful save (the caller typically also pops). + */ +@Composable +fun EditProfileRoute( + onBack: () -> Unit, + onSaved: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EditProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + val pickImage = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + ) { uri: Uri? -> + if (uri != null) { + val resolver = context.contentResolver + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } + if (bytes != null) { + val mime = resolver.getType(uri) ?: "image/*" + val name = "avatar.${mime.substringAfter('/', "jpg")}" + viewModel.uploadAvatar(bytes, name, mime) + } + } + } + + EditProfileScreen( + state = state, + onDisplayNameChange = viewModel::onDisplayNameChange, + onBioChange = viewModel::onBioChange, + onAvatarUrlInputChange = viewModel::onAvatarUrlInputChange, + onSetAvatarFromUrl = viewModel::setAvatarFromUrl, + onPickAvatar = { pickImage.launch("image/*") }, + onSave = { viewModel.save(onSaved) }, + onBack = onBack, + modifier = modifier, + ) +} + +/** Stateless edit-profile form — easy to preview and to drive from Compose tests. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun EditProfileScreen( + state: EditProfileUiState, + onDisplayNameChange: (String) -> Unit, + onBioChange: (String) -> Unit, + onAvatarUrlInputChange: (String) -> Unit, + onSetAvatarFromUrl: () -> Unit, + onPickAvatar: () -> Unit, + onSave: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Edit profile") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(EditProfileTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + TextButton( + onClick = onSave, + enabled = state.canSave, + modifier = Modifier.testTag(EditProfileTestTags.SAVE), + ) { + if (state.isSaving) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .testTag(EditProfileTestTags.SAVE_PROGRESS), + strokeWidth = 2.dp, + ) + } else { + Text("Save") + } + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(contentAlignment = Alignment.Center) { + UserAvatar( + avatarUrl = state.avatarUrl, + seedLabel = state.displayName.ifBlank { "?" }, + ) + if (state.isUploadingAvatar) { + CircularProgressIndicator(Modifier.testTag(EditProfileTestTags.PROGRESS)) + } + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = onPickAvatar, + enabled = !state.isUploadingAvatar, + modifier = Modifier.testTag(EditProfileTestTags.PICK_AVATAR), + ) { + Text("Upload photo") + } + + Spacer(Modifier.height(8.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + OutlinedTextField( + value = state.avatarUrlInput, + onValueChange = onAvatarUrlInputChange, + label = { Text("Avatar URL") }, + singleLine = true, + enabled = !state.isUploadingAvatar, + modifier = Modifier + .weight(1f) + .testTag(EditProfileTestTags.AVATAR_URL), + ) + TextButton( + onClick = onSetAvatarFromUrl, + enabled = state.avatarUrlInput.isNotBlank() && !state.isUploadingAvatar, + modifier = Modifier.testTag(EditProfileTestTags.SET_AVATAR_URL), + ) { + Text("Set") + } + } + + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = state.displayName, + onValueChange = onDisplayNameChange, + label = { Text("Display name") }, + singleLine = true, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(EditProfileTestTags.DISPLAY_NAME), + ) + + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = state.bio, + onValueChange = onBioChange, + label = { Text("Bio") }, + minLines = 3, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(EditProfileTestTags.BIO), + ) + + if (state.errorMessage != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .testTag(EditProfileTestTags.ERROR), + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = onSave, + enabled = state.canSave, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Save changes") + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun EditProfileScreenPreview() { + InterlinedListTheme { + EditProfileScreen( + state = EditProfileUiState( + displayName = "Adron Hall", + bio = "Building things.", + isLoading = false, + ), + onDisplayNameChange = {}, + onBioChange = {}, + onAvatarUrlInputChange = {}, + onSetAvatarFromUrl = {}, + onPickAvatar = {}, + onSave = {}, + onBack = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileViewModel.kt new file mode 100644 index 0000000..07cb05f --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/edit/EditProfileViewModel.kt @@ -0,0 +1,157 @@ +package com.interlinedlist.android.feature.profile.ui.edit + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the edit-profile form. */ +data class EditProfileUiState( + val displayName: String = "", + val bio: String = "", + val avatarUrl: String? = null, + // Text field for the "set avatar from URL" affordance. + val avatarUrlInput: String = "", + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val isUploadingAvatar: Boolean = false, + val hasUnsavedChanges: Boolean = false, + val errorMessage: String? = null, +) { + val canSave: Boolean get() = hasUnsavedChanges && !isSaving && !isLoading +} + +/** + * Backs the edit-profile form. Seeds from the current user's Room cache, edits the + * display name and bio, saves via `PATCH /api/user/update`, and sets the avatar via + * upload or from a URL. + */ +@HiltViewModel +class EditProfileViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(EditProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + seedFromCache() + refresh() + } + + /** Seeds the form from the cached current user so it renders instantly offline. */ + private fun seedFromCache() { + viewModelScope.launch { + repository.observeCurrentUser().collect { cached -> + if (cached != null && !_uiState.value.hasUnsavedChanges) { + _uiState.update { + it.copy( + displayName = cached.displayName ?: "", + bio = cached.bio ?: "", + avatarUrl = cached.avatarUrl, + ) + } + } + } + } + } + + private fun refresh() { + viewModelScope.launch { + when (val result = repository.refreshCurrentUser()) { + is ApiResult.Success -> _uiState.update { + if (it.hasUnsavedChanges) { + it.copy(isLoading = false) + } else { + it.copy( + displayName = result.data.displayName ?: "", + bio = result.data.bio ?: "", + avatarUrl = result.data.avatarUrl, + isLoading = false, + ) + } + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun onDisplayNameChange(value: String) = + _uiState.update { it.copy(displayName = value, hasUnsavedChanges = true, errorMessage = null) } + + fun onBioChange(value: String) = + _uiState.update { it.copy(bio = value, hasUnsavedChanges = true, errorMessage = null) } + + fun onAvatarUrlInputChange(value: String) = + _uiState.update { it.copy(avatarUrlInput = value, errorMessage = null) } + + /** Persists display name + bio; invokes [onSaved] on success. */ + fun save(onSaved: () -> Unit = {}) { + val state = _uiState.value + if (!state.canSave) return + _uiState.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + val result = repository.updateProfile( + displayName = state.displayName.trim(), + bio = state.bio.trim(), + ) + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false, hasUnsavedChanges = false) } + onSaved() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Sets the avatar from the URL currently typed into the form. */ + fun setAvatarFromUrl() { + val url = _uiState.value.avatarUrlInput.trim() + if (url.isBlank()) return + _uiState.update { it.copy(isUploadingAvatar = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.setAvatarFromUrl(url)) { + is ApiResult.Success -> _uiState.update { + it.copy( + isUploadingAvatar = false, + avatarUrl = result.data.avatarUrl, + avatarUrlInput = "", + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isUploadingAvatar = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Uploads a picked image (already resolved to bytes) as the new avatar. */ + fun uploadAvatar(bytes: ByteArray, fileName: String, mimeType: String) { + _uiState.update { it.copy(isUploadingAvatar = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.uploadAvatar(bytes, fileName, mimeType)) { + is ApiResult.Success -> _uiState.update { + it.copy(isUploadingAvatar = false, avatarUrl = result.data.avatarUrl) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isUploadingAvatar = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt new file mode 100644 index 0000000..eeca614 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.ui.common.SubscriberBadge +import com.interlinedlist.android.feature.profile.ui.common.UserAvatar + +/** Stable test tags shared by the profile screens. */ +object ProfileTestTags { + const val AVATAR = "profileAvatar" + const val DISPLAY_NAME = "profileDisplayName" + const val USERNAME = "profileUsername" + const val BIO = "profileBio" + const val SUBSCRIBER_BADGE = "profileSubscriberBadge" + const val EDIT = "profileEdit" + const val SIGN_OUT = "profileSignOut" + const val PROGRESS = "profileProgress" + const val ERROR = "profileError" + const val SEARCH = "profileSearch" + const val BACK = "profileBack" +} + +/** + * Stateless profile body — avatar, name, @username, subscriber badge, and bio. + * Shared by the current-user ("Account") and other-user profile screens so both + * render identically. + */ +@Composable +fun ProfileContent( + user: ProfileUser, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + UserAvatar( + avatarUrl = user.avatarUrl, + seedLabel = user.displayLabel, + modifier = Modifier.testTag(ProfileTestTags.AVATAR), + ) + Spacer(Modifier.height(16.dp)) + + Text( + text = user.displayLabel, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(ProfileTestTags.DISPLAY_NAME), + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "@${user.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ProfileTestTags.USERNAME), + ) + + if (user.isSubscriber) { + Spacer(Modifier.height(12.dp)) + SubscriberBadge(modifier = Modifier.testTag(ProfileTestTags.SUBSCRIBER_BADGE)) + } + + if (!user.bio.isNullOrBlank()) { + Spacer(Modifier.height(20.dp)) + Text( + text = user.bio, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .testTag(ProfileTestTags.BIO), + ) + } + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..cd5b44b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -0,0 +1,172 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.ProfileUser + +/** + * The app's "Account" tab: the current signed-in user's profile with entries to + * edit the profile, search users, and sign out. + * + * @param onEditProfile navigate to the edit-profile route. + * @param onSearchUsers navigate to the user-search route. + * @param onSignOut invoked after the caller performs sign-out (mirrors HomeScreen's + * `onLoggedOut`); the profile module does not own session state, so the app wires + * this to the auth logout + navigation. + */ +@Composable +fun ProfileRoute( + onEditProfile: () -> Unit, + onSearchUsers: () -> Unit, + onSignOut: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ProfileScreen( + state = state, + onEditProfile = onEditProfile, + onSearchUsers = onSearchUsers, + onSignOut = onSignOut, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless "Account" UI — easy to preview and to drive from Compose tests. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun ProfileScreen( + state: ProfileUiState, + onEditProfile: () -> Unit, + onSearchUsers: () -> Unit, + onSignOut: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Account") }, + actions = { + IconButton(onClick = onSearchUsers, modifier = Modifier.testTag(ProfileTestTags.SEARCH)) { + Icon(Icons.Default.Search, contentDescription = "Search users") + } + IconButton(onClick = onEditProfile, modifier = Modifier.testTag(ProfileTestTags.EDIT)) { + Icon(Icons.Default.Edit, contentDescription = "Edit profile") + } + }, + ) + }, + ) { padding -> + when { + state.user != null -> Column( + Modifier.fillMaxSize().padding(padding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + ProfileContent(user = state.user, modifier = Modifier.weight(1f, fill = false)) + + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .testTag(ProfileTestTags.ERROR), + ) + } + + Spacer(Modifier.height(24.dp)) + OutlinedButton( + onClick = onSignOut, + modifier = Modifier + .padding(horizontal = 24.dp) + .fillMaxWidth() + .testTag(ProfileTestTags.SIGN_OUT), + ) { + Text("Sign out") + } + Spacer(Modifier.height(24.dp)) + } + + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(ProfileTestTags.PROGRESS)) + } + + else -> Box( + Modifier.fillMaxSize().padding(padding).padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.errorMessage ?: "Couldn't load your profile.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ProfileTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ProfileScreenPreview() { + InterlinedListTheme { + ProfileScreen( + state = ProfileUiState( + user = ProfileUser( + id = "1", + username = "adron", + displayName = "Adron Hall", + avatarUrl = null, + bio = "Building things at InterlinedList.", + customerStatus = CustomerStatus.SUBSCRIBER, + isCurrentUser = true, + ), + isLoading = false, + ), + onEditProfile = {}, + onSearchUsers = {}, + onSignOut = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..dad7020 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for a profile screen (current user or another user). */ +data class ProfileUiState( + val user: ProfileUser? = null, + val isLoading: Boolean = true, + val errorMessage: String? = null, +) { + /** No cached user and not loading — nothing to render yet. */ + val isEmpty: Boolean get() = user == null && !isLoading +} + +/** + * Drives the current signed-in user's profile (the "Account" tab). Seeds instantly + * from the Room cache, then refreshes from `GET /api/user`. + */ +@HiltViewModel +class ProfileViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + observeCurrentUser() + refresh() + } + + private fun observeCurrentUser() { + viewModelScope.launch { + repository.observeCurrentUser().collect { cached -> + if (cached != null) { + _uiState.update { it.copy(user = cached) } + } + } + } + } + + fun refresh() { + _uiState.update { it.copy(isLoading = it.user == null, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshCurrentUser()) { + is ApiResult.Success -> _uiState.update { + it.copy(user = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt new file mode 100644 index 0000000..1d7f140 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt @@ -0,0 +1,129 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.ProfileUser + +/** + * Another user's public profile, reached by drilling down from search (route + * `profile/{username}`). Includes a back affordance to ascend, mirroring the app's + * drill-down navigation pattern. + * + * @param onBack pop back to the previous screen (search). + */ +@Composable +fun UserProfileRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: UserProfileViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + UserProfileScreen( + state = state, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless other-user profile UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun UserProfileScreen( + state: ProfileUiState, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(state.user?.displayLabel ?: "Profile") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(ProfileTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.user != null -> ProfileContent( + user = state.user, + modifier = Modifier.fillMaxSize().padding(padding), + ) + + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(ProfileTestTags.PROGRESS)) + } + + else -> Box( + Modifier.fillMaxSize().padding(padding).padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = state.errorMessage ?: "Couldn't load this profile.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ProfileTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun UserProfileScreenPreview() { + InterlinedListTheme { + UserProfileScreen( + state = ProfileUiState( + user = ProfileUser( + id = "2", + username = "ada", + displayName = "Ada Lovelace", + avatarUrl = null, + bio = "First programmer.", + customerStatus = CustomerStatus.FREE, + isCurrentUser = false, + ), + isLoading = false, + ), + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt new file mode 100644 index 0000000..bb0f3e1 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav arg key the user-profile route reads its target username from. */ +const val PROFILE_USERNAME_ARG = "username" + +/** + * Drives another user's public profile, reached by drilling down from search. The + * target [username] is read from the [SavedStateHandle] nav arg (route + * `profile/{username}`). Seeds from the Room cache, then refreshes from + * `GET /api/users/{username}`. + */ +@HiltViewModel +class UserProfileViewModel @Inject constructor( + private val repository: ProfileRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val username: String = checkNotNull(savedStateHandle[PROFILE_USERNAME_ARG]) { + "UserProfileViewModel requires a '$PROFILE_USERNAME_ARG' nav arg" + } + + private val _uiState = MutableStateFlow(ProfileUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + observeUser() + refresh() + } + + private fun observeUser() { + viewModelScope.launch { + repository.observeUser(username).collect { cached -> + if (cached != null) { + _uiState.update { it.copy(user = cached) } + } + } + } + } + + fun refresh() { + _uiState.update { it.copy(isLoading = it.user == null, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshUser(username)) { + is ApiResult.Success -> _uiState.update { + it.copy(user = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchScreen.kt new file mode 100644 index 0000000..7711aa9 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchScreen.kt @@ -0,0 +1,203 @@ +package com.interlinedlist.android.feature.profile.ui.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import com.interlinedlist.android.feature.profile.ui.common.UserAvatar + +/** Stable test tags for user search. */ +object UserSearchTestTags { + const val QUERY = "userSearchQuery" + const val LIST = "userSearchList" + const val EMPTY = "userSearchEmpty" + const val PROGRESS = "userSearchProgress" + const val ERROR = "userSearchError" + const val BACK = "userSearchBack" + fun row(username: String) = "userSearchRow_$username" +} + +/** + * User-search route. Results drill down into a user's profile by username. + * + * @param onOpenUser navigate to `profile/{username}` for the tapped user. + * @param onBack pop back to the account screen. + */ +@Composable +fun UserSearchRoute( + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: UserSearchViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + UserSearchScreen( + state = state, + onQueryChange = viewModel::onQueryChange, + onOpenUser = onOpenUser, + onBack = onBack, + modifier = modifier, + ) +} + +/** Stateless user-search UI — a query field over a results list. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun UserSearchScreen( + state: UserSearchUiState, + onQueryChange: (String) -> Unit, + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Find people") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(UserSearchTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + OutlinedTextField( + value = state.query, + onValueChange = onQueryChange, + label = { Text("Search users") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (state.isSearching) { + CircularProgressIndicator( + modifier = Modifier + .size(20.dp) + .testTag(UserSearchTestTags.PROGRESS), + strokeWidth = 2.dp, + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(UserSearchTestTags.QUERY), + ) + + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(UserSearchTestTags.ERROR), + ) + } + + when { + state.isEmptyResult -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text( + text = "No users match \"${state.query.trim()}\".", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(UserSearchTestTags.EMPTY), + ) + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(UserSearchTestTags.LIST), + ) { + items(state.results, key = { it.id }) { user -> + UserSearchRow(user = user, onClick = { onOpenUser(user.username) }) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun UserSearchRow(user: UserSearchResult, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(UserSearchTestTags.row(user.username)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar(avatarUrl = user.avatarUrl, seedLabel = user.displayLabel, size = 40.dp) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = user.displayLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${user.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun UserSearchScreenPreview() { + InterlinedListTheme { + UserSearchScreen( + state = UserSearchUiState( + query = "ada", + results = listOf( + UserSearchResult("1", "ada", "Ada Lovelace", null), + UserSearchResult("2", "adron", "Adron Hall", null), + ), + ), + onQueryChange = {}, + onOpenUser = {}, + onBack = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchViewModel.kt new file mode 100644 index 0000000..e1b892f --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/search/UserSearchViewModel.kt @@ -0,0 +1,93 @@ +package com.interlinedlist.android.feature.profile.ui.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the user-search screen. */ +data class UserSearchUiState( + val query: String = "", + val results: List = emptyList(), + val isSearching: Boolean = false, + val errorMessage: String? = null, +) { + /** A query was run and came back empty (so the UI can say "no matches"). */ + val isEmptyResult: Boolean + get() = query.trim().length >= MIN_QUERY_LENGTH && results.isEmpty() && !isSearching && errorMessage == null + + companion object { + const val MIN_QUERY_LENGTH = 2 + } +} + +/** + * Backs user search. Debounces the query, runs `GET /api/users/search`, and exposes + * results the UI drills into (open a profile by username). + */ +@OptIn(FlowPreview::class) +@HiltViewModel +class UserSearchViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(UserSearchUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val queryFlow = MutableStateFlow("") + + init { + observeQuery() + } + + private fun observeQuery() { + viewModelScope.launch { + queryFlow + .debounce(DEBOUNCE_MILLIS) + .distinctUntilChanged() + .collect { query -> runSearch(query) } + } + } + + fun onQueryChange(value: String) { + _uiState.update { it.copy(query = value, errorMessage = null) } + if (value.trim().length < UserSearchUiState.MIN_QUERY_LENGTH) { + _uiState.update { it.copy(results = emptyList(), isSearching = false) } + } + queryFlow.value = value.trim() + } + + private suspend fun runSearch(query: String) { + if (query.length < UserSearchUiState.MIN_QUERY_LENGTH) { + _uiState.update { it.copy(results = emptyList(), isSearching = false) } + return + } + _uiState.update { it.copy(isSearching = true, errorMessage = null) } + when (val result = repository.searchUsers(query)) { + is ApiResult.Success -> _uiState.update { + it.copy(isSearching = false, results = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSearching = false, results = emptyList(), errorMessage = result.error.toUserMessage()) + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + private companion object { + const val DEBOUNCE_MILLIS = 300L + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt new file mode 100644 index 0000000..917003a --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt @@ -0,0 +1,259 @@ +package com.interlinedlist.android.feature.profile.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.data.remote.ProfileApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultProfileRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: ProfileApi + private lateinit var dao: FakeProfileDao + private lateinit var repository: DefaultProfileRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(ProfileApi::class.java) + dao = FakeProfileDao() + repository = DefaultProfileRepository(api, dao, json, dispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `refreshCurrentUser parses the wrapped user and caches it as current`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "user": { + "id": "u1", + "username": "adron", + "displayName": "Adron Hall", + "avatar": "https://cdn/av.png", + "bio": "Building things.", + "customerStatus": "subscriber:annual" + } + } + """.trimIndent(), + ), + ) + + val result = repository.refreshCurrentUser() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val user = (result as ApiResult.Success).data + assertThat(user.username).isEqualTo("adron") + assertThat(user.avatarUrl).isEqualTo("https://cdn/av.png") + assertThat(user.customerStatus).isEqualTo(CustomerStatus.SUBSCRIBER_ANNUAL) + assertThat(user.isCurrentUser).isTrue() + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("GET") + assertThat(recorded.path).isEqualTo("/api/user") + + val cached = repository.observeCurrentUser().first() + assertThat(cached?.username).isEqualTo("adron") + } + + @Test + fun `refreshCurrentUser maps a 401 to Unauthorized`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{ "error": "Session expired." }""")) + + val result = repository.refreshCurrentUser() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Unauthorized::class.java) + } + + @Test + fun `refreshUser fetches another user by username and caches without the current flag`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u2", "username": "ada", "displayName": "Ada Lovelace" } }""", + ), + ) + + val result = repository.refreshUser("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val user = (result as ApiResult.Success).data + assertThat(user.username).isEqualTo("ada") + assertThat(user.isCurrentUser).isFalse() + + val recorded = server.takeRequest() + assertThat(recorded.path).isEqualTo("/api/users/ada") + + assertThat(repository.observeUser("ada").first()?.displayName).isEqualTo("Ada Lovelace") + // A viewed user must not become the observed "current" user. + assertThat(repository.observeCurrentUser().first()).isNull() + } + + @Test + fun `refreshUser maps a 404 to NotFound`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "No such user" }""")) + + val result = repository.refreshUser("ghost") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `updateProfile PATCHes the fields and updates the cache`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "displayName": "New Name", "bio": "New bio" } }""", + ), + ) + + val result = repository.updateProfile(displayName = "New Name", bio = "New bio") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.displayName).isEqualTo("New Name") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PATCH") + assertThat(recorded.path).isEqualTo("/api/user/update") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"displayName\":\"New Name\"") + assertThat(body).contains("\"bio\":\"New bio\"") + + assertThat(repository.observeCurrentUser().first()?.displayName).isEqualTo("New Name") + } + + @Test + fun `updateProfile re-fetches when the server echoes a thin body`() = runTest(testDispatcher) { + // PATCH returns nothing useful... + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + // ...so the repo re-fetches the full user. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "displayName": "Fresh", "bio": "b" } }""", + ), + ) + + val result = repository.updateProfile(displayName = "Fresh", bio = "b") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.displayName).isEqualTo("Fresh") + + assertThat(server.takeRequest().path).isEqualTo("/api/user/update") + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `setAvatarFromUrl posts the url then refreshes the cached user`() = runTest(testDispatcher) { + // Avatar endpoint returns just a URL... + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "avatarUrl": "https://cdn/new.png" }""")) + // ...and the repo refreshes the full user. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "avatar": "https://cdn/new.png" } }""", + ), + ) + + val result = repository.setAvatarFromUrl("https://cdn/new.png") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.avatarUrl).isEqualTo("https://cdn/new.png") + + val avatarRequest = server.takeRequest() + assertThat(avatarRequest.method).isEqualTo("POST") + assertThat(avatarRequest.path).isEqualTo("/api/user/avatar/from-url") + assertThat(avatarRequest.body.readUtf8()).contains("\"url\":\"https://cdn/new.png\"") + + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `uploadAvatar posts multipart then refreshes the cached user`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "avatarUrl": "https://cdn/up.png" }""")) + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "avatar": "https://cdn/up.png" } }""", + ), + ) + + val result = repository.uploadAvatar( + bytes = byteArrayOf(1, 2, 3, 4), + fileName = "avatar.png", + mimeType = "image/png", + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.avatarUrl).isEqualTo("https://cdn/up.png") + + val uploadRequest = server.takeRequest() + assertThat(uploadRequest.method).isEqualTo("POST") + assertThat(uploadRequest.path).isEqualTo("/api/user/avatar/upload") + assertThat(uploadRequest.getHeader("Content-Type")).contains("multipart/form-data") + } + + @Test + fun `searchUsers passes the query and limit and maps results`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "users": [ { "id": "1", "username": "ada", "displayName": "Ada" }, { "id": "2", "username": "adron" } ] }""", + ), + ) + + val result = repository.searchUsers("ad") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val users = (result as ApiResult.Success).data + assertThat(users.map { it.username }).containsExactly("ada", "adron").inOrder() + + val recorded = server.takeRequest() + assertThat(recorded.path).isEqualTo("/api/users/search?q=ad&limit=20") + } + + @Test + fun `searchUsers reads the generic data envelope too`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "data": [ { "id": "1", "username": "ada" } ] }""", + ), + ) + + val result = repository.searchUsers("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.single().username).isEqualTo("ada") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FakeProfileDao.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FakeProfileDao.kt new file mode 100644 index 0000000..9f6827c --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FakeProfileDao.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.profile.data + +import com.interlinedlist.android.feature.profile.data.local.ProfileDao +import com.interlinedlist.android.feature.profile.data.local.ProfileEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory [ProfileDao] mirroring the real DAO's query semantics, so repository + * tests can assert cache writes without Room/Robolectric. + */ +class FakeProfileDao : ProfileDao { + private val rows = MutableStateFlow>(emptyList()) + + fun snapshot(): List = rows.value + + override fun observeCurrentUser(): Flow = + rows.map { list -> list.firstOrNull { it.isCurrentUser } } + + override fun observeByUsername(username: String): Flow = + rows.map { list -> list.firstOrNull { it.username == username } } + + override suspend fun getByUsername(username: String): ProfileEntity? = + rows.value.firstOrNull { it.username == username } + + override suspend fun upsert(profile: ProfileEntity) { + rows.value = rows.value.filterNot { it.username == profile.username } + profile + } + + override suspend fun clearCurrentUserFlag() { + rows.value = rows.value.map { if (it.isCurrentUser) it.copy(isCurrentUser = false) else it } + } + + override suspend fun clear() { + rows.value = emptyList() + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/ProfileMappersTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/ProfileMappersTest.kt new file mode 100644 index 0000000..a4f1552 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/ProfileMappersTest.kt @@ -0,0 +1,90 @@ +package com.interlinedlist.android.feature.profile.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.data.mapper.toProfileUser +import com.interlinedlist.android.feature.profile.data.mapper.toSearchResult +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import org.junit.Test + +class ProfileMappersTest { + + @Test + fun `profile user maps all fields and resolves customer status`() { + val dto = ProfileUserDto( + id = "u1", + username = "adron", + displayName = "Adron Hall", + avatarUrl = "https://cdn/av.png", + bio = "Hello", + customerStatus = "subscriber", + ) + + val user = dto.toProfileUser(isCurrentUser = true) + + assertThat(user.id).isEqualTo("u1") + assertThat(user.username).isEqualTo("adron") + assertThat(user.displayName).isEqualTo("Adron Hall") + assertThat(user.avatarUrl).isEqualTo("https://cdn/av.png") + assertThat(user.bio).isEqualTo("Hello") + assertThat(user.customerStatus).isEqualTo(CustomerStatus.SUBSCRIBER) + assertThat(user.isSubscriber).isTrue() + assertThat(user.isCurrentUser).isTrue() + } + + @Test + fun `avatar falls back to the legacy avatar field when avatarUrl is absent`() { + val dto = ProfileUserDto(id = "u1", username = "x", avatar = "https://cdn/legacy.png") + assertThat(dto.toProfileUser(isCurrentUser = false).avatarUrl).isEqualTo("https://cdn/legacy.png") + } + + @Test + fun `display label prefers display name and falls back to at-username`() { + val named = ProfileUserDto(id = "1", username = "adron", displayName = "Adron").toProfileUser(false) + assertThat(named.displayLabel).isEqualTo("Adron") + + val unnamed = ProfileUserDto(id = "2", username = "adron", displayName = null).toProfileUser(false) + assertThat(unnamed.displayLabel).isEqualTo("@adron") + + val blank = ProfileUserDto(id = "3", username = "adron", displayName = " ").toProfileUser(false) + assertThat(blank.displayLabel).isEqualTo("@adron") + } + + @Test + fun `unknown customer status is not treated as a subscriber`() { + val user = ProfileUserDto(id = "1", username = "x", customerStatus = null).toProfileUser(false) + assertThat(user.customerStatus).isEqualTo(CustomerStatus.UNKNOWN) + assertThat(user.isSubscriber).isFalse() + } + + @Test + fun `search result maps to a lightweight entry`() { + val dto = ProfileUserDto(id = "1", username = "ada", displayName = "Ada", avatar = "u") + val result = dto.toSearchResult() + assertThat(result.id).isEqualTo("1") + assertThat(result.username).isEqualTo("ada") + assertThat(result.displayLabel).isEqualTo("Ada") + assertThat(result.avatarUrl).isEqualTo("u") + } + + @Test + fun `response resolves the wrapped user object`() { + val response = ProfileResponse(user = ProfileUserDto(id = "u1", username = "adron")) + assertThat(response.userOrSelf?.id).isEqualTo("u1") + } + + @Test + fun `response tolerates an inlined top-level user`() { + val response = ProfileResponse(id = "u2", username = "ada", displayName = "Ada") + val user = response.userOrSelf + assertThat(user?.id).isEqualTo("u2") + assertThat(user?.username).isEqualTo("ada") + assertThat(user?.displayName).isEqualTo("Ada") + } + + @Test + fun `response is null when there is no user payload at all`() { + assertThat(ProfileResponse().userOrSelf).isNull() + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/EditProfileViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/EditProfileViewModelTest.kt new file mode 100644 index 0000000..d4d4766 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/EditProfileViewModelTest.kt @@ -0,0 +1,135 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.edit.EditProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class EditProfileViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `seeds the form from the current user`() = runTest(dispatcher) { + val user = testUser(displayName = "Adron Hall", bio = "Building things.") + repo.refreshCurrentUserResult = ApiResult.Success(user) + repo.currentUserFlow.value = user + + val vm = EditProfileViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.displayName).isEqualTo("Adron Hall") + assertThat(vm.uiState.value.bio).isEqualTo("Building things.") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `editing marks unsaved changes and enables save`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Success(testUser()) + val vm = EditProfileViewModel(repo) + advanceUntilIdle() + + vm.onDisplayNameChange("New Name") + + assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() + assertThat(vm.uiState.value.canSave).isTrue() + } + + @Test + fun `save sends trimmed fields and invokes onSaved`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Success(testUser()) + repo.updateResult = ApiResult.Success(testUser(displayName = "New Name", bio = "New bio")) + val vm = EditProfileViewModel(repo) + advanceUntilIdle() + + vm.onDisplayNameChange(" New Name ") + vm.onBioChange(" New bio ") + var saved = false + vm.save { saved = true } + advanceUntilIdle() + + assertThat(repo.lastUpdate).isEqualTo("New Name" to "New bio") + assertThat(saved).isTrue() + assertThat(vm.uiState.value.hasUnsavedChanges).isFalse() + } + + @Test + fun `save failure surfaces an error and keeps unsaved changes`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Success(testUser()) + repo.updateResult = ApiResult.Failure(AppError.Server("boom")) + val vm = EditProfileViewModel(repo) + advanceUntilIdle() + + vm.onBioChange("changed") + var saved = false + vm.save { saved = true } + advanceUntilIdle() + + assertThat(saved).isFalse() + assertThat(vm.uiState.value.errorMessage).isEqualTo("InterlinedList is having trouble right now. Try again shortly.") + assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() + } + + @Test + fun `set avatar from url passes the typed url and updates the avatar`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Success(testUser()) + repo.avatarFromUrlResult = ApiResult.Success(testUser(avatarUrl = "https://cdn/new.png")) + val vm = EditProfileViewModel(repo) + advanceUntilIdle() + + vm.onAvatarUrlInputChange("https://cdn/new.png") + vm.setAvatarFromUrl() + advanceUntilIdle() + + assertThat(repo.lastAvatarUrl).isEqualTo("https://cdn/new.png") + assertThat(vm.uiState.value.avatarUrl).isEqualTo("https://cdn/new.png") + assertThat(vm.uiState.value.avatarUrlInput).isEmpty() + } + + @Test + fun `upload avatar forwards the bytes and metadata`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Success(testUser()) + repo.uploadAvatarResult = ApiResult.Success(testUser(avatarUrl = "https://cdn/up.png")) + val vm = EditProfileViewModel(repo) + advanceUntilIdle() + + vm.uploadAvatar(byteArrayOf(1, 2, 3), "avatar.png", "image/png") + advanceUntilIdle() + + assertThat(repo.lastUpload).isEqualTo(FakeProfileRepository.Upload("avatar.png", "image/png", 3)) + assertThat(vm.uiState.value.avatarUrl).isEqualTo("https://cdn/up.png") + assertThat(vm.uiState.value.isUploadingAvatar).isFalse() + } + + @Test + fun `refresh does not clobber in-progress edits`() = runTest(dispatcher) { + // The refresh completes after the user has already typed. + repo.refreshCurrentUserResult = ApiResult.Success(testUser(displayName = "Server Name")) + val vm = EditProfileViewModel(repo) + vm.onDisplayNameChange("My Draft") + advanceUntilIdle() + + assertThat(vm.uiState.value.displayName).isEqualTo("My Draft") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt new file mode 100644 index 0000000..8da0f56 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt @@ -0,0 +1,94 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory [ProfileRepository] for ViewModel tests. Backed by simple StateFlows so + * tests can observe the same reactive behaviour as Room without a device. Failure + * modes are injectable per operation. + */ +class FakeProfileRepository : ProfileRepository { + + val currentUserFlow = MutableStateFlow(null) + val userFlow = MutableStateFlow(null) + + var refreshCurrentUserResult: ApiResult? = null + var refreshUserResult: ApiResult? = null + var updateResult: ApiResult? = null + var avatarFromUrlResult: ApiResult? = null + var uploadAvatarResult: ApiResult? = null + var searchResult: ApiResult> = ApiResult.Success(emptyList()) + + var refreshCurrentUserCount = 0 + var lastUpdate: Pair? = null + var lastAvatarUrl: String? = null + var lastUpload: Upload? = null + var lastSearchQuery: String? = null + + data class Upload(val fileName: String, val mimeType: String, val size: Int) + + override fun observeCurrentUser() = currentUserFlow.map { it } + + override fun observeUser(username: String) = userFlow.map { it } + + override suspend fun refreshCurrentUser(): ApiResult { + refreshCurrentUserCount++ + return refreshCurrentUserResult ?: ApiResult.Failure(AppError.Network("not set")) + } + + override suspend fun refreshUser(username: String): ApiResult = + refreshUserResult ?: ApiResult.Failure(AppError.NotFound("not set")) + + override suspend fun updateProfile(displayName: String, bio: String): ApiResult { + lastUpdate = displayName to bio + return updateResult ?: ApiResult.Failure(AppError.Unknown("not set")) + } + + override suspend fun setAvatarFromUrl(url: String): ApiResult { + lastAvatarUrl = url + return avatarFromUrlResult ?: ApiResult.Failure(AppError.Unknown("not set")) + } + + override suspend fun uploadAvatar(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { + lastUpload = Upload(fileName, mimeType, bytes.size) + return uploadAvatarResult ?: ApiResult.Failure(AppError.Unknown("not set")) + } + + override suspend fun searchUsers(query: String): ApiResult> { + lastSearchQuery = query + return searchResult + } +} + +/** Shorthand for building a domain profile user in tests. */ +fun testUser( + id: String = "u1", + username: String = "adron", + displayName: String? = "Adron Hall", + avatarUrl: String? = null, + bio: String? = "bio", + customerStatus: CustomerStatus = CustomerStatus.FREE, + isCurrentUser: Boolean = true, +) = ProfileUser( + id = id, + username = username, + displayName = displayName, + avatarUrl = avatarUrl, + bio = bio, + customerStatus = customerStatus, + isCurrentUser = isCurrentUser, +) + +/** Shorthand for building a search result in tests. */ +fun testSearchResult( + id: String, + username: String = "user$id", + displayName: String? = "User $id", +) = UserSearchResult(id = id, username = username, displayName = displayName, avatarUrl = null) diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt new file mode 100644 index 0000000..c375b1e --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt @@ -0,0 +1,84 @@ +package com.interlinedlist.android.feature.profile.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.profile.ProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ProfileViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `emits the current user from the room flow and clears loading`() = runTest(dispatcher) { + val user = testUser(username = "adron", displayName = "Adron Hall") + repo.refreshCurrentUserResult = ApiResult.Success(user) + repo.currentUserFlow.value = user + + val vm = ProfileViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.user?.username).isEqualTo("adron") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = ProfileViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `cached user still shows even when the refresh fails offline`() = runTest(dispatcher) { + val cached = testUser(username = "adron") + repo.currentUserFlow.value = cached + repo.refreshCurrentUserResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = ProfileViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.user?.username).isEqualTo("adron") + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `state updates via Turbine when the cached user changes`() = runTest(dispatcher) { + repo.refreshCurrentUserResult = ApiResult.Success(testUser()) + + val vm = ProfileViewModel(repo) + advanceUntilIdle() + + vm.uiState.test { + assertThat(awaitItem().user?.displayName).isEqualTo("Adron Hall") + repo.currentUserFlow.value = testUser(displayName = "Renamed") + assertThat(awaitItem().user?.displayName).isEqualTo("Renamed") + } + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt new file mode 100644 index 0000000..9a5cd1a --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt @@ -0,0 +1,72 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.profile.PROFILE_USERNAME_ARG +import com.interlinedlist.android.feature.profile.ui.profile.UserProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UserProfileViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(username: String) = + UserProfileViewModel(repo, SavedStateHandle(mapOf(PROFILE_USERNAME_ARG to username))) + + @Test + fun `loads the user named by the nav arg`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", displayName = "Ada Lovelace", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.userFlow.value = ada + + val vm = viewModel("ada") + advanceUntilIdle() + + assertThat(vm.uiState.value.user?.username).isEqualTo("ada") + assertThat(vm.uiState.value.user?.isCurrentUser).isFalse() + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `a missing user surfaces a not-found message`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Failure(AppError.NotFound("No such user")) + + val vm = viewModel("ghost") + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No such user") + assertThat(vm.uiState.value.user).isNull() + } + + @Test + fun `missing nav arg fails fast`() { + try { + UserProfileViewModel(repo, SavedStateHandle()) + throw AssertionError("Expected IllegalStateException for missing nav arg") + } catch (e: IllegalStateException) { + assertThat(e).hasMessageThat().contains(PROFILE_USERNAME_ARG) + } + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserSearchViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserSearchViewModelTest.kt new file mode 100644 index 0000000..ab39865 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserSearchViewModelTest.kt @@ -0,0 +1,99 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.search.UserSearchViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UserSearchViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `debounced query runs a search and exposes results`() = runTest(dispatcher) { + repo.searchResult = ApiResult.Success(listOf(testSearchResult("1", "ada"), testSearchResult("2", "adron"))) + val vm = UserSearchViewModel(repo) + + vm.onQueryChange("ad") + advanceTimeBy(400) + advanceUntilIdle() + + assertThat(repo.lastSearchQuery).isEqualTo("ad") + assertThat(vm.uiState.value.results.map { it.username }).containsExactly("ada", "adron").inOrder() + assertThat(vm.uiState.value.isSearching).isFalse() + } + + @Test + fun `queries shorter than the minimum length do not hit the api`() = runTest(dispatcher) { + val vm = UserSearchViewModel(repo) + + vm.onQueryChange("a") + advanceTimeBy(400) + advanceUntilIdle() + + assertThat(repo.lastSearchQuery).isNull() + assertThat(vm.uiState.value.results).isEmpty() + } + + @Test + fun `empty result over a valid query flags isEmptyResult`() = runTest(dispatcher) { + repo.searchResult = ApiResult.Success(emptyList()) + val vm = UserSearchViewModel(repo) + + vm.onQueryChange("zzz") + advanceTimeBy(400) + advanceUntilIdle() + + assertThat(vm.uiState.value.isEmptyResult).isTrue() + } + + @Test + fun `search failure surfaces a mapped error and clears results`() = runTest(dispatcher) { + repo.searchResult = ApiResult.Failure(AppError.Network("offline")) + val vm = UserSearchViewModel(repo) + + vm.onQueryChange("ada") + advanceTimeBy(400) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + assertThat(vm.uiState.value.results).isEmpty() + } + + @Test + fun `rapid typing debounces to a single search`() = runTest(dispatcher) { + repo.searchResult = ApiResult.Success(listOf(testSearchResult("1", "ada"))) + val vm = UserSearchViewModel(repo) + + vm.onQueryChange("a") + vm.onQueryChange("ad") + vm.onQueryChange("ada") + advanceTimeBy(400) + advanceUntilIdle() + + // Only the final settled query should have been searched. + assertThat(repo.lastSearchQuery).isEqualTo("ada") + } +} From e3e7490475fda3e6f84d9a963b7bcf85722b83cc Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:30:18 -0700 Subject: [PATCH 05/25] feat: order home tabs Messages, Lists, Documents (match web app) Reorders the bottom navigation and defaults the landing tab to Messages, mirroring how interlinedlist.com organizes the sections. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/navigation/InterlinedListNavHost.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 96e8643..2de5f66 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -82,10 +82,13 @@ object Routes { fun userProfile(username: String) = "user/$username" } -/** The four post-login home tabs shown in the bottom navigation bar. */ +/** + * The four post-login home tabs shown in the bottom navigation bar. Order + * mirrors the web app: Messages, Lists, Documents (then Account). + */ private enum class HomeTab(val route: String, val label: String, val icon: ImageVector) { - Lists(Routes.LISTS, "Lists", Icons.AutoMirrored.Filled.List), Messages(Routes.MESSAGES, "Messages", Icons.Filled.Forum), + Lists(Routes.LISTS, "Lists", Icons.AutoMirrored.Filled.List), Documents(Routes.DOCUMENTS, "Documents", Icons.Filled.Description), Account(Routes.ACCOUNT, "Account", Icons.Filled.AccountCircle), } @@ -162,7 +165,7 @@ private fun MainShell(onLoggedOut: () -> Unit) { ) { padding -> NavHost( navController = tabNav, - startDestination = Routes.LISTS, + startDestination = Routes.MESSAGES, modifier = Modifier.padding(padding), ) { // ---- Lists ---- From 354cd12b751de7c1b444e2cfe6c28f1570821f34 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:51:52 -0700 Subject: [PATCH 06/25] feat(lists): wire a Connections entry point on the Lists screen Adds a Hub action to the Lists top bar that opens the connections screen (ConnectionsRoute was built and tested but previously unreachable). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/navigation/InterlinedListNavHost.kt | 5 ++++- .../feature/lists/ui/list/ListsScreenTest.kt | 1 + .../feature/lists/ui/list/ListsScreen.kt | 17 ++++++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 2de5f66..0576057 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -170,7 +170,10 @@ private fun MainShell(onLoggedOut: () -> Unit) { ) { // ---- Lists ---- composable(Routes.LISTS) { - ListsRoute(onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }) + ListsRoute( + onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, + onOpenConnections = { tabNav.navigate(Routes.LIST_CONNECTIONS) }, + ) } composable( Routes.LIST_DETAIL, diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt index 20c2ad5..7d82738 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreenTest.kt @@ -31,6 +31,7 @@ class ListsScreenTest { ListsScreen( state = state, onOpenList = onOpenList, + onOpenConnections = {}, onSearchQueryChange = {}, onLoadMore = {}, onCreateList = {}, diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt index e1b7a1d..b5923ee 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt @@ -14,7 +14,9 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.IconButton import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -57,6 +59,7 @@ object ListsTestTags { @Composable fun ListsRoute( onOpenList: (String) -> Unit, + onOpenConnections: () -> Unit, modifier: Modifier = Modifier, viewModel: ListsViewModel = hiltViewModel(), ) { @@ -64,6 +67,7 @@ fun ListsRoute( ListsScreen( state = state, onOpenList = onOpenList, + onOpenConnections = onOpenConnections, onSearchQueryChange = viewModel::onSearchQueryChange, onLoadMore = viewModel::loadMore, onCreateList = { title -> viewModel.createList(title, description = null, onCreated = { onOpenList(it.id) }) }, @@ -77,6 +81,7 @@ fun ListsRoute( fun ListsScreen( state: ListsUiState, onOpenList: (String) -> Unit, + onOpenConnections: () -> Unit, onSearchQueryChange: (String) -> Unit, onLoadMore: () -> Unit, onCreateList: (String) -> Unit, @@ -84,7 +89,16 @@ fun ListsScreen( ) { Scaffold( modifier = modifier.fillMaxSize(), - topBar = { TopAppBar(title = { Text("Lists") }) }, + topBar = { + TopAppBar( + title = { Text("Lists") }, + actions = { + IconButton(onClick = onOpenConnections) { + Icon(Icons.Default.Hub, contentDescription = "List connections") + } + }, + ) + }, floatingActionButton = { if (!state.subscriptionRequired) { ExtendedFloatingActionButton( @@ -286,6 +300,7 @@ private fun ListsScreenPreview() { ), ), onOpenList = {}, + onOpenConnections = {}, onSearchQueryChange = {}, onLoadMore = {}, onCreateList = {}, From 4b51649b565a5ae282b07b5e736f803bd614688f Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 13:52:29 -0700 Subject: [PATCH 07/25] chore: scaffold notifications/organizations/integrations feature modules Empty android-library modules for Phases 5/7/8, registered and verified to assemble, so parallel worktree agents branch from a base that includes them. Co-Authored-By: Claude Opus 4.8 (1M context) --- feature/integrations/build.gradle.kts | 82 ++++++++++++++++++++++++++ feature/notifications/build.gradle.kts | 82 ++++++++++++++++++++++++++ feature/organizations/build.gradle.kts | 82 ++++++++++++++++++++++++++ settings.gradle.kts | 3 + 4 files changed, 249 insertions(+) create mode 100644 feature/integrations/build.gradle.kts create mode 100644 feature/notifications/build.gradle.kts create mode 100644 feature/organizations/build.gradle.kts diff --git a/feature/integrations/build.gradle.kts b/feature/integrations/build.gradle.kts new file mode 100644 index 0000000..43eab58 --- /dev/null +++ b/feature/integrations/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.integrations" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + // Photo Picker (rememberLauncherForActivityResult) for document image uploads. + implementation(libs.androidx.activity.compose) + + // This module owns its own Room cache (see DocumentsDatabase) — it must not + // reuse the shared :core:database, so it pulls Room in directly. + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.coil.compose) + + implementation(libs.retrofit.core) + // okhttp is used directly for multipart image uploads (MultipartBody / RequestBody). + implementation(libs.okhttp.core) + implementation(libs.kotlinx.serialization.json) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/notifications/build.gradle.kts b/feature/notifications/build.gradle.kts new file mode 100644 index 0000000..827ee9f --- /dev/null +++ b/feature/notifications/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.notifications" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + // Photo Picker (rememberLauncherForActivityResult) for document image uploads. + implementation(libs.androidx.activity.compose) + + // This module owns its own Room cache (see DocumentsDatabase) — it must not + // reuse the shared :core:database, so it pulls Room in directly. + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.coil.compose) + + implementation(libs.retrofit.core) + // okhttp is used directly for multipart image uploads (MultipartBody / RequestBody). + implementation(libs.okhttp.core) + implementation(libs.kotlinx.serialization.json) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/organizations/build.gradle.kts b/feature/organizations/build.gradle.kts new file mode 100644 index 0000000..33242dc --- /dev/null +++ b/feature/organizations/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.organizations" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + // Photo Picker (rememberLauncherForActivityResult) for document image uploads. + implementation(libs.androidx.activity.compose) + + // This module owns its own Room cache (see DocumentsDatabase) — it must not + // reuse the shared :core:database, so it pulls Room in directly. + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + implementation(libs.coil.compose) + + implementation(libs.retrofit.core) + // okhttp is used directly for multipart image uploads (MultipartBody / RequestBody). + implementation(libs.okhttp.core) + implementation(libs.kotlinx.serialization.json) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index c70d80a..354d17f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,3 +39,6 @@ include(":feature:lists") include(":feature:messages") include(":feature:documents") include(":feature:profile") +include(":feature:notifications") +include(":feature:organizations") +include(":feature:integrations") From da1e1c548d001deef959d4ecc92a2f8dc0b1db67 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 14:24:18 -0700 Subject: [PATCH 08/25] feat: following + notifications + organizations + integrations (Phases 5/7/8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates four modules and restructures the Account tab into a hub: - profile: Following — follow/unfollow, follower/following lists, follow requests; Account screen becomes a hub (Followers/Following/Requests/Notifications/ Organizations/Integrations/Edit/Search/Sign out) (77 tests) - notifications (new): polled notifications list, mark read / mark-all / dismiss (42 tests) - organizations (new, Phase 7): org list/create + detail + member management (37 tests) - integrations (new, Phase 8): CSV data exports via share sheet + read-only connected-accounts status; OAuth connect flows deferred (13 tests) Nav rewired: Account hub wires Notifications/Organizations/Integrations to the new modules; followers/following/user-profile drill-down; ProfileRoute's follower/following callbacks pass the current username. Feature UI uses MaterialTheme.colorScheme roles (no brand-color constants), so it is independent of the design-system palette. Built by four parallel worktree agents (resynced to origin/dev), then integrated. :app:assembleDebug + full testDebugUnitTest green (366 unit tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 3 + .../navigation/InterlinedListNavHost.kt | 144 +++++- feature/integrations/build.gradle.kts | 14 +- .../src/androidTest/AndroidManifest.xml | 2 + .../ui/IntegrationsScreensTest.kt | 95 ++++ .../integrations/src/main/AndroidManifest.xml | 20 + .../data/DefaultIntegrationsRepository.kt | 62 +++ .../integrations/data/ExportFileStore.kt | 14 + .../data/IntegrationsRepository.kt | 27 + .../integrations/data/mapper/LimitsMapper.kt | 33 ++ .../data/remote/IntegrationsApi.kt | 35 ++ .../data/remote/dto/ConnectionStatusDto.kt | 28 + .../integrations/data/remote/dto/LimitsDto.kt | 28 + .../integrations/di/IntegrationsModule.kt | 48 ++ .../integrations/domain/ConnectedAccount.kt | 22 + .../feature/integrations/domain/ExportType.kt | 38 ++ .../feature/integrations/domain/PlanLimits.kt | 24 + .../ui/IntegrationsErrorMessages.kt | 14 + .../ui/accounts/ConnectedAccountsScreen.kt | 160 ++++++ .../ui/accounts/ConnectedAccountsViewModel.kt | 38 ++ .../integrations/ui/export/ExportScreen.kt | 198 +++++++ .../integrations/ui/export/ExportSharing.kt | 31 ++ .../integrations/ui/export/ExportViewModel.kt | 65 +++ .../ui/hub/IntegrationsHubScreen.kt | 222 ++++++++ .../ui/hub/IntegrationsHubViewModel.kt | 46 ++ .../src/main/res/xml/file_paths.xml | 7 + .../data/DefaultIntegrationsRepositoryTest.kt | 182 +++++++ .../ui/FakeIntegrationsRepository.kt | 33 ++ .../ConnectedAccountsViewModelTest.kt | 59 +++ .../ui/export/ExportViewModelTest.kt | 95 ++++ .../ui/hub/IntegrationsHubViewModelTest.kt | 58 +++ .../src/androidTest/AndroidManifest.xml | 3 + .../ui/NotificationsScreenTest.kt | 131 +++++ .../data/DefaultNotificationsRepository.kt | 104 ++++ .../data/NotificationsRepository.kt | 44 ++ .../data/local/NotificationDao.kt | 49 ++ .../data/local/NotificationEntity.kt | 78 +++ .../data/local/NotificationsDatabase.kt | 17 + .../data/remote/NotificationsApi.kt | 41 ++ .../data/remote/dto/NotificationDto.kt | 136 +++++ .../data/remote/dto/NotificationsResponse.kt | 37 ++ .../notifications/di/NotificationsModule.kt | 59 +++ .../notifications/domain/Notification.kt | 114 ++++ .../ui/NotificationsErrorMessages.kt | 18 + .../notifications/ui/NotificationsScreen.kt | 320 ++++++++++++ .../ui/NotificationsViewModel.kt | 145 ++++++ .../feature/notifications/ui/RelativeTime.kt | 27 + .../ui/components/NotificationRow.kt | 175 +++++++ .../DefaultNotificationsRepositoryTest.kt | 240 +++++++++ .../notifications/data/FakeNotificationDao.kt | 60 +++ .../feature/notifications/data/TestDoubles.kt | 11 + .../remote/dto/NotificationDtoMapperTest.kt | 148 ++++++ .../remote/dto/NotificationsResponseTest.kt | 38 ++ .../ui/FakeNotificationsRepository.kt | 94 ++++ .../ui/NotificationsViewModelTest.kt | 217 ++++++++ .../notifications/ui/RelativeTimeTest.kt | 39 ++ .../src/androidTest/AndroidManifest.xml | 2 + .../ui/detail/OrganizationDetailScreenTest.kt | 100 ++++ .../ui/list/OrganizationsScreenTest.kt | 89 ++++ .../src/main/AndroidManifest.xml | 2 + .../data/DefaultOrganizationsRepository.kt | 198 +++++++ .../organizations/data/MemberMapper.kt | 38 ++ .../organizations/data/OrganizationMapper.kt | 43 ++ .../data/OrganizationsRepository.kt | 71 +++ .../data/local/CachedOrganizationEntity.kt | 21 + .../data/local/OrganizationDao.kt | 38 ++ .../data/local/OrganizationsDatabase.kt | 18 + .../data/remote/OrganizationsApi.kt | 86 ++++ .../data/remote/dto/FlexibleBoolean.kt | 37 ++ .../data/remote/dto/MemberDtos.kt | 71 +++ .../data/remote/dto/OrganizationDtos.kt | 94 ++++ .../organizations/di/OrganizationsModule.kt | 53 ++ .../feature/organizations/domain/OrgMember.kt | 53 ++ .../organizations/domain/Organization.kt | 33 ++ .../ui/OrganizationsErrorMessages.kt | 17 + .../ui/detail/OrganizationDetailScreen.kt | 485 ++++++++++++++++++ .../ui/detail/OrganizationDetailViewModel.kt | 185 +++++++ .../ui/list/OrganizationsScreen.kt | 378 ++++++++++++++ .../ui/list/OrganizationsViewModel.kt | 134 +++++ .../FakeOrganizationsRepository.kt | 115 +++++ .../DefaultOrganizationsRepositoryTest.kt | 266 ++++++++++ .../data/FlexibleBooleanSerializerTest.kt | 41 ++ .../organizations/data/MemberMapperTest.kt | 61 +++ .../data/OrganizationMapperTest.kt | 62 +++ .../detail/OrganizationDetailViewModelTest.kt | 177 +++++++ .../ui/list/OrganizationsViewModelTest.kt | 142 +++++ .../feature/profile/ui/FollowScreensTest.kt | 97 ++++ .../feature/profile/ui/ProfileScreenTest.kt | 96 ++-- .../profile/data/DefaultProfileRepository.kt | 79 +++ .../feature/profile/data/ProfileRepository.kt | 39 ++ .../profile/data/mapper/FollowMappers.kt | 34 ++ .../feature/profile/data/remote/ProfileApi.kt | 55 ++ .../data/remote/dto/FollowResponses.kt | 111 ++++ .../feature/profile/domain/FollowCounts.kt | 11 + .../feature/profile/domain/FollowStatus.kt | 20 + .../feature/profile/domain/FollowUser.kt | 17 + .../profile/ui/common/ProfileComponents.kt | 8 +- .../profile/ui/follow/FollowListScreen.kt | 212 ++++++++ .../profile/ui/follow/FollowListViewModel.kt | 87 ++++ .../profile/ui/follow/FollowRequestsScreen.kt | 223 ++++++++ .../ui/follow/FollowRequestsViewModel.kt | 86 ++++ .../profile/ui/profile/ProfileContent.kt | 130 ++++- .../profile/ui/profile/ProfileScreen.kt | 207 ++++++-- .../profile/ui/profile/ProfileViewModel.kt | 33 +- .../profile/ui/profile/UserProfileScreen.kt | 23 +- .../ui/profile/UserProfileViewModel.kt | 70 ++- .../data/DefaultProfileRepositoryTest.kt | 212 ++++++++ .../feature/profile/data/FollowMappersTest.kt | 77 +++ .../profile/ui/FakeProfileRepository.kt | 89 ++++ .../profile/ui/FollowListViewModelTest.kt | 95 ++++ .../profile/ui/FollowRequestsViewModelTest.kt | 110 ++++ .../profile/ui/ProfileViewModelTest.kt | 15 + .../profile/ui/UserProfileViewModelTest.kt | 100 ++++ 113 files changed, 9522 insertions(+), 114 deletions(-) create mode 100644 feature/integrations/src/androidTest/AndroidManifest.xml create mode 100644 feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt create mode 100644 feature/integrations/src/main/AndroidManifest.xml create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/ExportFileStore.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/LimitsMapper.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/ConnectionStatusDto.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/LimitsDto.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/di/IntegrationsModule.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ExportType.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/PlanLimits.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsErrorMessages.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportScreen.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportSharing.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModel.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModel.kt create mode 100644 feature/integrations/src/main/res/xml/file_paths.xml create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModelTest.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModelTest.kt create mode 100644 feature/notifications/src/androidTest/AndroidManifest.xml create mode 100644 feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreenTest.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationDao.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationEntity.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationsDatabase.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationsApi.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDto.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/Notification.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsErrorMessages.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModel.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTime.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/components/NotificationRow.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepositoryTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/FakeNotificationDao.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/TestDoubles.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDtoMapperTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModelTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTimeTest.kt create mode 100644 feature/organizations/src/androidTest/AndroidManifest.xml create mode 100644 feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt create mode 100644 feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreenTest.kt create mode 100644 feature/organizations/src/main/AndroidManifest.xml create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapper.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapper.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/CachedOrganizationEntity.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationDao.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationsDatabase.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/FlexibleBoolean.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/MemberDtos.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrganizationDtos.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/di/OrganizationsModule.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgMember.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/Organization.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreen.kt create mode 100644 feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModel.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/FlexibleBooleanSerializerTest.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapperTest.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapperTest.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt create mode 100644 feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModelTest.kt create mode 100644 feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/FollowScreensTest.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/FollowMappers.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/FollowResponses.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowCounts.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowStatus.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowUser.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsViewModel.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FollowMappersTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowListViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowRequestsViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d3c1663..020788c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -53,6 +53,9 @@ dependencies { implementation(project(":feature:messages")) implementation(project(":feature:documents")) implementation(project(":feature:profile")) + implementation(project(":feature:notifications")) + implementation(project(":feature:organizations")) + implementation(project(":feature:integrations")) // Compose implementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 0576057..903de7d 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -28,6 +28,9 @@ import com.interlinedlist.android.feature.auth.ui.LoginRoute import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute +import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute +import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute +import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsRoute import com.interlinedlist.android.feature.lists.ui.connections.ConnectionsRoute import com.interlinedlist.android.feature.lists.ui.detail.ListDetailRoute import com.interlinedlist.android.feature.lists.ui.list.ListsRoute @@ -36,7 +39,13 @@ import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute +import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute +import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute +import com.interlinedlist.android.feature.organizations.ui.list.OrganizationsRoute import com.interlinedlist.android.feature.profile.ui.edit.EditProfileRoute +import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsRoute +import com.interlinedlist.android.feature.profile.ui.follow.FollowersRoute +import com.interlinedlist.android.feature.profile.ui.follow.FollowingRoute import com.interlinedlist.android.feature.profile.ui.profile.ProfileRoute import com.interlinedlist.android.feature.profile.ui.profile.UserProfileRoute import com.interlinedlist.android.feature.profile.ui.search.UserSearchRoute @@ -48,8 +57,8 @@ object Routes { const val MAIN = "main" // Top-level tabs (bottom navigation). - const val LISTS = "lists" const val MESSAGES = "messages" + const val LISTS = "lists" const val DOCUMENTS = "documents" const val ACCOUNT = "account" @@ -67,11 +76,22 @@ object Routes { const val DOCUMENT_FOLDER = "documents/folder/{folderId}" const val DOCUMENT_EDITOR = "documents/editor/{documentId}" - // Profile destinations. Distinct prefixes so a username can never collide - // with the edit/search routes. + // Profile / following destinations. Distinct prefixes so a username can + // never collide with the edit/search/list routes. const val PROFILE_EDIT = "editProfile" const val USER_SEARCH = "userSearch" const val USER_PROFILE = "user/{username}" + const val FOLLOWERS = "followers/{username}" + const val FOLLOWING = "following/{username}" + const val FOLLOW_REQUESTS = "followRequests" + + // Notifications / organizations / integrations (reached from the Account hub). + const val NOTIFICATIONS = "notifications" + const val ORGANIZATIONS = "organizations" + const val ORGANIZATION_DETAIL = "organizations/{orgId}" + const val INTEGRATIONS = "integrations" + const val INTEGRATIONS_EXPORT = "integrations/export" + const val INTEGRATIONS_ACCOUNTS = "integrations/accounts" fun listDetail(id: String) = "lists/$id" fun listSchema(id: String) = "lists/$id/schema" @@ -80,6 +100,9 @@ object Routes { fun documentFolder(id: String) = "documents/folder/$id" fun documentEditor(id: String) = "documents/editor/$id" fun userProfile(username: String) = "user/$username" + fun followers(username: String) = "followers/$username" + fun following(username: String) = "following/$username" + fun organization(orgId: String) = "organizations/$orgId" } /** @@ -168,6 +191,26 @@ private fun MainShell(onLoggedOut: () -> Unit) { startDestination = Routes.MESSAGES, modifier = Modifier.padding(padding), ) { + // ---- Messages ---- + composable(Routes.MESSAGES) { + MessagesRoute( + onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) }, + ) + } + composable( + Routes.MESSAGE_DETAIL, + arguments = listOf(navArgument("messageId") { type = NavType.StringType }), + ) { + MessageDetailRoute( + onBack = { tabNav.popBackStack() }, + onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + ) + } + composable(Routes.MESSAGES_SCHEDULED) { + ScheduledMessagesRoute(onBack = { tabNav.popBackStack() }) + } + // ---- Lists ---- composable(Routes.LISTS) { ListsRoute( @@ -206,26 +249,6 @@ private fun MainShell(onLoggedOut: () -> Unit) { ConnectionsRoute(onBack = { tabNav.popBackStack() }) } - // ---- Messages ---- - composable(Routes.MESSAGES) { - MessagesRoute( - onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, - onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) }, - ) - } - composable( - Routes.MESSAGE_DETAIL, - arguments = listOf(navArgument("messageId") { type = NavType.StringType }), - ) { - MessageDetailRoute( - onBack = { tabNav.popBackStack() }, - onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, - ) - } - composable(Routes.MESSAGES_SCHEDULED) { - ScheduledMessagesRoute(onBack = { tabNav.popBackStack() }) - } - // ---- Documents ---- composable(Routes.DOCUMENTS) { DocumentsRoute( @@ -253,7 +276,7 @@ private fun MainShell(onLoggedOut: () -> Unit) { ) } - // ---- Account / Profile ---- + // ---- Account / Profile hub ---- composable(Routes.ACCOUNT) { // Sign-out reuses the existing auth-backed logout; the profile // module intentionally owns no session state. @@ -261,6 +284,12 @@ private fun MainShell(onLoggedOut: () -> Unit) { ProfileRoute( onEditProfile = { tabNav.navigate(Routes.PROFILE_EDIT) }, onSearchUsers = { tabNav.navigate(Routes.USER_SEARCH) }, + onOpenFollowers = { username -> tabNav.navigate(Routes.followers(username)) }, + onOpenFollowing = { username -> tabNav.navigate(Routes.following(username)) }, + onOpenRequests = { tabNav.navigate(Routes.FOLLOW_REQUESTS) }, + onOpenNotifications = { tabNav.navigate(Routes.NOTIFICATIONS) }, + onOpenOrganizations = { tabNav.navigate(Routes.ORGANIZATIONS) }, + onOpenIntegrations = { tabNav.navigate(Routes.INTEGRATIONS) }, onSignOut = { logoutViewModel.logout(onLoggedOut) }, ) } @@ -280,7 +309,72 @@ private fun MainShell(onLoggedOut: () -> Unit) { Routes.USER_PROFILE, arguments = listOf(navArgument("username") { type = NavType.StringType }), ) { - UserProfileRoute(onBack = { tabNav.popBackStack() }) + UserProfileRoute( + onBack = { tabNav.popBackStack() }, + onOpenFollowers = { username -> tabNav.navigate(Routes.followers(username)) }, + onOpenFollowing = { username -> tabNav.navigate(Routes.following(username)) }, + ) + } + composable( + Routes.FOLLOWERS, + arguments = listOf(navArgument("username") { type = NavType.StringType }), + ) { + FollowersRoute( + onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) }, + onBack = { tabNav.popBackStack() }, + ) + } + composable( + Routes.FOLLOWING, + arguments = listOf(navArgument("username") { type = NavType.StringType }), + ) { + FollowingRoute( + onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) }, + onBack = { tabNav.popBackStack() }, + ) + } + composable(Routes.FOLLOW_REQUESTS) { + FollowRequestsRoute( + onOpenUser = { username -> tabNav.navigate(Routes.userProfile(username)) }, + onBack = { tabNav.popBackStack() }, + ) + } + + // ---- Notifications ---- + composable(Routes.NOTIFICATIONS) { + NotificationsRoute(onBack = { tabNav.popBackStack() }) + } + + // ---- Organizations ---- + composable(Routes.ORGANIZATIONS) { + OrganizationsRoute( + onOpenOrg = { id -> tabNav.navigate(Routes.organization(id)) }, + onBack = { tabNav.popBackStack() }, + ) + } + composable( + Routes.ORGANIZATION_DETAIL, + arguments = listOf(navArgument("orgId") { type = NavType.StringType }), + ) { + OrganizationDetailRoute( + onBack = { tabNav.popBackStack() }, + onDeleted = { tabNav.popBackStack() }, + ) + } + + // ---- Integrations & exports ---- + composable(Routes.INTEGRATIONS) { + IntegrationsRoute( + onBack = { tabNav.popBackStack() }, + onOpenExport = { tabNav.navigate(Routes.INTEGRATIONS_EXPORT) }, + onOpenConnectedAccounts = { tabNav.navigate(Routes.INTEGRATIONS_ACCOUNTS) }, + ) + } + composable(Routes.INTEGRATIONS_EXPORT) { + ExportRoute(onBack = { tabNav.popBackStack() }) + } + composable(Routes.INTEGRATIONS_ACCOUNTS) { + ConnectedAccountsRoute(onBack = { tabNav.popBackStack() }) } } } diff --git a/feature/integrations/build.gradle.kts b/feature/integrations/build.gradle.kts index 43eab58..052048b 100644 --- a/feature/integrations/build.gradle.kts +++ b/feature/integrations/build.gradle.kts @@ -40,23 +40,19 @@ dependencies { debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - // Photo Picker (rememberLauncherForActivityResult) for document image uploads. implementation(libs.androidx.activity.compose) - // This module owns its own Room cache (see DocumentsDatabase) — it must not - // reuse the shared :core:database, so it pulls Room in directly. - implementation(libs.room.runtime) - implementation(libs.room.ktx) - ksp(libs.room.compiler) + // androidx.core.content.FileProvider hands a cached CSV to the Android share sheet. + // This module has no Room cache: exports stream fresh CSV and status/limits are + // lightweight live reads, so there is nothing worth persisting locally. + implementation(libs.androidx.core.ktx) implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) - implementation(libs.coil.compose) - implementation(libs.retrofit.core) - // okhttp is used directly for multipart image uploads (MultipartBody / RequestBody). + // okhttp is used directly for the streaming CSV ResponseBody. implementation(libs.okhttp.core) implementation(libs.kotlinx.serialization.json) diff --git a/feature/integrations/src/androidTest/AndroidManifest.xml b/feature/integrations/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/integrations/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt b/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt new file mode 100644 index 0000000..463df1d --- /dev/null +++ b/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt @@ -0,0 +1,95 @@ +package com.interlinedlist.android.feature.integrations.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsScreen +import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsTestTags +import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsUiState +import com.interlinedlist.android.feature.integrations.ui.export.ExportScreen +import com.interlinedlist.android.feature.integrations.ui.export.ExportTestTags +import com.interlinedlist.android.feature.integrations.ui.export.ExportUiState +import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubScreen +import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubTestTags +import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class IntegrationsScreensTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun hub_entries_navigateToSubScreens() { + var export = false + var accounts = false + composeRule.setContent { + InterlinedListTheme { + IntegrationsHubScreen( + state = IntegrationsHubUiState(isLoadingLimits = false), + onBack = {}, + onOpenExport = { export = true }, + onOpenConnectedAccounts = { accounts = true }, + ) + } + } + + composeRule.onNodeWithTag(IntegrationsHubTestTags.EXPORT).performClick() + composeRule.onNodeWithTag(IntegrationsHubTestTags.ACCOUNTS).performClick() + + assert(export) + assert(accounts) + } + + @Test + fun export_row_tap_triggersExport() { + var requested: ExportType? = null + composeRule.setContent { + InterlinedListTheme { + ExportScreen( + state = ExportUiState(), + onExport = { requested = it }, + onDismissError = {}, + onBack = {}, + ) + } + } + + composeRule.onNodeWithTag(ExportTestTags.button(ExportType.FOLLOWS)).performClick() + + assert(requested == ExportType.FOLLOWS) + } + + @Test + fun accounts_showsConnectedStatus() { + composeRule.setContent { + InterlinedListTheme { + ConnectedAccountsScreen( + state = ConnectedAccountsUiState( + isLoading = false, + accounts = listOf( + ConnectedAccount(ConnectedAccount.Provider.GITHUB, isConnected = true, handle = "@adron"), + ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false), + ), + ), + onBack = {}, + ) + } + } + + composeRule.onNodeWithTag(ConnectedAccountsTestTags.row(ConnectedAccount.Provider.GITHUB)) + .assertIsDisplayed() + composeRule.onNodeWithTag(ConnectedAccountsTestTags.status(ConnectedAccount.Provider.GITHUB)) + .assertIsDisplayed() + composeRule.onNodeWithTag(ConnectedAccountsTestTags.status(ConnectedAccount.Provider.BLUESKY)) + .assertIsDisplayed() + } +} diff --git a/feature/integrations/src/main/AndroidManifest.xml b/feature/integrations/src/main/AndroidManifest.xml new file mode 100644 index 0000000..62d8d45 --- /dev/null +++ b/feature/integrations/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt new file mode 100644 index 0000000..941574b --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt @@ -0,0 +1,62 @@ +package com.interlinedlist.android.feature.integrations.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.integrations.data.mapper.toDomain +import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.PlanLimits +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.File +import javax.inject.Inject + +class DefaultIntegrationsRepository @Inject constructor( + private val api: IntegrationsApi, + private val fileStore: ExportFileStore, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : IntegrationsRepository { + + override suspend fun downloadExport(type: ExportType): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.downloadExport(type.pathSegment) } + .map { body -> + // Stream the response straight to a cache file so a large CSV + // never has to sit fully in memory. + val dir = fileStore.exportsDir().apply { mkdirs() } + val file = File(dir, "${type.fileBaseName}.csv") + body.byteStream().use { input -> + file.outputStream().use { output -> input.copyTo(output) } + } + file + } + } + + override suspend fun getConnectedAccounts(): List = + withContext(dispatchers.io) { + // Statuses are independent; one provider failing shouldn't hide the + // rest, so a failed lookup is treated as "not connected". + ConnectedAccount.Provider.entries.map { provider -> + when (val result = safeApiCall(json) { api.getConnectionStatus(provider.statusPath) }) { + is ApiResult.Success -> ConnectedAccount( + provider = provider, + isConnected = result.data.isConnected, + handle = result.data.bestHandle, + ) + is ApiResult.Failure -> ConnectedAccount( + provider = provider, + isConnected = false, + ) + } + } + } + + override suspend fun getLimits(): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getLimits() }.map { it.toDomain() } + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/ExportFileStore.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/ExportFileStore.kt new file mode 100644 index 0000000..17defd8 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/ExportFileStore.kt @@ -0,0 +1,14 @@ +package com.interlinedlist.android.feature.integrations.data + +import java.io.File + +/** + * Where downloaded CSVs are written. Abstracted behind an interface so the + * repository can be unit-tested against a temp directory without an Android + * `Context`; in production it points at the app's cache dir (see the Hilt + * module), which the FileProvider then exposes to the share sheet. + */ +interface ExportFileStore { + /** The directory CSV files are written into (created if missing). */ + fun exportsDir(): File +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt new file mode 100644 index 0000000..d57214e --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.integrations.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.PlanLimits +import java.io.File + +/** + * Data operations for the integrations hub: downloading CSV exports to disk, + * reading connected-account status, and reading plan limits. Everything is a + * live read — there is no offline cache — so results come back as [ApiResult]. + */ +interface IntegrationsRepository { + + /** + * Downloads the CSV for [type] and writes it into the app cache, returning + * the file so the caller can hand it to the share sheet. + */ + suspend fun downloadExport(type: ExportType): ApiResult + + /** Fetches connection status for every supported provider. */ + suspend fun getConnectedAccounts(): List + + /** Reads plan limits/usage, or a failure the UI can render inline. */ + suspend fun getLimits(): ApiResult +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/LimitsMapper.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/LimitsMapper.kt new file mode 100644 index 0000000..11f7066 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/LimitsMapper.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.integrations.data.mapper + +import com.interlinedlist.android.feature.integrations.data.remote.dto.LimitsDto +import com.interlinedlist.android.feature.integrations.domain.PlanLimits + +/** + * Maps the loosely-typed `/api/limits` payload into [PlanLimits]. Each entry in + * the `limits` map becomes a [PlanLimits.Limit] with a humanised label derived + * from its snake/camel-case key; entries are sorted by key for a stable order. + */ +fun LimitsDto.toDomain(): PlanLimits = PlanLimits( + planName = planName ?: plan, + limits = (limits ?: emptyMap()) + .toSortedMap() + .map { (key, entry) -> + PlanLimits.Limit( + key = key, + label = key.toDisplayLabel(), + used = entry.used, + max = entry.ceiling, + ) + }, +) + +/** "listDataRows" / "list_data_rows" -> "List data rows". */ +private fun String.toDisplayLabel(): String { + val spaced = replace("_", " ") + .replace(Regex("([a-z])([A-Z])"), "$1 $2") + .trim() + return spaced.replaceFirstChar { it.uppercaseChar() } + .lowercase() + .replaceFirstChar { it.uppercaseChar() } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt new file mode 100644 index 0000000..e1e1a8b --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.integrations.data.remote + +import com.interlinedlist.android.feature.integrations.data.remote.dto.ConnectionStatusDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.LimitsDto +import okhttp3.ResponseBody +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Streaming + +/** + * Retrofit description of the Phase 8 integrations endpoints. The shared Retrofit + * instance already carries the base URL and Bearer token, so these calls are + * authed. + * + * Exports return raw CSV, so they surface as a [Streaming] [ResponseBody] rather + * than a deserialised type — the repository copies the bytes straight to disk + * without buffering the whole file in memory. + */ +interface IntegrationsApi { + + /** CSV of the user's data for the given export type (path segment from ExportType). */ + @Streaming + @GET("api/exports/{type}") + suspend fun downloadExport(@Path("type") type: String): ResponseBody + + /** Connection status for one provider, e.g. `api/auth/github/status`. */ + @GET("{statusPath}") + suspend fun getConnectionStatus( + @Path(value = "statusPath", encoded = true) statusPath: String, + ): ConnectionStatusDto + + /** Plan limits/usage for the current user. */ + @GET("api/limits") + suspend fun getLimits(): LimitsDto +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/ConnectionStatusDto.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/ConnectionStatusDto.kt new file mode 100644 index 0000000..e305786 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/ConnectionStatusDto.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.integrations.data.remote.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The `/api/auth//status` payload. The OpenAPI spec only documents a + * "Successful response", so this DTO accepts the field names those endpoints use + * in practice and treats them all as optional. `connected` is the canonical flag; + * a present `handle`/`username` is treated as a fallback signal of connection. + */ +@Serializable +data class ConnectionStatusDto( + val connected: Boolean? = null, + val handle: String? = null, + val username: String? = null, + @SerialName("displayName") val displayName: String? = null, +) { + /** True when the flag says so, or when a handle/username is present. */ + val isConnected: Boolean + get() = connected ?: (!handle.isNullOrBlank() || !username.isNullOrBlank()) + + /** Best available human handle for display, if any. */ + val bestHandle: String? + get() = handle?.takeIf { it.isNotBlank() } + ?: username?.takeIf { it.isNotBlank() } + ?: displayName?.takeIf { it.isNotBlank() } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/LimitsDto.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/LimitsDto.kt new file mode 100644 index 0000000..88654e0 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/LimitsDto.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.integrations.data.remote.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * The `/api/limits` payload. Its exact shape isn't pinned in the spec, so this + * models the common `{ plan, limits: { : { used, max } } }` form. `limits` + * is a map keyed by resource name; unknown keys survive because each value is a + * lenient [LimitEntryDto]. Both a flat `{ key: max }` and a `{ used, limit }` + * object form are tolerated by the mapper. + */ +@Serializable +data class LimitsDto( + val plan: String? = null, + @SerialName("planName") val planName: String? = null, + val limits: Map? = null, +) + +@Serializable +data class LimitEntryDto( + val used: Int? = null, + val max: Int? = null, + val limit: Int? = null, +) { + /** Reconciles the two spellings the API might use for the ceiling. */ + val ceiling: Int? get() = max ?: limit +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/di/IntegrationsModule.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/di/IntegrationsModule.kt new file mode 100644 index 0000000..374f6af --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/di/IntegrationsModule.kt @@ -0,0 +1,48 @@ +package com.interlinedlist.android.feature.integrations.di + +import android.content.Context +import com.interlinedlist.android.feature.integrations.data.DefaultIntegrationsRepository +import com.interlinedlist.android.feature.integrations.data.ExportFileStore +import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository +import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import java.io.File +import javax.inject.Singleton + +/** Binds the repository interface to its default implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class IntegrationsRepositoryModule { + + @Binds + @Singleton + abstract fun bindIntegrationsRepository(impl: DefaultIntegrationsRepository): IntegrationsRepository +} + +/** Provides this feature's API off the shared authed Retrofit and the export file store. */ +@Module +@InstallIn(SingletonComponent::class) +object IntegrationsDataModule { + + @Provides + @Singleton + fun provideIntegrationsApi(retrofit: Retrofit): IntegrationsApi = + retrofit.create(IntegrationsApi::class.java) + + /** + * Writes exports into `cache/exports`, which the module's FileProvider + * (`cache-path name="exports"`) shares with the system share sheet. + */ + @Provides + @Singleton + fun provideExportFileStore(@ApplicationContext context: Context): ExportFileStore = + object : ExportFileStore { + override fun exportsDir(): File = File(context.cacheDir, "exports") + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt new file mode 100644 index 0000000..de99b07 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ConnectedAccount.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.feature.integrations.domain + +/** + * A social/identity provider the user can connect on the web, and whether the + * current account is connected. This module is read-only: the OAuth "connect" + * dance needs a browser redirect and is deferred to the web app, so the UI only + * surfaces status plus a "manage on the web" note. + */ +data class ConnectedAccount( + val provider: Provider, + val isConnected: Boolean, + /** Provider-supplied handle/username when connected, e.g. "@you". */ + val handle: String? = null, +) { + enum class Provider(val statusPath: String, val label: String) { + GITHUB("api/auth/github/status", "GitHub"), + LINKEDIN("api/auth/linkedin/status", "LinkedIn"), + BLUESKY("api/auth/bluesky/status", "Bluesky"), + MASTODON("api/auth/mastodon/status", "Mastodon"), + TWITTER("api/auth/twitter/status", "X (Twitter)"), + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ExportType.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ExportType.kt new file mode 100644 index 0000000..09b3048 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/ExportType.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.integrations.domain + +/** + * The four CSV exports the web app offers under `/api/exports/`. Each value + * knows its API path segment, a user-facing label, and the base name used for + * the downloaded file, so the UI and repository can be driven off this one enum. + */ +enum class ExportType( + val pathSegment: String, + val label: String, + val description: String, + val fileBaseName: String, +) { + FOLLOWS( + pathSegment = "follows", + label = "Follows", + description = "The people you follow and who follow you.", + fileBaseName = "follows", + ), + LISTS( + pathSegment = "lists", + label = "Lists", + description = "Your lists and their metadata.", + fileBaseName = "lists", + ), + MESSAGES( + pathSegment = "messages", + label = "Messages", + description = "Your sent and received messages.", + fileBaseName = "messages", + ), + LIST_DATA_ROWS( + pathSegment = "list-data-rows", + label = "List data rows", + description = "Every row of data across all your lists.", + fileBaseName = "list-data-rows", + ), +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/PlanLimits.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/PlanLimits.kt new file mode 100644 index 0000000..a8ee1e4 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/PlanLimits.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.feature.integrations.domain + +/** + * The plan/usage summary from `GET /api/limits`. The live shape isn't pinned in + * the OpenAPI spec, so the repository maps whatever it recognises into this flat + * list of named limits; unknown fields are ignored rather than failing the read. + */ +data class PlanLimits( + val planName: String?, + val limits: List, +) { + /** + * One metered resource. [max] is null when the plan is unlimited for it; + * [used] is null when the API doesn't report current usage. + */ + data class Limit( + val key: String, + val label: String, + val used: Int?, + val max: Int?, + ) { + val isUnlimited: Boolean get() = max == null + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsErrorMessages.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsErrorMessages.kt new file mode 100644 index 0000000..9adbb82 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsErrorMessages.kt @@ -0,0 +1,14 @@ +package com.interlinedlist.android.feature.integrations.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the integrations UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.SubscriptionRequired -> message ?: "This feature requires an active subscription." + is AppError.NotFound -> message ?: "That data could not be found." + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt new file mode 100644 index 0000000..0048de7 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsScreen.kt @@ -0,0 +1,160 @@ +package com.interlinedlist.android.feature.integrations.ui.accounts + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount + +/** Stable test tags so UI/instrumented tests can address the accounts controls. */ +object ConnectedAccountsTestTags { + const val LIST = "accountsList" + const val PROGRESS = "accountsProgress" + fun row(provider: ConnectedAccount.Provider) = "account_${provider.name}" + fun status(provider: ConnectedAccount.Provider) = "accountStatus_${provider.name}" +} + +/** Hilt-wired entry point for the read-only "Connected accounts" screen. */ +@Composable +fun ConnectedAccountsRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectedAccountsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ConnectedAccountsScreen(state = state, onBack = onBack, modifier = modifier) +} + +/** Stateless "Connected accounts" UI — easy to preview and to drive from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConnectedAccountsScreen( + state: ConnectedAccountsUiState, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Connected accounts") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + if (state.isLoading && state.accounts.isEmpty()) { + Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(ConnectedAccountsTestTags.PROGRESS)) + } + return@Scaffold + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .testTag(ConnectedAccountsTestTags.LIST), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + items(state.accounts, key = { it.provider.name }) { account -> AccountRow(account) } + item { + Spacer(Modifier.size(4.dp)) + Text( + text = "Connecting or disconnecting accounts happens on the InterlinedList " + + "website — this app shows their current status.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun AccountRow(account: ConnectedAccount) { + Card(modifier = Modifier.fillMaxWidth().testTag(ConnectedAccountsTestTags.row(account.provider))) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val (icon, tint) = if (account.isConnected) { + Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary + } else { + Icons.Outlined.Circle to MaterialTheme.colorScheme.onSurfaceVariant + } + Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp)) + Spacer(Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(account.provider.label, style = MaterialTheme.typography.titleMedium) + val subtitle = when { + account.isConnected && account.handle != null -> "Connected · ${account.handle}" + account.isConnected -> "Connected" + else -> "Not connected" + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = if (account.isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ConnectedAccountsTestTags.status(account.provider)), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectedAccountsScreenPreview() { + InterlinedListTheme { + ConnectedAccountsScreen( + state = ConnectedAccountsUiState( + isLoading = false, + accounts = listOf( + ConnectedAccount(ConnectedAccount.Provider.GITHUB, isConnected = true, handle = "@adron"), + ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false), + ), + ), + onBack = {}, + ) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt new file mode 100644 index 0000000..c83c116 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModel.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.integrations.ui.accounts + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the read-only "Connected accounts" screen. */ +data class ConnectedAccountsUiState( + val isLoading: Boolean = true, + val accounts: List = emptyList(), +) + +@HiltViewModel +class ConnectedAccountsViewModel @Inject constructor( + private val repository: IntegrationsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConnectedAccountsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { refresh() } + + fun refresh() { + _uiState.update { it.copy(isLoading = true) } + viewModelScope.launch { + val accounts = repository.getConnectedAccounts() + _uiState.update { it.copy(isLoading = false, accounts = accounts) } + } + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportScreen.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportScreen.kt new file mode 100644 index 0000000..88d5439 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportScreen.kt @@ -0,0 +1,198 @@ +package com.interlinedlist.android.feature.integrations.ui.export + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Download +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.integrations.domain.ExportType + +/** Stable test tags so UI/instrumented tests can address the export controls. */ +object ExportTestTags { + const val LIST = "exportList" + const val ERROR = "exportError" + fun row(type: ExportType) = "export_${type.pathSegment}" + fun button(type: ExportType) = "exportButton_${type.pathSegment}" + fun progress(type: ExportType) = "exportProgress_${type.pathSegment}" +} + +/** + * Hilt-wired entry point for the "Export data" screen. Collects state and, when a + * download finishes, forwards the cached CSV to the Android share sheet. + */ +@Composable +fun ExportRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ExportViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // One-shot: launch the share sheet each time a CSV is ready. + LaunchedEffect(Unit) { + viewModel.ready.collect { ready -> ExportSharing.share(context, ready.file) } + } + + ExportScreen( + state = state, + onExport = viewModel::export, + onDismissError = viewModel::clearError, + onBack = onBack, + modifier = modifier, + ) +} + +/** Stateless "Export data" UI — easy to preview and to drive from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ExportScreen( + state: ExportUiState, + onExport: (ExportType) -> Unit, + onDismissError: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(state.errorMessage) { + state.errorMessage?.let { + snackbarHostState.showSnackbar(it) + onDismissError() + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Export data") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.testTag(ExportTestTags.ERROR), + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp) + .testTag(ExportTestTags.LIST), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp), + ) { + item { + Text( + text = "Download your data as CSV, then save or send it from the share sheet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + items(ExportType.entries, key = { it.pathSegment }) { type -> + ExportRow( + type = type, + isDownloading = state.isDownloading(type), + enabled = !state.isBusy, + onExport = { onExport(type) }, + ) + } + } + } +} + +@Composable +private fun ExportRow( + type: ExportType, + isDownloading: Boolean, + enabled: Boolean, + onExport: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth().testTag(ExportTestTags.row(type))) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(type.label, style = MaterialTheme.typography.titleMedium) + Text( + type.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + OutlinedButton( + onClick = onExport, + enabled = enabled, + modifier = Modifier.testTag(ExportTestTags.button(type)), + ) { + if (isDownloading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp).testTag(ExportTestTags.progress(type)), + strokeWidth = 2.dp, + ) + } else { + Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(8.dp)) + Text("Export") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ExportScreenPreview() { + InterlinedListTheme { + ExportScreen( + state = ExportUiState(downloading = ExportType.LISTS), + onExport = {}, + onDismissError = {}, + onBack = {}, + ) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportSharing.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportSharing.kt new file mode 100644 index 0000000..2773da0 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportSharing.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.integrations.ui.export + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File + +/** + * Hands a downloaded CSV to the Android share sheet via the module's FileProvider, + * granting the receiving app temporary read access to the cached file. Kept as a + * small platform-facing helper so the ViewModel stays free of Android UI plumbing. + */ +object ExportSharing { + + /** Must match the authority declared in the module's AndroidManifest. */ + private fun authority(context: Context): String = + "${context.packageName}.integrations.fileprovider" + + /** Launches a chooser to save/send [file] as CSV. */ + fun share(context: Context, file: File) { + val uri = FileProvider.getUriForFile(context, authority(context), file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "text/csv" + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + val chooser = Intent.createChooser(intent, "Share ${file.name}") + .apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(chooser) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModel.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModel.kt new file mode 100644 index 0000000..78d652d --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModel.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.integrations.ui.export + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.io.File +import javax.inject.Inject + +/** + * UI state for the export screen. [downloading] holds the export currently being + * fetched (null when idle) so the row can show a spinner and the others disable. + */ +data class ExportUiState( + val downloading: ExportType? = null, + val errorMessage: String? = null, +) { + fun isDownloading(type: ExportType): Boolean = downloading == type + val isBusy: Boolean get() = downloading != null +} + +/** A downloaded CSV ready to be handed to the share sheet — a one-shot event. */ +data class ExportReady(val file: File) + +@HiltViewModel +class ExportViewModel @Inject constructor( + private val repository: IntegrationsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ExportUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Buffered channel so a file-ready event survives brief config-change gaps. + private val _ready = Channel(Channel.BUFFERED) + val ready = _ready.receiveAsFlow() + + /** Downloads [type] to cache; on success emits a [ready] event to share it. */ + fun export(type: ExportType) { + if (_uiState.value.isBusy) return + _uiState.update { it.copy(downloading = type, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.downloadExport(type)) { + is ApiResult.Success -> { + _uiState.update { it.copy(downloading = null) } + _ready.send(ExportReady(result.data)) + } + is ApiResult.Failure -> _uiState.update { + it.copy(downloading = null, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt new file mode 100644 index 0000000..491cfa8 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt @@ -0,0 +1,222 @@ +package com.interlinedlist.android.feature.integrations.ui.hub + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Link +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.integrations.domain.PlanLimits + +/** Stable test tags so UI/instrumented tests can address the hub controls. */ +object IntegrationsHubTestTags { + const val EXPORT = "hubExport" + const val ACCOUNTS = "hubAccounts" + const val LIMITS = "hubLimits" +} + +/** + * Public entry point for the integrations feature, reached from the Account hub. + * This is the hub; it drills down into the "Export data" and "Connected accounts" + * sub-screens and shows plan limits inline when available. + * + * @param onBack pop back to the Account hub. + * @param onOpenExport navigate to the export sub-route (see ExportRoute). + * @param onOpenConnectedAccounts navigate to the connected-accounts sub-route. + */ +@Composable +fun IntegrationsRoute( + onBack: () -> Unit, + onOpenExport: () -> Unit, + onOpenConnectedAccounts: () -> Unit, + modifier: Modifier = Modifier, + viewModel: IntegrationsHubViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + IntegrationsHubScreen( + state = state, + onBack = onBack, + onOpenExport = onOpenExport, + onOpenConnectedAccounts = onOpenConnectedAccounts, + modifier = modifier, + ) +} + +/** Stateless integrations hub UI — easy to preview and to drive from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IntegrationsHubScreen( + state: IntegrationsHubUiState, + onBack: () -> Unit, + onOpenExport: () -> Unit, + onOpenConnectedAccounts: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Integrations") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + item { + HubEntry( + icon = Icons.Default.Download, + title = "Export data", + subtitle = "Download your follows, lists, messages and rows as CSV.", + onClick = onOpenExport, + testTag = IntegrationsHubTestTags.EXPORT, + ) + } + item { + HubEntry( + icon = Icons.Default.Link, + title = "Connected accounts", + subtitle = "See which social accounts are linked.", + onClick = onOpenConnectedAccounts, + testTag = IntegrationsHubTestTags.ACCOUNTS, + ) + } + state.limits?.takeIf { it.limits.isNotEmpty() }?.let { limits -> + item { LimitsCard(limits) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HubEntry( + icon: ImageVector, + title: String, + subtitle: String, + onClick: () -> Unit, + testTag: String, +) { + Card( + onClick = onClick, + modifier = Modifier.fillMaxWidth().testTag(testTag), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.size(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + } +} + +@Composable +private fun LimitsCard(limits: PlanLimits) { + Card(modifier = Modifier.fillMaxWidth().testTag(IntegrationsHubTestTags.LIMITS)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = limits.planName?.let { "Plan: $it" } ?: "Plan limits", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.size(8.dp)) + limits.limits.forEach { limit -> LimitRow(limit) } + } + } +} + +@Composable +private fun LimitRow(limit: PlanLimits.Limit) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp)) { + Row(modifier = Modifier.fillMaxWidth()) { + Text(limit.label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text( + text = when { + limit.isUnlimited -> limit.used?.let { "$it / ∞" } ?: "Unlimited" + else -> "${limit.used ?: 0} / ${limit.max}" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val max = limit.max + val used = limit.used + if (max != null && max > 0 && used != null) { + Spacer(Modifier.size(4.dp)) + LinearProgressIndicator( + progress = { (used.toFloat() / max).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun IntegrationsHubScreenPreview() { + InterlinedListTheme { + IntegrationsHubScreen( + state = IntegrationsHubUiState( + isLoadingLimits = false, + limits = PlanLimits( + planName = "Free", + limits = listOf( + PlanLimits.Limit("lists", "Lists", used = 3, max = 5), + PlanLimits.Limit("follows", "Follows", used = 42, max = null), + ), + ), + ), + onBack = {}, + onOpenExport = {}, + onOpenConnectedAccounts = {}, + ) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModel.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModel.kt new file mode 100644 index 0000000..cd6f11d --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModel.kt @@ -0,0 +1,46 @@ +package com.interlinedlist.android.feature.integrations.ui.hub + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository +import com.interlinedlist.android.feature.integrations.domain.PlanLimits +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * UI state for the integrations hub. Plan limits are optional context shown at + * the top; a failure to load them is non-fatal and simply hides the section, so + * the hub's primary actions (export, connected accounts) stay usable. + */ +data class IntegrationsHubUiState( + val isLoadingLimits: Boolean = true, + val limits: PlanLimits? = null, +) + +@HiltViewModel +class IntegrationsHubViewModel @Inject constructor( + private val repository: IntegrationsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(IntegrationsHubUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { loadLimits() } + + fun loadLimits() { + _uiState.update { it.copy(isLoadingLimits = true) } + viewModelScope.launch { + val limits = when (val result = repository.getLimits()) { + is ApiResult.Success -> result.data + is ApiResult.Failure -> null + } + _uiState.update { it.copy(isLoadingLimits = false, limits = limits) } + } + } +} diff --git a/feature/integrations/src/main/res/xml/file_paths.xml b/feature/integrations/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..d7d25c4 --- /dev/null +++ b/feature/integrations/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt new file mode 100644 index 0000000..766ca52 --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryTest.kt @@ -0,0 +1,182 @@ +package com.interlinedlist.android.feature.integrations.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import java.io.File +import java.nio.file.Files + +/** + * Repository behaviour against a real HTTP stack (Retrofit + OkHttp) driven by + * MockWebServer, writing to a temp directory in place of the app cache. Verifies + * that CSV bytes are streamed to disk, provider statuses are mapped, per-provider + * failures degrade to "not connected", and limits are mapped/normalised. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultIntegrationsRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: IntegrationsApi + private lateinit var repository: DefaultIntegrationsRepository + private lateinit var tempDir: File + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(IntegrationsApi::class.java) + tempDir = Files.createTempDirectory("exports-test").toFile() + val fileStore = object : ExportFileStore { + override fun exportsDir(): File = File(tempDir, "exports") + } + repository = DefaultIntegrationsRepository(api, fileStore, json, testDispatchers) + } + + @After + fun tearDown() { + server.shutdown() + tempDir.deleteRecursively() + } + + @Test + fun `downloadExport requests the right path and writes the CSV bytes to disk`() = runTest(dispatcher) { + val csv = "id,name\n1,Ada\n2,Adron\n" + server.enqueue( + MockResponse() + .setHeader("Content-Type", "text/csv") + .setBody(csv), + ) + + val result = repository.downloadExport(ExportType.FOLLOWS) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val file = (result as ApiResult.Success).data + // Request went to the follows export endpoint. + val request = server.takeRequest() + assertThat(request.path).isEqualTo("/api/exports/follows") + // The exact bytes were surfaced to disk. + assertThat(file.name).isEqualTo("follows.csv") + assertThat(file.readText()).isEqualTo(csv) + } + + @Test + fun `downloadExport maps a subscription 403 to SubscriptionRequired`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription" }"""), + ) + + val result = repository.downloadExport(ExportType.LISTS) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `getConnectedAccounts maps each provider status and degrades failures to not-connected`() = + runTest(dispatcher) { + // Providers are queried in enum order: github, linkedin, bluesky, mastodon, twitter. + server.enqueue(MockResponse().setBody("""{ "connected": true, "handle": "@adron" }""")) + server.enqueue(MockResponse().setBody("""{ "connected": false }""")) + server.enqueue(MockResponse().setBody("""{ "username": "adron.bsky.social" }""")) + server.enqueue(MockResponse().setResponseCode(500).setBody("boom")) // mastodon fails + server.enqueue(MockResponse().setBody("""{ "connected": false }""")) + + val accounts = repository.getConnectedAccounts() + + assertThat(accounts.map { it.provider }).containsExactlyElementsIn( + ConnectedAccount.Provider.entries, + ) + val byProvider = accounts.associateBy { it.provider } + assertThat(byProvider[ConnectedAccount.Provider.GITHUB]!!.isConnected).isTrue() + assertThat(byProvider[ConnectedAccount.Provider.GITHUB]!!.handle).isEqualTo("@adron") + assertThat(byProvider[ConnectedAccount.Provider.LINKEDIN]!!.isConnected).isFalse() + // A present username without an explicit flag counts as connected. + assertThat(byProvider[ConnectedAccount.Provider.BLUESKY]!!.isConnected).isTrue() + assertThat(byProvider[ConnectedAccount.Provider.BLUESKY]!!.handle).isEqualTo("adron.bsky.social") + // A 500 degrades to not-connected rather than throwing. + assertThat(byProvider[ConnectedAccount.Provider.MASTODON]!!.isConnected).isFalse() + } + + @Test + fun `getConnectedAccounts hits each provider status path`() = runTest(dispatcher) { + repeat(ConnectedAccount.Provider.entries.size) { + server.enqueue(MockResponse().setBody("""{ "connected": false }""")) + } + + repository.getConnectedAccounts() + + val paths = buildList { + repeat(ConnectedAccount.Provider.entries.size) { add(server.takeRequest().path) } + } + assertThat(paths).containsExactly( + "/api/auth/github/status", + "/api/auth/linkedin/status", + "/api/auth/bluesky/status", + "/api/auth/mastodon/status", + "/api/auth/twitter/status", + ).inOrder() + } + + @Test + fun `getLimits maps the plan and normalises entries`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "plan": "free", + "limits": { + "lists": { "used": 3, "max": 5 }, + "listDataRows": { "used": 40, "limit": 100 }, + "follows": { "used": 12 } + } + } + """.trimIndent(), + ), + ) + + val result = repository.getLimits() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val limits = (result as ApiResult.Success).data + assertThat(limits.planName).isEqualTo("free") + val byKey = limits.limits.associateBy { it.key } + assertThat(byKey["lists"]!!.max).isEqualTo(5) + assertThat(byKey["lists"]!!.used).isEqualTo(3) + // `limit` is accepted as an alias for `max`. + assertThat(byKey["listDataRows"]!!.max).isEqualTo(100) + assertThat(byKey["listDataRows"]!!.label).isEqualTo("List data rows") + // No ceiling means unlimited. + assertThat(byKey["follows"]!!.isUnlimited).isTrue() + } +} diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt new file mode 100644 index 0000000..b9fd84e --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.integrations.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.PlanLimits +import java.io.File + +/** In-memory [IntegrationsRepository] for ViewModel tests; each result is settable. */ +class FakeIntegrationsRepository : IntegrationsRepository { + + var exportResult: ApiResult = ApiResult.Failure(AppError.Unknown("not set")) + val exportedTypes = mutableListOf() + + var accounts: List = emptyList() + var accountsCalls = 0 + + var limitsResult: ApiResult = ApiResult.Failure(AppError.Unknown("not set")) + + override suspend fun downloadExport(type: ExportType): ApiResult { + exportedTypes.add(type) + return exportResult + } + + override suspend fun getConnectedAccounts(): List { + accountsCalls++ + return accounts + } + + override suspend fun getLimits(): ApiResult = limitsResult +} diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt new file mode 100644 index 0000000..bc99b45 --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/accounts/ConnectedAccountsViewModelTest.kt @@ -0,0 +1,59 @@ +package com.interlinedlist.android.feature.integrations.ui.accounts + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount +import com.interlinedlist.android.feature.integrations.ui.FakeIntegrationsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectedAccountsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeIntegrationsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeIntegrationsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads accounts on init and clears loading`() = runTest(dispatcher) { + repo.accounts = listOf( + ConnectedAccount(ConnectedAccount.Provider.GITHUB, isConnected = true, handle = "@adron"), + ConnectedAccount(ConnectedAccount.Provider.BLUESKY, isConnected = false), + ) + + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.accounts).hasSize(2) + assertThat(vm.uiState.value.accounts.first().handle).isEqualTo("@adron") + assertThat(repo.accountsCalls).isEqualTo(1) + } + + @Test + fun `refresh re-queries the repository`() = runTest(dispatcher) { + repo.accounts = emptyList() + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.refresh() + advanceUntilIdle() + + assertThat(repo.accountsCalls).isEqualTo(2) + } +} diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModelTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModelTest.kt new file mode 100644 index 0000000..5c42a5c --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/export/ExportViewModelTest.kt @@ -0,0 +1,95 @@ +package com.interlinedlist.android.feature.integrations.ui.export + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.ui.FakeIntegrationsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class ExportViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeIntegrationsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeIntegrationsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `successful export clears loading and emits a ready event`() = runTest(dispatcher) { + val file = File.createTempFile("lists", ".csv").apply { deleteOnExit() } + repo.exportResult = ApiResult.Success(file) + val vm = ExportViewModel(repo) + + vm.ready.test { + vm.export(ExportType.LISTS) + advanceUntilIdle() + + assertThat(awaitItem().file).isEqualTo(file) + cancelAndIgnoreRemainingEvents() + } + assertThat(repo.exportedTypes).containsExactly(ExportType.LISTS) + assertThat(vm.uiState.value.downloading).isNull() + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `download shows a per-type spinner while in flight`() = runTest(dispatcher) { + repo.exportResult = ApiResult.Success(File.createTempFile("msg", ".csv").apply { deleteOnExit() }) + val vm = ExportViewModel(repo) + + vm.uiState.test { + assertThat(awaitItem().downloading).isNull() // initial + + vm.export(ExportType.MESSAGES) + assertThat(awaitItem().isDownloading(ExportType.MESSAGES)).isTrue() // in-flight + + advanceUntilIdle() + assertThat(awaitItem().downloading).isNull() // done + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a second export is ignored while one is in flight`() = runTest(dispatcher) { + repo.exportResult = ApiResult.Success(File.createTempFile("fol", ".csv").apply { deleteOnExit() }) + val vm = ExportViewModel(repo) + + vm.export(ExportType.FOLLOWS) + vm.export(ExportType.LISTS) // dropped: busy + advanceUntilIdle() + + assertThat(repo.exportedTypes).containsExactly(ExportType.FOLLOWS) + } + + @Test + fun `failed export surfaces a mapped error and emits no ready event`() = runTest(dispatcher) { + repo.exportResult = ApiResult.Failure(AppError.Network(null)) + val vm = ExportViewModel(repo) + + vm.export(ExportType.LIST_DATA_ROWS) + advanceUntilIdle() + + assertThat(vm.uiState.value.downloading).isNull() + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + } +} diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModelTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModelTest.kt new file mode 100644 index 0000000..670f89b --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubViewModelTest.kt @@ -0,0 +1,58 @@ +package com.interlinedlist.android.feature.integrations.ui.hub + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.integrations.domain.PlanLimits +import com.interlinedlist.android.feature.integrations.ui.FakeIntegrationsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class IntegrationsHubViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeIntegrationsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeIntegrationsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads limits on init`() = runTest(dispatcher) { + repo.limitsResult = ApiResult.Success( + PlanLimits("Free", listOf(PlanLimits.Limit("lists", "Lists", used = 1, max = 5))), + ) + + val vm = IntegrationsHubViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoadingLimits).isFalse() + assertThat(vm.uiState.value.limits?.planName).isEqualTo("Free") + assertThat(vm.uiState.value.limits?.limits).hasSize(1) + } + + @Test + fun `a limits failure is non-fatal and hides the section`() = runTest(dispatcher) { + repo.limitsResult = ApiResult.Failure(AppError.Server("boom")) + + val vm = IntegrationsHubViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoadingLimits).isFalse() + assertThat(vm.uiState.value.limits).isNull() + } +} diff --git a/feature/notifications/src/androidTest/AndroidManifest.xml b/feature/notifications/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..cf47aaf --- /dev/null +++ b/feature/notifications/src/androidTest/AndroidManifest.xml @@ -0,0 +1,3 @@ + + + diff --git a/feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreenTest.kt b/feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreenTest.kt new file mode 100644 index 0000000..f94dcb9 --- /dev/null +++ b/feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreenTest.kt @@ -0,0 +1,131 @@ +package com.interlinedlist.android.feature.notifications.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import com.interlinedlist.android.feature.notifications.ui.components.NotificationRowTags +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NotificationsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun notification( + id: String, + subject: String, + read: Boolean = false, + type: NotificationType = NotificationType.FOLLOW, + ) = Notification( + id = id, type = type, actor = null, subject = subject, body = null, + createdAt = null, read = read, target = null, + ) + + /** Hosts the stateless screen with a tiny in-memory state holder. */ + private fun setScreen( + initial: NotificationsUiState, + onBack: () -> Unit = {}, + onMarkAllRead: () -> Unit = {}, + onOpen: (Notification) -> Unit = {}, + onDismiss: (Notification) -> Unit = {}, + ) { + composeRule.setContent { + var state by mutableStateOf(initial) + InterlinedListTheme { + NotificationsScreen( + state = state, + onBack = onBack, + onRefresh = {}, + onLoadMore = {}, + onMarkAllRead = onMarkAllRead, + onOpen = onOpen, + onDismiss = onDismiss, + ) + } + } + } + + @Test + fun emptyState_isShown_whenThereAreNoNotifications() { + setScreen(NotificationsUiState(notifications = emptyList())) + composeRule.onNodeWithTag(NotificationsTags.EMPTY).assertIsDisplayed() + } + + @Test + fun notifications_areRendered_inTheList() { + setScreen(NotificationsUiState(notifications = listOf(notification("1", "Amy followed you")))) + composeRule.onNodeWithText("Amy followed you").assertIsDisplayed() + } + + @Test + fun back_invokesOnBack() { + var backed = false + setScreen( + NotificationsUiState(notifications = listOf(notification("1", "hi"))), + onBack = { backed = true }, + ) + composeRule.onNodeWithTag(NotificationsTags.BACK).performClick() + assert(backed) + } + + @Test + fun tappingNotification_invokesOnOpen() { + var opened: String? = null + setScreen( + NotificationsUiState(notifications = listOf(notification("42", "Tap me"))), + onOpen = { opened = it.id }, + ) + composeRule.onNodeWithText("Tap me").performClick() + assert(opened == "42") + } + + @Test + fun unreadDot_isShown_forUnreadRows() { + setScreen(NotificationsUiState(notifications = listOf(notification("1", "unread", read = false)))) + composeRule.onNodeWithTag(NotificationRowTags.UNREAD_DOT).assertIsDisplayed() + } + + @Test + fun markAllRead_isShown_whenThereAreUnread_andInvokesCallback() { + var marked = false + setScreen( + NotificationsUiState( + notifications = listOf(notification("1", "unread", read = false)), + unreadCount = 1, + ), + onMarkAllRead = { marked = true }, + ) + composeRule.onNodeWithTag(NotificationsTags.MARK_ALL_READ).performClick() + assert(marked) + } + + @Test + fun overflowMenu_dismissesNotification() { + var dismissed: String? = null + setScreen( + NotificationsUiState(notifications = listOf(notification("77", "bye"))), + onDismiss = { dismissed = it.id }, + ) + composeRule.onNodeWithTag(NotificationRowTags.MENU).performClick() + composeRule.onNodeWithTag(NotificationRowTags.DISMISS).performClick() + assert(dismissed == "77") + } + + @Test + fun subscriptionGate_showsLockedState() { + setScreen(NotificationsUiState(subscriptionRequired = true, errorMessage = "Subscribers only")) + composeRule.onNodeWithTag(NotificationsTags.LOCKED).assertIsDisplayed() + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt new file mode 100644 index 0000000..72c20e2 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepository.kt @@ -0,0 +1,104 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.notifications.data.local.NotificationDao +import com.interlinedlist.android.feature.notifications.data.local.NotificationEntity +import com.interlinedlist.android.feature.notifications.data.local.toDomain +import com.interlinedlist.android.feature.notifications.data.local.toEntity +import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi +import com.interlinedlist.android.feature.notifications.data.remote.dto.PaginationDto +import com.interlinedlist.android.feature.notifications.data.remote.dto.toDomain +import com.interlinedlist.android.feature.notifications.domain.Notification +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultNotificationsRepository @Inject constructor( + private val api: NotificationsApi, + private val notificationDao: NotificationDao, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : NotificationsRepository { + + override fun observeNotifications(): Flow> = + notificationDao.observeNotifications().map { rows -> rows.map { it.toDomain() } } + + override fun observeUnreadCount(): Flow = notificationDao.observeUnreadCount() + + override suspend fun refresh(): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = 0) }) { + is ApiResult.Success -> { + val page = result.data + val entities = page.items.mapIndexed { index, dto -> + dto.toDomain().toEntity(listOrder = index.toLong()) + } + notificationDao.clear() + notificationDao.insertAll(entities) + ApiResult.Success(page.pagination.hasMore) + } + is ApiResult.Failure -> result + } + } + + override suspend fun loadMore(currentCount: Int): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { + api.getNotifications(limit = PaginationDto.DEFAULT_LIMIT, offset = currentCount) + }) { + is ApiResult.Success -> { + val page = result.data + val base = (notificationDao.maxListOrder() ?: -1L) + 1L + val entities = page.items.mapIndexed { index, dto -> + dto.toDomain().toEntity(listOrder = base + index) + } + notificationDao.insertAll(entities) + ApiResult.Success(page.pagination.hasMore) + } + is ApiResult.Failure -> result + } + } + + override suspend fun markRead(id: String): ApiResult = withContext(dispatchers.io) { + val previous = notificationDao.findById(id) + // Optimistically flip to read so the UI reacts immediately. + notificationDao.markRead(id) + val result = safeCall { api.markRead(id) } + if (result is ApiResult.Failure && previous != null) { + // Roll back to the pre-mark state on failure. + notificationDao.upsert(previous) + } + result + } + + override suspend fun markAllRead(): ApiResult = withContext(dispatchers.io) { + // Snapshot current rows so their exact read-states can be restored on failure. + val snapshot: List = notificationDao.observeNotifications().first() + notificationDao.markAllRead() + val result = safeCall { api.markAllRead() } + if (result is ApiResult.Failure) { + notificationDao.insertAll(snapshot) + } + result + } + + override suspend fun dismiss(id: String): ApiResult = withContext(dispatchers.io) { + val previous = notificationDao.findById(id) + // Optimistically remove so the swipe/overflow feels instant. + notificationDao.deleteById(id) + val result = safeCall { api.delete(id) } + if (result is ApiResult.Failure && previous != null) { + // Restore the row on failure. + notificationDao.upsert(previous) + } + result + } + + // --- helpers ----------------------------------------------------------- + + private suspend fun safeCall(block: suspend () -> T): ApiResult = + safeApiCall(json, block) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt new file mode 100644 index 0000000..14d128d --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationsRepository.kt @@ -0,0 +1,44 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.domain.Notification +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to the recipient's notifications. Room is the source of + * truth: reads are Flows off the cache; network refreshes upsert into Room and let + * the Flows re-emit. Mutations (read / mark-all-read / dismiss) optimistically + * update the cache and roll back on failure. + * + * Notifications are polled (no FCM yet) — the ViewModel refreshes on load and on + * pull-to-refresh. + */ +interface NotificationsRepository { + + /** The cached notifications, newest-first, re-emitting on every change. */ + fun observeNotifications(): Flow> + + /** Live count of unread notifications (drives the badge/header styling). */ + fun observeUnreadCount(): Flow + + /** + * Refreshes the first page from the API and replaces the cached list. + * Returns whether more pages are available. + */ + suspend fun refresh(): ApiResult + + /** + * Fetches and appends the next page after [currentCount] items. + * Returns whether still more pages remain. + */ + suspend fun loadMore(currentCount: Int): ApiResult + + /** Marks a single notification read; optimistically updates the cache. */ + suspend fun markRead(id: String): ApiResult + + /** Marks every notification read; optimistically updates the cache. */ + suspend fun markAllRead(): ApiResult + + /** Dismisses (deletes) a notification, removing it from the cache. */ + suspend fun dismiss(id: String): ApiResult +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationDao.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationDao.kt new file mode 100644 index 0000000..962f1b1 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationDao.kt @@ -0,0 +1,49 @@ +package com.interlinedlist.android.feature.notifications.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface NotificationDao { + + /** Cached notifications in server order; re-emits on every change. */ + @Query("SELECT * FROM notification ORDER BY listOrder ASC") + fun observeNotifications(): Flow> + + /** Live count of unread notifications, for the badge/header. */ + @Query("SELECT COUNT(*) FROM notification WHERE read = 0") + fun observeUnreadCount(): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(notifications: List) + + @Upsert + suspend fun upsert(notification: NotificationEntity) + + /** A single cached row (or null); used for optimistic mutations. */ + @Query("SELECT * FROM notification WHERE id = :id") + suspend fun findById(id: String): NotificationEntity? + + @Query("DELETE FROM notification WHERE id = :id") + suspend fun deleteById(id: String) + + /** Marks one row read in place (optimistic mark-as-read). */ + @Query("UPDATE notification SET read = 1 WHERE id = :id") + suspend fun markRead(id: String) + + /** Marks every cached row read (optimistic mark-all-read). */ + @Query("UPDATE notification SET read = 1") + suspend fun markAllRead() + + /** Clears the cache (used before writing a fresh refresh page). */ + @Query("DELETE FROM notification") + suspend fun clear() + + /** Largest list-order position currently stored (for append/load-more). */ + @Query("SELECT MAX(listOrder) FROM notification") + suspend fun maxListOrder(): Long? +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationEntity.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationEntity.kt new file mode 100644 index 0000000..812b91b --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationEntity.kt @@ -0,0 +1,78 @@ +package com.interlinedlist.android.feature.notifications.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationActor +import com.interlinedlist.android.feature.notifications.domain.NotificationTarget +import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind +import com.interlinedlist.android.feature.notifications.domain.NotificationType + +/** + * Locally cached notification row — this module's own offline-first source of truth + * for the list screen. Kept flat (actor + target fields inlined) so a single table + * serves the list without joins. Enum categories are stored by name so the schema + * survives new enum values (unknown names decode back to the OTHER fallbacks). + */ +@Entity(tableName = "notification") +data class NotificationEntity( + @PrimaryKey val id: String, + val type: String, + val actorId: String?, + val actorUsername: String?, + val actorDisplayName: String?, + val actorAvatarUrl: String?, + val subject: String, + val body: String?, + val createdAt: String?, + val read: Boolean, + val targetKind: String?, + val targetId: String?, + /** Server-relative ordering position captured at fetch time (list order). */ + val listOrder: Long, +) + +fun NotificationEntity.toDomain(): Notification = Notification( + id = id, + type = runCatching { NotificationType.valueOf(type) }.getOrDefault(NotificationType.OTHER), + actor = if (actorId != null || actorUsername != null) { + NotificationActor( + id = actorId.orEmpty(), + username = actorUsername.orEmpty(), + displayName = actorDisplayName, + avatarUrl = actorAvatarUrl, + ) + } else { + null + }, + subject = subject, + body = body, + createdAt = createdAt, + read = read, + target = if (targetId != null) { + NotificationTarget( + kind = targetKind + ?.let { runCatching { NotificationTargetKind.valueOf(it) }.getOrNull() } + ?: NotificationTargetKind.OTHER, + id = targetId, + ) + } else { + null + }, +) + +fun Notification.toEntity(listOrder: Long): NotificationEntity = NotificationEntity( + id = id, + type = type.name, + actorId = actor?.id, + actorUsername = actor?.username, + actorDisplayName = actor?.displayName, + actorAvatarUrl = actor?.avatarUrl, + subject = subject, + body = body, + createdAt = createdAt, + read = read, + targetKind = target?.kind?.name, + targetId = target?.id, + listOrder = listOrder, +) diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationsDatabase.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationsDatabase.kt new file mode 100644 index 0000000..04c2517 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/local/NotificationsDatabase.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.notifications.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * This feature module's own Room cache, separate from `:core:database`'s + * `InterlinedListDatabase`. A disposable cache during early development. + */ +@Database( + entities = [NotificationEntity::class], + version = 1, + exportSchema = false, +) +abstract class NotificationsDatabase : RoomDatabase() { + abstract fun notificationDao(): NotificationDao +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationsApi.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationsApi.kt new file mode 100644 index 0000000..c30f857 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationsApi.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.feature.notifications.data.remote + +import com.interlinedlist.android.feature.notifications.data.remote.dto.NotificationsResponse +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the Notifications endpoints. Provided from the shared, + * already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), so + * every call here is authed. + * + * The OpenAPI extract exposes `scope` as the only list parameter; we additionally + * send `limit`/`offset` for offset/limit pagination, which the server ignores if it + * does not paginate this collection. + */ +interface NotificationsApi { + + /** The recipient's notifications, newest first, offset/limit paginated. */ + @GET("api/notifications") + suspend fun getNotifications( + @Query("limit") limit: Int, + @Query("offset") offset: Int, + @Query("scope") scope: String? = null, + ): NotificationsResponse + + /** Marks a single notification read (on tap). */ + @PATCH("api/notifications/{id}/read") + suspend fun markRead(@Path("id") id: String) + + /** Marks every notification read (top-bar action). */ + @POST("api/notifications/mark-all-read") + suspend fun markAllRead() + + /** Dismisses (deletes) a single notification. */ + @DELETE("api/notifications/{id}") + suspend fun delete(@Path("id") id: String) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDto.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDto.kt new file mode 100644 index 0000000..9835b6a --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDto.kt @@ -0,0 +1,136 @@ +package com.interlinedlist.android.feature.notifications.data.remote.dto + +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationActor +import com.interlinedlist.android.feature.notifications.domain.NotificationTarget +import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import kotlinx.serialization.Serializable + +/** + * Wire model for a notification returned by `GET /api/notifications`. + * + * The OpenAPI extract does not pin the response schema, so this is modelled + * defensively: every field is defaulted and the shared [kotlinx.serialization.json.Json] + * is configured with `ignoreUnknownKeys`, so extra/renamed fields are tolerated and + * dropped rather than throwing. The mapper ([toDomain]) is the single point of change + * if the real shape differs from what we assumed here. + * + * Common naming variants are accommodated where cheap: + * - the actor may arrive as a nested [actor]/[sender]/[fromUser] object; + * - the read flag may be `read` or `isRead`; + * - the target may be a nested [target] object or flat `targetType`/`targetId` fields. + */ +@Serializable +data class NotificationDto( + val id: String = "", + val type: String? = null, + val actor: NotificationActorDto? = null, + /** Alternate key some payloads use for the acting user. */ + val sender: NotificationActorDto? = null, + /** Alternate key some payloads use for the acting user. */ + val fromUser: NotificationActorDto? = null, + /** Primary summary line; alternates handled by [subjectText]. */ + val subject: String? = null, + val title: String? = null, + val message: String? = null, + val text: String? = null, + /** Secondary detail line, when present. */ + val body: String? = null, + val excerpt: String? = null, + val createdAt: String? = null, + val timestamp: String? = null, + val read: Boolean? = null, + val isRead: Boolean? = null, + val readAt: String? = null, + /** Nested target descriptor, when the server nests it. */ + val target: NotificationTargetDto? = null, + /** Flat target fields, when the server flattens it. */ + val targetType: String? = null, + val targetId: String? = null, +) + +/** Acting user embedded in a notification. All fields defaulted for defensiveness. */ +@Serializable +data class NotificationActorDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, + val name: String? = null, + val avatar: String? = null, + val avatarUrl: String? = null, +) + +/** Nested deep-link target descriptor. */ +@Serializable +data class NotificationTargetDto( + val type: String? = null, + val id: String? = null, +) + +/** The best available summary line across the payload's naming variants. */ +private val NotificationDto.subjectText: String + get() = (subject ?: title ?: message ?: text)?.takeIf { it.isNotBlank() }.orEmpty() + +/** The best available secondary line across the payload's naming variants. */ +private val NotificationDto.bodyText: String? + get() = (body ?: excerpt)?.takeIf { it.isNotBlank() } + +/** The best available creation timestamp across the payload's naming variants. */ +private val NotificationDto.createdTimestamp: String? + get() = (createdAt ?: timestamp)?.takeIf { it.isNotBlank() } + +/** + * Read state, tolerant of the several ways the API may express it: an explicit + * `read`/`isRead` boolean, or the presence of a `readAt` timestamp. Defaults to + * unread when nothing is provided. + */ +private val NotificationDto.readState: Boolean + get() = read ?: isRead ?: (readAt?.isNotBlank() == true) + +/** Maps the wire model into the domain [Notification]. Single point of change. */ +fun NotificationDto.toDomain(): Notification = Notification( + id = id, + type = NotificationType.fromWire(type), + actor = (actor ?: sender ?: fromUser)?.toDomain(), + subject = subjectText, + body = bodyText, + createdAt = createdTimestamp, + read = readState, + target = resolveTarget(), +) + +/** Maps an actor sub-object, dropping it entirely when it carries no identity. */ +private fun NotificationActorDto.toDomain(): NotificationActor? { + val actorId = id.takeIf { it.isNotBlank() } + val handle = username.takeIf { it.isNotBlank() } + // Without an id or a username there is nothing meaningful to show. + if (actorId == null && handle == null) return null + return NotificationActor( + id = actorId.orEmpty(), + username = handle.orEmpty(), + displayName = (displayName ?: name)?.takeIf { it.isNotBlank() }, + avatarUrl = (avatar ?: avatarUrl)?.takeIf { it.isNotBlank() }, + ) +} + +/** Resolves the deep-link target from either the nested or the flat representation. */ +private fun NotificationDto.resolveTarget(): NotificationTarget? { + val kindRaw = target?.type ?: targetType + val targetIdentifier = (target?.id ?: targetId)?.takeIf { it.isNotBlank() } ?: return null + return NotificationTarget( + kind = targetKindFromWire(kindRaw), + id = targetIdentifier, + ) +} + +/** Maps a free-form target type string to a [NotificationTargetKind]; unknowns -> OTHER. */ +private fun targetKindFromWire(raw: String?): NotificationTargetKind { + val value = raw?.trim()?.lowercase().orEmpty() + return when { + value.contains("message") || value.contains("post") -> NotificationTargetKind.MESSAGE + value.contains("user") || value.contains("profile") -> NotificationTargetKind.USER + value.contains("list") -> NotificationTargetKind.LIST + else -> NotificationTargetKind.OTHER + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt new file mode 100644 index 0000000..87fe0fd --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponse.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.notifications.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Paginated list envelope for `GET /api/notifications`. Mirrors the shape shared by + * the other list endpoints — `{ data: [...], pagination: { total, limit, offset, + * hasMore } }` — while tolerating a couple of alternate key names the notifications + * endpoint might use (`notifications` instead of `data`, `unreadCount` alongside). + * + * Everything is defaulted so an empty or partially-populated body decodes cleanly. + */ +@Serializable +data class NotificationsResponse( + val data: List = emptyList(), + /** Alternate key some payloads use for the list. */ + val notifications: List = emptyList(), + val pagination: PaginationDto = PaginationDto(), + /** Server-provided unread count, when present; otherwise derived from [items]. */ + val unreadCount: Int? = null, +) { + /** The notification list, whichever key the server populated. */ + val items: List get() = data.ifEmpty { notifications } +} + +/** Pagination cursor returned alongside a list of notifications. */ +@Serializable +data class PaginationDto( + val total: Int = 0, + val limit: Int = DEFAULT_LIMIT, + val offset: Int = 0, + val hasMore: Boolean = false, +) { + companion object { + const val DEFAULT_LIMIT = 20 + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt new file mode 100644 index 0000000..ae5c8b1 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationsModule.kt @@ -0,0 +1,59 @@ +package com.interlinedlist.android.feature.notifications.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.feature.notifications.data.DefaultNotificationsRepository +import com.interlinedlist.android.feature.notifications.data.NotificationsRepository +import com.interlinedlist.android.feature.notifications.data.local.NotificationDao +import com.interlinedlist.android.feature.notifications.data.local.NotificationsDatabase +import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the Notifications repository interface to its implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsRepositoryModule { + + @Binds + @Singleton + abstract fun bindNotificationsRepository( + impl: DefaultNotificationsRepository, + ): NotificationsRepository +} + +/** + * Provides the Notifications data layer: a Retrofit API from the shared, + * authenticated [Retrofit] singleton, and this module's own Room cache (a distinct + * db file from `:core:database`). + */ +@Module +@InstallIn(SingletonComponent::class) +object NotificationsDataModule { + + @Provides + @Singleton + fun provideNotificationsApi(retrofit: Retrofit): NotificationsApi = + retrofit.create(NotificationsApi::class.java) + + @Provides + @Singleton + fun provideNotificationsDatabase(@ApplicationContext context: Context): NotificationsDatabase = + Room.databaseBuilder( + context, + NotificationsDatabase::class.java, + "interlinedlist-notifications.db", + ) + // Disposable cache during early development; real migrations come later. + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideNotificationDao(db: NotificationsDatabase): NotificationDao = db.notificationDao() +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/Notification.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/Notification.kt new file mode 100644 index 0000000..3ab3ccf --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/Notification.kt @@ -0,0 +1,114 @@ +package com.interlinedlist.android.feature.notifications.domain + +/** + * A single in-app notification, normalised from the API wire model into a + * platform-independent domain type. + * + * The OpenAPI extract does not pin the notification schema, so this captures the + * fields the web app surfaces: a [type] (what happened), the [actor] who caused it + * (with an avatar), a short [subject]/body describing it, a creation timestamp, and + * whether the recipient has read it yet. An optional [target] lets the app deep-link + * to whatever the notification is about (a message/user/list); it is best-effort and + * often null. + */ +data class Notification( + val id: String, + /** Coarse category driving the icon/label; see [NotificationType]. */ + val type: NotificationType, + /** The user who triggered the notification, when the API supplies one. */ + val actor: NotificationActor?, + /** Short human-readable summary line (already rendered server-side, if present). */ + val subject: String, + /** Optional secondary line (e.g. a message excerpt). */ + val body: String?, + /** ISO-8601 creation instant, used to derive a relative timestamp for display. */ + val createdAt: String?, + /** Whether the recipient has read this notification (drives unread styling). */ + val read: Boolean, + /** Optional deep-link target this notification refers to; null when self-contained. */ + val target: NotificationTarget?, +) { + /** Best available display label for the actor, or null when there is no actor. */ + val actorLabel: String? + get() = actor?.let { it.displayName?.takeIf(String::isNotBlank) ?: it.username.takeIf(String::isNotBlank) } +} + +/** The user who caused a notification (liked, replied, followed, …). */ +data class NotificationActor( + val id: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) + +/** + * A deep-link target a notification points at. [kind] categorises what is being + * referenced so the app can route to the right screen; [id] identifies the entity. + * Kept deliberately small — the app decides how (or whether) to navigate. + */ +data class NotificationTarget( + val kind: NotificationTargetKind, + val id: String, +) + +/** What a notification's target refers to. Unknown kinds fall back to [OTHER]. */ +enum class NotificationTargetKind { + MESSAGE, + USER, + LIST, + OTHER, +} + +/** + * Coarse notification category. The API sends free-form type strings; anything we + * don't recognise maps to [OTHER] so the list still renders sensibly (defensive by + * design — see [NotificationType.fromWire]). + */ +enum class NotificationType { + /** Someone liked/dug the recipient's content. */ + LIKE, + + /** Someone replied to or commented on the recipient's content. */ + REPLY, + + /** Someone mentioned the recipient. */ + MENTION, + + /** Someone started following the recipient. */ + FOLLOW, + + /** Someone shared a list (or added the recipient to one). */ + LIST_SHARE, + + /** A direct/private message notification. */ + MESSAGE, + + /** System/account notification (billing, security, …). */ + SYSTEM, + + /** Unrecognised type; still displayed generically. */ + OTHER; + + companion object { + /** + * Maps a free-form wire `type` string to a [NotificationType]. Matching is + * case-insensitive and substring-based so minor server naming variations + * ("message_reply", "new-follower", …) still resolve; unknowns become [OTHER]. + */ + fun fromWire(raw: String?): NotificationType { + val value = raw?.trim()?.lowercase().orEmpty() + return when { + value.isEmpty() -> OTHER + value.contains("like") || value.contains("dig") || value.contains("favorite") -> LIKE + value.contains("reply") || value.contains("comment") -> REPLY + value.contains("mention") || value.contains("tag") -> MENTION + value.contains("follow") -> FOLLOW + value.contains("list") || value.contains("share") -> LIST_SHARE + value.contains("message") || value.contains("dm") -> MESSAGE + value.contains("system") || value.contains("account") || + value.contains("billing") || value.contains("security") -> SYSTEM + else -> OTHER + } + } + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsErrorMessages.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsErrorMessages.kt new file mode 100644 index 0000000..9f5ac3b --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsErrorMessages.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.notifications.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the list UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> "Your session expired. Please sign in again." + is AppError.SubscriptionRequired -> message ?: "Notifications require an active subscription." + is AppError.NotFound -> "This notification is no longer available." + is AppError.RateLimited -> "Slow down a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} + +/** True when the error should render the subscription upsell/locked state. */ +val AppError.isSubscriptionGate: Boolean + get() = this is AppError.SubscriptionRequired diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt new file mode 100644 index 0000000..f581985 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt @@ -0,0 +1,320 @@ +package com.interlinedlist.android.feature.notifications.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationTarget +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import com.interlinedlist.android.feature.notifications.ui.components.NotificationRow + +/** Stable test tags for the notifications screen. */ +object NotificationsTags { + const val LIST = "notificationsList" + const val EMPTY = "notificationsEmpty" + const val ERROR = "notificationsError" + const val LOCKED = "notificationsLocked" + const val PROGRESS = "notificationsProgress" + const val BACK = "notificationsBack" + const val MARK_ALL_READ = "notificationsMarkAllRead" + const val UNREAD_BADGE = "notificationsUnreadBadge" +} + +/** + * Hilt-wired notifications entry point. Reached from the Account hub as a drill-down; + * mirrors the back pattern used by the other detail screens. + * + * @param onBack pops the notifications screen off the back stack. + * @param onOpenTarget optional deep-link handler. When a tapped notification carries a + * [NotificationTarget] (a message / user / list it refers to), this is invoked with + * that target so the app can navigate to it; the notification is marked read either + * way. Defaults to a no-op, which keeps the screen a self-contained list — pass a + * real handler only when the app is ready to route on notification targets. + */ +@Composable +fun NotificationsRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + onOpenTarget: (NotificationTarget) -> Unit = {}, + viewModel: NotificationsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + NotificationsScreen( + state = state, + onBack = onBack, + onRefresh = viewModel::refresh, + onLoadMore = viewModel::loadMore, + onMarkAllRead = viewModel::onMarkAllRead, + onOpen = { notification -> + // Marking read is offline-first; navigation (if any) is the app's concern. + viewModel.onOpen(notification) + notification.target?.let(onOpenTarget) + }, + onDismiss = viewModel::onDismiss, + modifier = modifier, + ) +} + +/** Stateless notifications UI — drives all list/empty/error/locked states from [state]. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationsScreen( + state: NotificationsUiState, + onBack: () -> Unit, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + onMarkAllRead: () -> Unit, + onOpen: (Notification) -> Unit, + onDismiss: (Notification) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + if (state.hasUnread) { + BadgedBox( + badge = { + Badge(modifier = Modifier.testTag(NotificationsTags.UNREAD_BADGE)) { + Text(state.unreadCount.coerceAtMost(99).toString()) + } + }, + ) { + Text("Notifications") + } + } else { + Text("Notifications") + } + }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(NotificationsTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + if (state.hasUnread && !state.subscriptionRequired) { + IconButton( + onClick = onMarkAllRead, + modifier = Modifier.testTag(NotificationsTags.MARK_ALL_READ), + ) { + Icon(Icons.Filled.DoneAll, contentDescription = "Mark all read") + } + } + }, + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> LockedState( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + else -> Content( + state = state, + contentPadding = padding, + onRefresh = onRefresh, + onLoadMore = onLoadMore, + onOpen = onOpen, + onDismiss = onDismiss, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun Content( + state: NotificationsUiState, + contentPadding: PaddingValues, + onRefresh: () -> Unit, + onLoadMore: () -> Unit, + onOpen: (Notification) -> Unit, + onDismiss: (Notification) -> Unit, +) { + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + when { + state.isEmpty && state.isRefreshing -> LoadingState() + state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) + state.isEmpty -> EmptyState() + else -> NotificationList( + state = state, + onLoadMore = onLoadMore, + onOpen = onOpen, + onDismiss = onDismiss, + ) + } + } +} + +@Composable +private fun NotificationList( + state: NotificationsUiState, + onLoadMore: () -> Unit, + onOpen: (Notification) -> Unit, + onDismiss: (Notification) -> Unit, +) { + val listState = rememberLazyListState() + // Trigger load-more when the last item scrolls into view. + val shouldLoadMore by remember { + derivedStateOf { + val last = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + state.canLoadMore && !state.isLoadingMore && last >= state.notifications.size - 3 + } + } + if (shouldLoadMore) onLoadMore() + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .testTag(NotificationsTags.LIST), + ) { + items(state.notifications, key = { it.id }) { notification -> + NotificationRow( + notification = notification, + onClick = { onOpen(notification) }, + onDismiss = { onDismiss(notification) }, + ) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + if (state.isLoadingMore) { + item { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.height(24.dp)) + } + } + } + } +} + +@Composable +private fun LoadingState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(NotificationsTags.PROGRESS)) + } +} + +@Composable +private fun EmptyState() { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = "You're all caught up. No notifications yet.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(NotificationsTags.EMPTY), + ) + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(NotificationsTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } +} + +@Composable +private fun LockedState(message: String?, modifier: Modifier = Modifier) { + Column( + modifier = modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Subscribers only", + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Upgrade to an active subscription to view your notifications.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(NotificationsTags.LOCKED), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationsPreview() { + InterlinedListTheme { + NotificationsScreen( + state = NotificationsUiState( + notifications = listOf( + Notification( + id = "1", type = NotificationType.FOLLOW, actor = null, + subject = "Amy started following you", body = null, + createdAt = null, read = false, target = null, + ), + Notification( + id = "2", type = NotificationType.REPLY, actor = null, + subject = "Ben replied to your post", body = "\"Great idea!\"", + createdAt = null, read = true, target = null, + ), + ), + unreadCount = 1, + ), + onBack = {}, onRefresh = {}, onLoadMore = {}, onMarkAllRead = {}, + onOpen = {}, onDismiss = {}, + ) + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModel.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModel.kt new file mode 100644 index 0000000..90b7eea --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModel.kt @@ -0,0 +1,145 @@ +package com.interlinedlist.android.feature.notifications.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.notifications.data.NotificationsRepository +import com.interlinedlist.android.feature.notifications.domain.Notification +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Notifications screen state: the cached list + unread count + transient flags. */ +data class NotificationsUiState( + val notifications: List = emptyList(), + val unreadCount: Int = 0, + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = false, + val errorMessage: String? = null, + /** True when the failure is a subscription gate — render an upsell instead. */ + val subscriptionRequired: Boolean = false, +) { + val isEmpty: Boolean get() = notifications.isEmpty() + val hasUnread: Boolean get() = unreadCount > 0 +} + +/** Transient (non-cached) UI flags kept separate from the Room-backed list. */ +private data class NotificationsTransientState( + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val canLoadMore: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, +) + +@HiltViewModel +class NotificationsViewModel @Inject constructor( + private val repository: NotificationsRepository, +) : ViewModel() { + + private val transient = MutableStateFlow(NotificationsTransientState()) + + /** + * Room is the source of truth: the list and unread count come from the cache + * Flows and are combined with transient flags into a single [NotificationsUiState]. + */ + val uiState: StateFlow = + combine( + repository.observeNotifications(), + repository.observeUnreadCount(), + transient, + ) { notifications, unread, t -> + NotificationsUiState( + notifications = notifications, + unreadCount = unread, + isRefreshing = t.isRefreshing, + isLoadingMore = t.isLoadingMore, + canLoadMore = t.canLoadMore, + errorMessage = t.errorMessage, + subscriptionRequired = t.subscriptionRequired, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = NotificationsUiState(), + ) + + init { + refresh() + } + + fun refresh() { + transient.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.refresh()) { + is ApiResult.Success -> transient.update { + it.copy(isRefreshing = false, canLoadMore = result.data) + } + is ApiResult.Failure -> transient.update { + it.copy(isRefreshing = false).withError(result.error) + } + } + } + } + + fun loadMore() { + val current = uiState.value + if (current.isLoadingMore || !current.canLoadMore) return + transient.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + when (val result = repository.loadMore(currentCount = current.notifications.size)) { + is ApiResult.Success -> transient.update { + it.copy(isLoadingMore = false, canLoadMore = result.data) + } + is ApiResult.Failure -> transient.update { + it.copy(isLoadingMore = false).withError(result.error) + } + } + } + } + + /** Marks a notification read (on tap). Repository updates the cache optimistically. */ + fun onOpen(notification: Notification) { + if (notification.read) return + viewModelScope.launch { + val result = repository.markRead(notification.id) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + /** Marks every notification read (top-bar action). */ + fun onMarkAllRead() { + viewModelScope.launch { + val result = repository.markAllRead() + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + /** Dismisses a notification (swipe or overflow). */ + fun onDismiss(notification: Notification) { + viewModelScope.launch { + val result = repository.dismiss(notification.id) + if (result is ApiResult.Failure) { + transient.update { it.withError(result.error) } + } + } + } + + fun dismissError() = transient.update { it.copy(errorMessage = null, subscriptionRequired = false) } + + private fun NotificationsTransientState.withError(error: AppError?): NotificationsTransientState = + if (error == null) this + else copy(errorMessage = error.toUserMessage(), subscriptionRequired = error.isSubscriptionGate) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTime.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTime.kt new file mode 100644 index 0000000..4fdb969 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTime.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.notifications.ui + +import java.time.Duration +import java.time.Instant + +/** + * Formats an ISO-8601 instant as a short relative label ("just now", "5m", "3h", + * "2d", "4w"). Falls back to the raw string when it cannot be parsed, and to an + * empty string when null, so the UI never crashes on unexpected timestamps. + * + * [now] is injectable to keep the mapping deterministic in tests. + */ +fun relativeTime(isoTimestamp: String?, now: Instant = Instant.now()): String { + if (isoTimestamp.isNullOrBlank()) return "" + val then = runCatching { Instant.parse(isoTimestamp) }.getOrElse { + return@relativeTime isoTimestamp + } + val seconds = Duration.between(then, now).seconds + if (seconds < 0) return "just now" + return when { + seconds < 60 -> "just now" + seconds < 3_600 -> "${seconds / 60}m" + seconds < 86_400 -> "${seconds / 3_600}h" + seconds < 604_800 -> "${seconds / 86_400}d" + else -> "${seconds / 604_800}w" + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/components/NotificationRow.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/components/NotificationRow.kt new file mode 100644 index 0000000..9b2a0f5 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/components/NotificationRow.kt @@ -0,0 +1,175 @@ +package com.interlinedlist.android.feature.notifications.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Chat +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Campaign +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import com.interlinedlist.android.feature.notifications.ui.relativeTime + +/** Stable test tags for a notification row. */ +object NotificationRowTags { + const val ROW = "notificationRow" + const val UNREAD_DOT = "notificationUnreadDot" + const val MENU = "notificationRowMenu" + const val DISMISS = "notificationRowDismiss" +} + +/** + * A single notification list item: a leading type icon, the summary line and an + * optional body/actor + relative time, an unread dot, and an overflow menu offering + * "Dismiss". Unread rows are tinted and bold; read rows are muted. Tapping the row + * invokes [onClick] (which the ViewModel uses to mark it read). + */ +@Composable +fun NotificationRow( + notification: Notification, + onClick: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val unread = !notification.read + val background = + if (unread) MaterialTheme.colorScheme.primary.copy(alpha = 0.06f) + else MaterialTheme.colorScheme.surface + + Row( + modifier = modifier + .fillMaxWidth() + .background(background) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + .testTag(NotificationRowTags.ROW), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = notification.type.icon(), + contentDescription = null, + tint = if (unread) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + ) + Spacer(Modifier.size(12.dp)) + + Column(Modifier.weight(1f)) { + Text( + text = notification.displayTitle(), + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (unread) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + notification.body?.takeIf { it.isNotBlank() }?.let { body -> + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val time = relativeTime(notification.createdAt) + if (time.isNotBlank()) { + Text( + text = time, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (unread) { + Box( + Modifier + .size(10.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape) + .testTag(NotificationRowTags.UNREAD_DOT), + ) + Spacer(Modifier.size(4.dp)) + } + + OverflowMenu(onDismiss = onDismiss) + } +} + +@Composable +private fun OverflowMenu(onDismiss: () -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { expanded = true }, + modifier = Modifier.testTag(NotificationRowTags.MENU), + ) { + Icon(Icons.Filled.MoreVert, contentDescription = "More") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text("Dismiss") }, + onClick = { + expanded = false + onDismiss() + }, + modifier = Modifier.testTag(NotificationRowTags.DISMISS), + ) + } + } +} + +/** The line shown as the row title: the server subject, or a sensible fallback. */ +private fun Notification.displayTitle(): String { + if (subject.isNotBlank()) return subject + val who = actorLabel ?: "Someone" + return when (type) { + NotificationType.LIKE -> "$who liked your post" + NotificationType.REPLY -> "$who replied to your post" + NotificationType.MENTION -> "$who mentioned you" + NotificationType.FOLLOW -> "$who started following you" + NotificationType.LIST_SHARE -> "$who shared a list with you" + NotificationType.MESSAGE -> "New message from $who" + NotificationType.SYSTEM -> "Account notification" + NotificationType.OTHER -> "New notification" + } +} + +/** Leading icon for a notification type. */ +private fun NotificationType.icon(): ImageVector = when (this) { + NotificationType.LIKE -> Icons.Filled.Favorite + NotificationType.REPLY -> Icons.AutoMirrored.Filled.Reply + NotificationType.MENTION -> Icons.AutoMirrored.Filled.Chat + NotificationType.FOLLOW -> Icons.Filled.PersonAdd + NotificationType.LIST_SHARE -> Icons.AutoMirrored.Filled.PlaylistAdd + NotificationType.MESSAGE -> Icons.Filled.Campaign + NotificationType.SYSTEM -> Icons.Filled.Settings + NotificationType.OTHER -> Icons.Filled.Notifications +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepositoryTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepositoryTest.kt new file mode 100644 index 0000000..6d4a5ec --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationsRepositoryTest.kt @@ -0,0 +1,240 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.notifications.data.remote.NotificationsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNotificationsRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private lateinit var server: MockWebServer + private lateinit var api: NotificationsApi + private lateinit var dao: FakeNotificationDao + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val contentType = "application/json".toMediaType() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(NotificationsApi::class.java) + dao = FakeNotificationDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultNotificationsRepository( + api = api, + notificationDao = dao, + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueueJson(code: Int, body: String) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun `refresh caches notifications and reports hasMore`() = runTest(dispatcher) { + enqueueJson( + 200, + """ + { + "data": [ + { "id": "1", "type": "follow", "subject": "Amy followed you", "read": false }, + { "id": "2", "type": "reply", "subject": "Ben replied", "read": true } + ], + "pagination": { "total": 5, "limit": 20, "offset": 0, "hasMore": true } + } + """.trimIndent(), + ) + val repo = repository() + + val result = repo.refresh() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isTrue() // hasMore + val cached = repo.observeNotifications().first() + assertThat(cached.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(repo.observeUnreadCount().first()).isEqualTo(1) + } + + @Test + fun `refresh maps a 403 subscription error`() = runTest(dispatcher) { + enqueueJson(403, """{ "error": "An active subscription is required." }""") + val repo = repository() + + val result = repo.refresh() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `loadMore appends after existing rows and carries the offset`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "a" } ], + "pagination": { "hasMore": true } }""", + ) + enqueueJson( + 200, + """{ "data": [ { "id": "2", "subject": "b" } ], + "pagination": { "hasMore": false } }""", + ) + val repo = repository() + repo.refresh() + + val more = repo.loadMore(currentCount = 1) + + assertThat((more as ApiResult.Success).data).isFalse() // no more pages + val ids = repo.observeNotifications().first().map { it.id } + assertThat(ids).containsExactly("1", "2").inOrder() + + server.takeRequest() // first (refresh) request + val secondPath = server.takeRequest().path + assertThat(secondPath).contains("offset=1") + } + + @Test + fun `markRead flips the cached row and hits the read endpoint`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "x", "read": false } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(200, "") + val repo = repository() + repo.refresh() + + val result = repo.markRead("1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo.observeNotifications().first().first().read).isTrue() + assertThat(repo.observeUnreadCount().first()).isEqualTo(0) + server.takeRequest() // refresh + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PATCH") + assertThat(request.path).contains("api/notifications/1/read") + } + + @Test + fun `markRead rolls back the cache on failure`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "x", "read": false } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(500, """{ "error": "boom" }""") + val repo = repository() + repo.refresh() + + val result = repo.markRead("1") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + // Rolled back to unread. + assertThat(repo.observeNotifications().first().first().read).isFalse() + assertThat(repo.observeUnreadCount().first()).isEqualTo(1) + } + + @Test + fun `markAllRead marks every cached row read`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "x", "read": false }, + { "id": "2", "subject": "y", "read": false } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(201, "") + val repo = repository() + repo.refresh() + + val result = repo.markAllRead() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo.observeUnreadCount().first()).isEqualTo(0) + server.takeRequest() // refresh + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).contains("api/notifications/mark-all-read") + } + + @Test + fun `markAllRead restores read-states on failure`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "x", "read": false }, + { "id": "2", "subject": "y", "read": true } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(500, """{ "error": "nope" }""") + val repo = repository() + repo.refresh() + + val result = repo.markAllRead() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + // The originally-unread row is unread again; the count is restored. + assertThat(repo.observeUnreadCount().first()).isEqualTo(1) + } + + @Test + fun `dismiss removes the notification from the cache`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "x" }, { "id": "2", "subject": "y" } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(200, "") + val repo = repository() + repo.refresh() + + val result = repo.dismiss("1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(repo.observeNotifications().first().map { it.id }).containsExactly("2") + server.takeRequest() // refresh + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).contains("api/notifications/1") + } + + @Test + fun `dismiss restores the row on failure`() = runTest(dispatcher) { + enqueueJson( + 200, + """{ "data": [ { "id": "1", "subject": "x" } ], + "pagination": { "hasMore": false } }""", + ) + enqueueJson(404, """{ "error": "gone" }""") + val repo = repository() + repo.refresh() + + val result = repo.dismiss("1") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat(repo.observeNotifications().first().map { it.id }).containsExactly("1") + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/FakeNotificationDao.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/FakeNotificationDao.kt new file mode 100644 index 0000000..77553e5 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/FakeNotificationDao.kt @@ -0,0 +1,60 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.feature.notifications.data.local.NotificationDao +import com.interlinedlist.android.feature.notifications.data.local.NotificationEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory stand-in for the Room [NotificationDao] so repository/ViewModel logic + * can be unit-tested on the JVM without an Android runtime. Mirrors the query + * semantics of the real DAO (ordered by listOrder; unread = read == false). + */ +class FakeNotificationDao : NotificationDao { + + private val rows = MutableStateFlow>(emptyMap()) + + private fun sorted(): List = + rows.value.values.sortedBy { it.listOrder } + + override fun observeNotifications(): Flow> = + rows.map { map -> map.values.sortedBy { it.listOrder } } + + override fun observeUnreadCount(): Flow = + rows.map { map -> map.values.count { !it.read } } + + override suspend fun insertAll(notifications: List) { + rows.value = rows.value.toMutableMap().apply { + notifications.forEach { put(it.id, it) } + } + } + + override suspend fun upsert(notification: NotificationEntity) { + rows.value = rows.value.toMutableMap().apply { put(notification.id, notification) } + } + + override suspend fun findById(id: String): NotificationEntity? = rows.value[id] + + override suspend fun deleteById(id: String) { + rows.value = rows.value.toMutableMap().apply { remove(id) } + } + + override suspend fun markRead(id: String) { + rows.value[id]?.let { upsertNow(it.copy(read = true)) } + } + + override suspend fun markAllRead() { + rows.value = rows.value.mapValues { (_, entity) -> entity.copy(read = true) } + } + + override suspend fun clear() { + rows.value = emptyMap() + } + + override suspend fun maxListOrder(): Long? = sorted().maxOfOrNull { it.listOrder } + + private fun upsertNow(entity: NotificationEntity) { + rows.value = rows.value.toMutableMap().apply { put(entity.id, entity) } + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/TestDoubles.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/TestDoubles.kt new file mode 100644 index 0000000..976f64f --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/TestDoubles.kt @@ -0,0 +1,11 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher + +/** DispatcherProvider that runs everything on the supplied test dispatcher. */ +class TestDispatcherProvider(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDtoMapperTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDtoMapperTest.kt new file mode 100644 index 0000000..0b25199 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationDtoMapperTest.kt @@ -0,0 +1,148 @@ +package com.interlinedlist.android.feature.notifications.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.notifications.domain.NotificationTargetKind +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import kotlinx.serialization.json.Json +import org.junit.Test + +class NotificationDtoMapperTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + @Test + fun `maps core wire fields into the domain model`() { + val dto = NotificationDto( + id = "n1", + type = "reply", + actor = NotificationActorDto(id = "u1", username = "amy", displayName = "Amy", avatar = "a.png"), + subject = "Amy replied to your post", + body = "\"nice work\"", + createdAt = "2026-07-18T10:00:00Z", + read = false, + ) + + val notification = dto.toDomain() + + assertThat(notification.id).isEqualTo("n1") + assertThat(notification.type).isEqualTo(NotificationType.REPLY) + assertThat(notification.actor?.username).isEqualTo("amy") + assertThat(notification.actor?.displayName).isEqualTo("Amy") + assertThat(notification.actor?.avatarUrl).isEqualTo("a.png") + assertThat(notification.subject).isEqualTo("Amy replied to your post") + assertThat(notification.body).isEqualTo("\"nice work\"") + assertThat(notification.createdAt).isEqualTo("2026-07-18T10:00:00Z") + assertThat(notification.read).isFalse() + } + + @Test + fun `unknown type falls back to OTHER`() { + val notification = NotificationDto(id = "n2", type = "quantum-entanglement").toDomain() + assertThat(notification.type).isEqualTo(NotificationType.OTHER) + } + + @Test + fun `type matching is substring and case insensitive`() { + assertThat(NotificationDto(id = "1", type = "NEW_FOLLOWER").toDomain().type) + .isEqualTo(NotificationType.FOLLOW) + assertThat(NotificationDto(id = "2", type = "message_reply").toDomain().type) + .isEqualTo(NotificationType.REPLY) + assertThat(NotificationDto(id = "3", type = "post-liked").toDomain().type) + .isEqualTo(NotificationType.LIKE) + } + + @Test + fun `reads the actor from the sender alternate key`() { + val notification = NotificationDto( + id = "n3", + sender = NotificationActorDto(id = "u9", username = "ben", name = "Ben"), + ).toDomain() + + assertThat(notification.actor?.username).isEqualTo("ben") + // displayName falls back to the `name` alternate. + assertThat(notification.actor?.displayName).isEqualTo("Ben") + } + + @Test + fun `an actor with no identity is dropped`() { + val notification = NotificationDto( + id = "n4", + actor = NotificationActorDto(), + ).toDomain() + assertThat(notification.actor).isNull() + } + + @Test + fun `read state is derived from isRead alternate`() { + assertThat(NotificationDto(id = "1", isRead = true).toDomain().read).isTrue() + assertThat(NotificationDto(id = "2").toDomain().read).isFalse() + } + + @Test + fun `read state is derived from a readAt timestamp`() { + val notification = NotificationDto(id = "5", readAt = "2026-07-18T11:00:00Z").toDomain() + assertThat(notification.read).isTrue() + } + + @Test + fun `subject falls back across title message and text keys`() { + assertThat(NotificationDto(id = "1", title = "T").toDomain().subject).isEqualTo("T") + assertThat(NotificationDto(id = "2", message = "M").toDomain().subject).isEqualTo("M") + assertThat(NotificationDto(id = "3", text = "X").toDomain().subject).isEqualTo("X") + assertThat(NotificationDto(id = "4").toDomain().subject).isEmpty() + } + + @Test + fun `resolves a nested target`() { + val notification = NotificationDto( + id = "n6", + target = NotificationTargetDto(type = "message", id = "m42"), + ).toDomain() + + assertThat(notification.target?.kind).isEqualTo(NotificationTargetKind.MESSAGE) + assertThat(notification.target?.id).isEqualTo("m42") + } + + @Test + fun `resolves a flat target`() { + val notification = NotificationDto( + id = "n7", + targetType = "user", + targetId = "u7", + ).toDomain() + + assertThat(notification.target?.kind).isEqualTo(NotificationTargetKind.USER) + assertThat(notification.target?.id).isEqualTo("u7") + } + + @Test + fun `an unknown target type maps to OTHER`() { + val notification = NotificationDto( + id = "n8", + targetType = "widget", + targetId = "w1", + ).toDomain() + assertThat(notification.target?.kind).isEqualTo(NotificationTargetKind.OTHER) + } + + @Test + fun `a target without an id is dropped`() { + val notification = NotificationDto(id = "n9", targetType = "message").toDomain() + assertThat(notification.target).isNull() + } + + @Test + fun `tolerates unknown keys and a minimal body`() { + // A payload with extra fields and only an id still decodes and maps cleanly. + val dto = json.decodeFromString( + NotificationDto.serializer(), + """{ "id": "n10", "somethingNew": true, "nested": { "x": 1 } }""", + ) + val notification = dto.toDomain() + assertThat(notification.id).isEqualTo("n10") + assertThat(notification.type).isEqualTo(NotificationType.OTHER) + assertThat(notification.read).isFalse() + assertThat(notification.actor).isNull() + assertThat(notification.target).isNull() + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt new file mode 100644 index 0000000..aa2bd6d --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationsResponseTest.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.notifications.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +class NotificationsResponseTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + @Test + fun `reads the list from the data key`() { + val response = json.decodeFromString( + NotificationsResponse.serializer(), + """{ "data": [ { "id": "1" }, { "id": "2" } ], + "pagination": { "hasMore": true } }""", + ) + assertThat(response.items.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(response.pagination.hasMore).isTrue() + } + + @Test + fun `falls back to the notifications key when data is absent`() { + val response = json.decodeFromString( + NotificationsResponse.serializer(), + """{ "notifications": [ { "id": "a" } ] }""", + ) + assertThat(response.items.map { it.id }).containsExactly("a") + } + + @Test + fun `an empty body decodes with sane defaults`() { + val response = json.decodeFromString(NotificationsResponse.serializer(), "{}") + assertThat(response.items).isEmpty() + assertThat(response.pagination.hasMore).isFalse() + assertThat(response.pagination.limit).isEqualTo(PaginationDto.DEFAULT_LIMIT) + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt new file mode 100644 index 0000000..a585ccf --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationsRepository.kt @@ -0,0 +1,94 @@ +package com.interlinedlist.android.feature.notifications.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.data.NotificationsRepository +import com.interlinedlist.android.feature.notifications.domain.Notification +import com.interlinedlist.android.feature.notifications.domain.NotificationTarget +import com.interlinedlist.android.feature.notifications.domain.NotificationType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * A configurable in-memory [NotificationsRepository] for ViewModel tests. The list + * and unread count are exposed as Flows (source of truth), and each operation's + * result can be pre-set to drive success/failure paths. + */ +class FakeNotificationsRepository : NotificationsRepository { + + private val notifications = MutableStateFlow>(emptyList()) + + var refreshResult: ApiResult = ApiResult.Success(false) + var loadMoreResult: ApiResult = ApiResult.Success(false) + var markReadResult: ApiResult = ApiResult.Success(Unit) + var markAllReadResult: ApiResult = ApiResult.Success(Unit) + var dismissResult: ApiResult = ApiResult.Success(Unit) + + var refreshCount = 0 + var loadMoreCount = 0 + var markReadIds = mutableListOf() + var markAllReadCount = 0 + var dismissedIds = mutableListOf() + + fun emit(items: List) { notifications.value = items } + + override fun observeNotifications(): Flow> = notifications + + override fun observeUnreadCount(): Flow = + notifications.map { list -> list.count { !it.read } } + + override suspend fun refresh(): ApiResult { + refreshCount++ + return refreshResult + } + + override suspend fun loadMore(currentCount: Int): ApiResult { + loadMoreCount++ + return loadMoreResult + } + + override suspend fun markRead(id: String): ApiResult { + markReadIds += id + if (markReadResult is ApiResult.Success) { + notifications.value = notifications.value.map { + if (it.id == id) it.copy(read = true) else it + } + } + return markReadResult + } + + override suspend fun markAllRead(): ApiResult { + markAllReadCount++ + if (markAllReadResult is ApiResult.Success) { + notifications.value = notifications.value.map { it.copy(read = true) } + } + return markAllReadResult + } + + override suspend fun dismiss(id: String): ApiResult { + dismissedIds += id + if (dismissResult is ApiResult.Success) { + notifications.value = notifications.value.filterNot { it.id == id } + } + return dismissResult + } +} + +/** Builds a sample [Notification] for tests. */ +fun sampleNotification( + id: String = "1", + type: NotificationType = NotificationType.FOLLOW, + subject: String = "Amy started following you", + body: String? = null, + read: Boolean = false, + target: NotificationTarget? = null, +) = Notification( + id = id, + type = type, + actor = null, + subject = subject, + body = body, + createdAt = null, + read = read, + target = target, +) diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModelTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModelTest.kt new file mode 100644 index 0000000..2cf380e --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsViewModelTest.kt @@ -0,0 +1,217 @@ +package com.interlinedlist.android.feature.notifications.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `list emits cached notifications and the unread count`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository() + repo.emit( + listOf( + sampleNotification(id = "1", read = false), + sampleNotification(id = "2", read = true), + ), + ) + val vm = NotificationsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.notifications.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(state.unreadCount).isEqualTo(1) + assertThat(state.hasUnread).isTrue() + assertThat(state.isRefreshing).isFalse() + } + } + + @Test + fun `refresh runs on init and toggles the refreshing flag`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { refreshResult = ApiResult.Success(true) } + val vm = NotificationsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(repo.refreshCount).isEqualTo(1) + assertThat(state.isRefreshing).isFalse() + assertThat(state.canLoadMore).isTrue() + } + } + + @Test + fun `refresh failure surfaces a mapped error`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { + refreshResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = NotificationsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.errorMessage).isEqualTo("No connection. Check your network and try again.") + assertThat(state.subscriptionRequired).isFalse() + } + } + + @Test + fun `subscription-gated refresh sets the locked flag`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { + refreshResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribers only")) + } + val vm = NotificationsViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.subscriptionRequired).isTrue() + assertThat(state.errorMessage).isEqualTo("Subscribers only") + } + } + + @Test + fun `loadMore is a no-op when there are no more pages`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { refreshResult = ApiResult.Success(false) } + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(0) + } + + @Test + fun `loadMore fetches the next page when more are available`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { + refreshResult = ApiResult.Success(true) + loadMoreResult = ApiResult.Success(false) + } + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(1) + assertThat(vm.uiState.value.canLoadMore).isFalse() + } + + @Test + fun `opening an unread notification marks it read`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository() + repo.emit(listOf(sampleNotification(id = "42", read = false))) + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onOpen(sampleNotification(id = "42", read = false)) + advanceUntilIdle() + + assertThat(repo.markReadIds).containsExactly("42") + assertThat(vm.uiState.value.unreadCount).isEqualTo(0) + } + + @Test + fun `opening an already-read notification does not call the repository`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository() + val vm = NotificationsViewModel(repo) + advanceUntilIdle() + + vm.onOpen(sampleNotification(id = "9", read = true)) + advanceUntilIdle() + + assertThat(repo.markReadIds).isEmpty() + } + + @Test + fun `mark all read delegates to the repository`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository() + repo.emit( + listOf( + sampleNotification(id = "1", read = false), + sampleNotification(id = "2", read = false), + ), + ) + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onMarkAllRead() + advanceUntilIdle() + + assertThat(repo.markAllReadCount).isEqualTo(1) + assertThat(vm.uiState.value.unreadCount).isEqualTo(0) + assertThat(vm.uiState.value.hasUnread).isFalse() + } + + @Test + fun `dismiss delegates to the repository and removes the row`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository() + repo.emit(listOf(sampleNotification(id = "1"), sampleNotification(id = "2"))) + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onDismiss(sampleNotification(id = "1")) + advanceUntilIdle() + + assertThat(repo.dismissedIds).containsExactly("1") + assertThat(vm.uiState.value.notifications.map { it.id }).containsExactly("2") + } + + @Test + fun `a failed dismiss surfaces an error`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { + dismissResult = ApiResult.Failure(AppError.Server("boom")) + } + repo.emit(listOf(sampleNotification(id = "1"))) + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onDismiss(sampleNotification(id = "1")) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotEmpty() + } + + @Test + fun `dismissError clears the error and locked flags`() = runTest(dispatcher) { + val repo = FakeNotificationsRepository().apply { + refreshResult = ApiResult.Failure(AppError.SubscriptionRequired("locked")) + } + val vm = NotificationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + + vm.dismissError() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNull() + assertThat(vm.uiState.value.subscriptionRequired).isFalse() + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTimeTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTimeTest.kt new file mode 100644 index 0000000..0adc5b6 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/RelativeTimeTest.kt @@ -0,0 +1,39 @@ +package com.interlinedlist.android.feature.notifications.ui + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import java.time.Instant + +class RelativeTimeTest { + + private val now = Instant.parse("2026-07-18T12:00:00Z") + + @Test + fun `null or blank yields empty string`() { + assertThat(relativeTime(null, now)).isEmpty() + assertThat(relativeTime(" ", now)).isEmpty() + } + + @Test + fun `recent instants read as just now`() { + assertThat(relativeTime("2026-07-18T11:59:30Z", now)).isEqualTo("just now") + } + + @Test + fun `minutes hours days and weeks are abbreviated`() { + assertThat(relativeTime("2026-07-18T11:55:00Z", now)).isEqualTo("5m") + assertThat(relativeTime("2026-07-18T09:00:00Z", now)).isEqualTo("3h") + assertThat(relativeTime("2026-07-16T12:00:00Z", now)).isEqualTo("2d") + assertThat(relativeTime("2026-07-04T12:00:00Z", now)).isEqualTo("2w") + } + + @Test + fun `a future instant reads as just now`() { + assertThat(relativeTime("2026-07-18T12:05:00Z", now)).isEqualTo("just now") + } + + @Test + fun `an unparseable timestamp falls back to the raw string`() { + assertThat(relativeTime("yesterday", now)).isEqualTo("yesterday") + } +} diff --git a/feature/organizations/src/androidTest/AndroidManifest.xml b/feature/organizations/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/organizations/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt b/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt new file mode 100644 index 0000000..22587e8 --- /dev/null +++ b/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreenTest.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.feature.organizations.ui.detail + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Compose UI coverage for the stateless [OrganizationDetailScreen]. Runs on-device; + * the orchestrator executes instrumented tests after merge, so this is written to + * compile and be correct. + */ +@RunWith(AndroidJUnit4::class) +class OrganizationDetailScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen( + state: OrganizationDetailUiState, + onRemoveMember: (OrgMember) -> Unit = {}, + onDelete: () -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + OrganizationDetailScreen( + state = state, + onBack = {}, + onSearchQueryChange = {}, + onAddCandidate = {}, + onChangeRole = { _, _ -> }, + onRemoveMember = onRemoveMember, + onSaveEdit = { _, _, _ -> }, + onDelete = onDelete, + ) + } + } + } + + private fun loaded() = OrganizationDetailUiState( + organization = Organization("o1", "Acme Corp", "Makers", null, false, 2, OrgRole.OWNER, null), + members = listOf( + OrgMember("u1", "ada", "Ada", null, OrgRole.OWNER, active = true), + OrgMember("u2", "grace", null, null, OrgRole.MEMBER, active = true), + ), + isLoading = false, + ) + + @Test + fun rendersMembers_andSearchField() { + setScreen(state = loaded()) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.SEARCH).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationDetailTestTags.member("u1")).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationDetailTestTags.member("u2")).assertIsDisplayed() + } + + @Test + fun removesMember_onCloseTap() { + var removed: OrgMember? = null + setScreen(state = loaded(), onRemoveMember = { removed = it }) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.remove("u1")).performClick() + assert(removed?.userId == "u1") + } + + @Test + fun deleteFlow_confirmsBeforeDeleting() { + var deleted = false + setScreen(state = loaded(), onDelete = { deleted = true }) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.OVERFLOW).performClick() + composeRule.onNodeWithTag(OrganizationDetailTestTags.DELETE).performClick() + // A confirmation dialog appears before the destructive delete fires. + composeRule.onNodeWithTag(OrganizationDetailTestTags.DELETE_DIALOG).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationDetailTestTags.DELETE_CONFIRM).performClick() + assert(deleted) + } + + @Test + fun showsEmptyState_whenNoMembers() { + setScreen( + state = OrganizationDetailUiState( + organization = Organization("o1", "Acme", null, null, false, 0, OrgRole.OWNER, null), + members = emptyList(), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(OrganizationDetailTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreenTest.kt b/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreenTest.kt new file mode 100644 index 0000000..094111f --- /dev/null +++ b/feature/organizations/src/androidTest/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreenTest.kt @@ -0,0 +1,89 @@ +package com.interlinedlist.android.feature.organizations.ui.list + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.organizations.domain.Organization +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Compose UI coverage for the stateless [OrganizationsScreen]. Runs on-device; the + * orchestrator executes instrumented tests after merge (no emulator in the + * worktree), so this is written to compile and be correct. + */ +@RunWith(AndroidJUnit4::class) +class OrganizationsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen( + state: OrganizationsUiState, + onOpenOrg: (String) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + OrganizationsScreen( + state = state, + onOpenOrg = onOpenOrg, + onBack = {}, + onLoadMore = {}, + onCreateOrganization = { _, _, _ -> }, + ) + } + } + } + + @Test + fun rendersCards_andOpensOrgOnTap() { + var opened: String? = null + setScreen( + state = OrganizationsUiState( + organizations = listOf( + Organization("1", "Acme Corp", "Makers", null, false, 3, null, null), + Organization("2", "Open", null, null, true, 0, null, null), + ), + isRefreshing = false, + ), + onOpenOrg = { opened = it }, + ) + + composeRule.onNodeWithTag(OrganizationsTestTags.row("1")).assertIsDisplayed() + composeRule.onNodeWithTag(OrganizationsTestTags.row("2")).assertIsDisplayed() + + composeRule.onNodeWithTag(OrganizationsTestTags.row("1")).performClick() + assert(opened == "1") + } + + @Test + fun showsEmptyState_whenNoOrgs() { + setScreen(state = OrganizationsUiState(organizations = emptyList(), isRefreshing = false)) + + composeRule.onNodeWithTag(OrganizationsTestTags.EMPTY).assertIsDisplayed() + } + + @Test + fun showsSubscriptionGate_whenRequired() { + setScreen( + state = OrganizationsUiState( + subscriptionRequired = true, + errorMessage = "Organizations require an active subscription", + ), + ) + + composeRule.onNodeWithTag(OrganizationsTestTags.SUBSCRIPTION).assertIsDisplayed() + } + + @Test + fun openingCreateDialog_showsNameField() { + setScreen(state = OrganizationsUiState(organizations = emptyList(), isRefreshing = false)) + + composeRule.onNodeWithTag(OrganizationsTestTags.CREATE_FAB).performClick() + composeRule.onNodeWithTag(OrganizationsTestTags.CREATE_NAME).assertIsDisplayed() + } +} diff --git a/feature/organizations/src/main/AndroidManifest.xml b/feature/organizations/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/organizations/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt new file mode 100644 index 0000000..88fc0c9 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepository.kt @@ -0,0 +1,198 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.organizations.data.local.OrganizationDao +import com.interlinedlist.android.feature.organizations.data.remote.OrganizationsApi +import com.interlinedlist.android.feature.organizations.data.remote.dto.AddMemberRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.CreateOrganizationRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationsResponse +import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateMemberRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateOrganizationRequest +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.domain.Paged +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import javax.inject.Inject + +/** + * Offline-first [OrganizationsRepository]. The index is served from Room and + * refreshed from the API (Room stays the source of truth); mutations write through + * to the API and then update the cache. Failures are normalised to [ApiResult] via + * [safeApiCall], which maps a subscription 403 to `AppError.SubscriptionRequired`. + */ +class DefaultOrganizationsRepository @Inject constructor( + private val api: OrganizationsApi, + private val dao: OrganizationDao, + private val json: kotlinx.serialization.json.Json, + private val dispatchers: DispatcherProvider, +) : OrganizationsRepository { + + override fun observeOrganizations(): Flow> = + dao.observeOrganizations().map { entities -> entities.map(OrganizationMapper::fromEntity) } + + override suspend fun refreshOrganizations(limit: Int): ApiResult> = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getOrganizations(limit = limit, offset = 0) }) { + is ApiResult.Success -> { + val orgs = result.data.items.map(OrganizationMapper::fromDto) + // First page → replace so server-side deletions are reflected. + dao.replaceAll(orgs.map(OrganizationMapper::toEntity)) + ApiResult.Success(result.data.toPaged(orgs, offset = 0, limit = limit)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun loadMoreOrganizations(offset: Int, limit: Int): ApiResult> = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getOrganizations(limit = limit, offset = offset) }) { + is ApiResult.Success -> { + val orgs = result.data.items.map(OrganizationMapper::fromDto) + dao.upsertAll(orgs.map(OrganizationMapper::toEntity)) + ApiResult.Success(result.data.toPaged(orgs, offset = offset, limit = limit)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun createOrganization( + name: String, + description: String?, + isPublic: Boolean, + ): ApiResult = withContext(dispatchers.io) { + val body = CreateOrganizationRequest( + name = name, + description = description, + isPublic = isPublic.toString(), + ) + when (val result = safeApiCall(json) { api.createOrganization(body) }) { + is ApiResult.Success -> { + val dto = result.data.org + ?: return@withContext ApiResult.Success( + Organization( + id = "", name = name, description = description, avatarUrl = null, + isPublic = isPublic, memberCount = 1, role = OrgRole.OWNER, updatedAt = null, + ), + ) + val org = OrganizationMapper.fromDto(dto) + dao.upsert(OrganizationMapper.toEntity(org)) + ApiResult.Success(org) + } + is ApiResult.Failure -> result + } + } + + override suspend fun getOrganization(id: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getOrganization(id) }) { + is ApiResult.Success -> { + val dto = result.data.org + ?: return@withContext ApiResult.Failure(AppError.NotFound("Organization not found")) + val org = OrganizationMapper.fromDto(dto) + dao.upsert(OrganizationMapper.toEntity(org)) + ApiResult.Success(org) + } + is ApiResult.Failure -> result + } + } + + override suspend fun updateOrganization( + id: String, + name: String?, + description: String?, + isPublic: Boolean?, + ): ApiResult = withContext(dispatchers.io) { + val body = UpdateOrganizationRequest( + name = name?.trim()?.ifBlank { null }, + description = description?.trim(), + isPublic = isPublic?.toString(), + ) + when (val result = safeApiCall(json) { api.updateOrganization(id, body) }) { + is ApiResult.Success -> { + // The API may echo the updated org; if not, re-fetch it for a fresh cache. + val dto = result.data.org + if (dto != null) { + val org = OrganizationMapper.fromDto(dto) + dao.upsert(OrganizationMapper.toEntity(org)) + ApiResult.Success(org) + } else { + getOrganization(id) + } + } + is ApiResult.Failure -> result + } + } + + override suspend fun deleteOrganization(id: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.deleteOrganization(id) }) { + is ApiResult.Success -> { + dao.deleteById(id) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun getMembers(orgId: String, limit: Int): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getMembers(orgId, limit = limit, offset = 0) } + .map { response -> response.items.mapNotNull(MemberMapper::fromDto) } + } + + override suspend fun searchMemberCandidates( + orgId: String, + query: String, + limit: Int, + ): ApiResult> = withContext(dispatchers.io) { + safeApiCall(json) { + api.searchOrgUsers( + id = orgId, + search = query, + excludeMembers = true, + limit = limit, + offset = 0, + ) + }.map { response -> response.items.map(MemberMapper::candidateFromDto) } + } + + override suspend fun addMember(orgId: String, userId: String, role: OrgRole): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { + api.addMember(orgId, AddMemberRequest(userId = userId, role = role.apiValue)) + }.map { } + } + + override suspend fun updateMemberRole(orgId: String, userId: String, role: OrgRole): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { + api.updateMember(orgId, userId, UpdateMemberRequest(role = role.apiValue)) + }.map { } + } + + override suspend fun removeMember(orgId: String, userId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.removeMember(orgId, userId) }.map { } + } +} + +/** Builds a [Paged] from the response's pagination block, tolerating its absence. */ +private fun OrganizationsResponse.toPaged( + items: List, + offset: Int, + limit: Int, +): Paged { + val page = pagination + val nextOffset = offset + items.size + val hasMore = page?.hasMore ?: (page?.let { nextOffset < it.total } ?: (items.size >= limit)) + val total = page?.total ?: nextOffset + return Paged(items = items, hasMore = hasMore, total = total, offset = nextOffset) +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapper.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapper.kt new file mode 100644 index 0000000..6ba5d8e --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapper.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.interlinedlist.android.feature.organizations.data.remote.dto.MemberDto +import com.interlinedlist.android.feature.organizations.data.remote.dto.MemberUserDto +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole + +/** + * DTO → domain mapping for members and candidate users. + * + * A member row reaches the client in two shapes: flattened (`userId`/`username` + * on the row) or with a nested `user` object. This mapper reads the user id, + * username, display name, and avatar from whichever is present, and normalises the + * role via [OrgRole.fromApi], so a member is never dropped for a missing field. + */ +object MemberMapper { + + fun fromDto(dto: MemberDto): OrgMember? { + val userId = dto.userId ?: dto.user?.id ?: dto.id ?: return null + return OrgMember( + userId = userId, + username = dto.username ?: dto.user?.username ?: userId, + displayName = dto.displayName ?: dto.user?.displayName, + avatarUrl = dto.resolvedAvatar, + role = OrgRole.fromApi(dto.role), + // Absent `active` defaults to true: a listed member is treated as active. + active = dto.active ?: true, + ) + } + + fun candidateFromDto(dto: MemberUserDto): MemberCandidate = MemberCandidate( + userId = dto.id, + username = dto.username.ifBlank { dto.id }, + displayName = dto.displayName, + avatarUrl = dto.resolvedAvatar, + ) +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapper.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapper.kt new file mode 100644 index 0000000..5c78a31 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapper.kt @@ -0,0 +1,43 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.interlinedlist.android.feature.organizations.data.local.CachedOrganizationEntity +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationDto +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization + +/** DTO/entity ↔ domain mapping for organizations. */ +object OrganizationMapper { + + fun fromDto(dto: OrganizationDto): Organization = Organization( + id = dto.id, + name = dto.name, + description = dto.description, + avatarUrl = dto.resolvedAvatar, + isPublic = dto.resolvedPublic, + memberCount = dto.resolvedMemberCount, + role = dto.role?.let(OrgRole::fromApi), + updatedAt = dto.updatedAt, + ) + + fun toEntity(org: Organization): CachedOrganizationEntity = CachedOrganizationEntity( + id = org.id, + name = org.name, + description = org.description, + avatarUrl = org.avatarUrl, + isPublic = org.isPublic, + memberCount = org.memberCount, + role = org.role?.apiValue, + updatedAt = org.updatedAt, + ) + + fun fromEntity(entity: CachedOrganizationEntity): Organization = Organization( + id = entity.id, + name = entity.name, + description = entity.description, + avatarUrl = entity.avatarUrl, + isPublic = entity.isPublic, + memberCount = entity.memberCount, + role = entity.role?.let(OrgRole::fromApi), + updatedAt = entity.updatedAt, + ) +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt new file mode 100644 index 0000000..6d54d95 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationsRepository.kt @@ -0,0 +1,71 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.domain.Paged +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to the Organizations domain. The index streams from Room + * (the source of truth) via [observeOrganizations]; refresh/load-more calls update + * the cache and report the pagination state so the UI knows whether more pages + * remain. Detail metadata + member mutations write through to the API. + */ +interface OrganizationsRepository { + + /** Cached organizations, emitted from Room and re-emitted on every local change. */ + fun observeOrganizations(): Flow> + + /** Fetches the first page from the API and replaces the cache. Returns pagination. */ + suspend fun refreshOrganizations(limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Fetches a further page and appends it to the cache. */ + suspend fun loadMoreOrganizations(offset: Int, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Creates an organization; caches the result and returns it. */ + suspend fun createOrganization( + name: String, + description: String?, + isPublic: Boolean, + ): ApiResult + + /** Loads a single organization's metadata, caching it. */ + suspend fun getOrganization(id: String): ApiResult + + /** Updates an organization's name/description/visibility and refreshes the cache. */ + suspend fun updateOrganization( + id: String, + name: String?, + description: String?, + isPublic: Boolean?, + ): ApiResult + + /** Deletes an organization and evicts it from the cache. */ + suspend fun deleteOrganization(id: String): ApiResult + + /** Members of an organization (users granted access), with their roles. */ + suspend fun getMembers(orgId: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult> + + /** Searches users who could be added as members (excludes existing members). */ + suspend fun searchMemberCandidates( + orgId: String, + query: String, + limit: Int = DEFAULT_PAGE_SIZE, + ): ApiResult> + + /** Adds a user as a member with the given role. */ + suspend fun addMember(orgId: String, userId: String, role: OrgRole): ApiResult + + /** Changes an existing member's role. */ + suspend fun updateMemberRole(orgId: String, userId: String, role: OrgRole): ApiResult + + /** Removes a user's membership from the organization. */ + suspend fun removeMember(orgId: String, userId: String): ApiResult + + companion object { + const val DEFAULT_PAGE_SIZE = 20 + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/CachedOrganizationEntity.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/CachedOrganizationEntity.kt new file mode 100644 index 0000000..bccd174 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/CachedOrganizationEntity.kt @@ -0,0 +1,21 @@ +package com.interlinedlist.android.feature.organizations.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * Locally cached organization — the offline-first source of truth for the index. + * Members are not cached here; they are loaded per-org on demand on the detail + * screen. + */ +@Entity(tableName = "cached_organization") +data class CachedOrganizationEntity( + @PrimaryKey val id: String, + val name: String, + val description: String?, + val avatarUrl: String?, + val isPublic: Boolean, + val memberCount: Int, + val role: String?, + val updatedAt: String?, +) diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationDao.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationDao.kt new file mode 100644 index 0000000..4f4880b --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationDao.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.organizations.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +@Dao +interface OrganizationDao { + + /** Emits all cached organizations, name-sorted, re-emitting on every change. */ + @Query("SELECT * FROM cached_organization ORDER BY name COLLATE NOCASE ASC") + fun observeOrganizations(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(orgs: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(org: CachedOrganizationEntity) + + @Query("DELETE FROM cached_organization WHERE id = :id") + suspend fun deleteById(id: String) + + @Query("DELETE FROM cached_organization") + suspend fun clear() + + /** + * Replaces the whole cache with [orgs] in one transaction — used when a full + * first page is fetched so removals on the server are reflected locally. + */ + @Transaction + suspend fun replaceAll(orgs: List) { + clear() + upsertAll(orgs) + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationsDatabase.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationsDatabase.kt new file mode 100644 index 0000000..2c62e1f --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/local/OrganizationsDatabase.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.organizations.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * Room database owned by the Organizations feature module. Kept separate from the + * shared `InterlinedListDatabase` so the feature stays self-contained (the module + * must not touch `:core:*`). + */ +@Database( + entities = [CachedOrganizationEntity::class], + version = 1, + exportSchema = false, +) +abstract class OrganizationsDatabase : RoomDatabase() { + abstract fun organizationDao(): OrganizationDao +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt new file mode 100644 index 0000000..15624af --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/OrganizationsApi.kt @@ -0,0 +1,86 @@ +package com.interlinedlist.android.feature.organizations.data.remote + +import com.interlinedlist.android.feature.organizations.data.remote.dto.AddMemberRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.CreateOrganizationRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.MembersResponse +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrgUsersResponse +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationEnvelope +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationsResponse +import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateMemberRequest +import com.interlinedlist.android.feature.organizations.data.remote.dto.UpdateOrganizationRequest +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the InterlinedList Organizations API used by this + * module. The shared Retrofit singleton supplies the base URL and Bearer auth, so + * these calls are authenticated. + */ +interface OrganizationsApi { + + @GET("api/organizations") + suspend fun getOrganizations( + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): OrganizationsResponse + + /** The current user's org memberships (sync-token authed); mirrors the index shape. */ + @GET("api/user/organizations") + suspend fun getUserOrganizations(): OrganizationsResponse + + @POST("api/organizations") + suspend fun createOrganization(@Body body: CreateOrganizationRequest): OrganizationEnvelope + + @GET("api/organizations/{id}") + suspend fun getOrganization(@Path("id") id: String): OrganizationEnvelope + + @PUT("api/organizations/{id}") + suspend fun updateOrganization( + @Path("id") id: String, + @Body body: UpdateOrganizationRequest, + ): OrganizationEnvelope + + @DELETE("api/organizations/{id}") + suspend fun deleteOrganization(@Path("id") id: String) + + @GET("api/organizations/{id}/members") + suspend fun getMembers( + @Path("id") id: String, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): MembersResponse + + @POST("api/organizations/{id}/members") + suspend fun addMember( + @Path("id") id: String, + @Body body: AddMemberRequest, + ) + + @PUT("api/organizations/{id}/members/{userId}") + suspend fun updateMember( + @Path("id") id: String, + @Path("userId") userId: String, + @Body body: UpdateMemberRequest, + ) + + @DELETE("api/organizations/{id}/members/{userId}") + suspend fun removeMember( + @Path("id") id: String, + @Path("userId") userId: String, + ) + + /** Users who could be added as members (excludes existing members). */ + @GET("api/organizations/{id}/users") + suspend fun searchOrgUsers( + @Path("id") id: String, + @Query("search") search: String, + @Query("excludeMembers") excludeMembers: Boolean, + @Query("limit") limit: Int, + @Query("offset") offset: Int, + ): OrgUsersResponse +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/FlexibleBoolean.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/FlexibleBoolean.kt new file mode 100644 index 0000000..05d8d29 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/FlexibleBoolean.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.organizations.data.remote.dto + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +/** + * Tolerates a boolean that the API may send either as a JSON boolean (`true`) or + * as a string (`"true"` / `"1"`). The OpenAPI extract types several flags as + * strings, but real payloads mix the two, so we normalise both to [Boolean]. + */ +object FlexibleBooleanSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("FlexibleBoolean", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): Boolean? { + val input = decoder as? JsonDecoder ?: return decoder.decodeBoolean() + val element = input.decodeJsonElement() as? JsonPrimitive ?: return null + element.booleanOrNull?.let { return it } + return when (element.content.trim().lowercase()) { + "true", "1", "yes" -> true + "false", "0", "no", "" -> false + else -> null + } + } + + override fun serialize(encoder: Encoder, value: Boolean?) { + encoder.encodeBoolean(value ?: false) + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/MemberDtos.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/MemberDtos.kt new file mode 100644 index 0000000..45df9fd --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/MemberDtos.kt @@ -0,0 +1,71 @@ +package com.interlinedlist.android.feature.organizations.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the organization members endpoints. A member row arrives either + * flattened (`userId`/`username` on the row) or with a nested `user` object — the + * mapper ([com.interlinedlist.android.feature.organizations.data.MemberMapper]) + * tolerates both so a member is never dropped for a missing field. + */ +@Serializable +data class MemberDto( + val id: String? = null, + val userId: String? = null, + val role: String? = null, + val username: String? = null, + val displayName: String? = null, + val avatarUrl: String? = null, + val avatar: String? = null, + @Serializable(with = FlexibleBooleanSerializer::class) + val active: Boolean? = null, + val user: MemberUserDto? = null, +) { + val resolvedAvatar: String? get() = avatarUrl ?: avatar ?: user?.resolvedAvatar +} + +/** A user reference nested on a member row or returned by the org user search. */ +@Serializable +data class MemberUserDto( + val id: String, + val username: String = "", + val displayName: String? = null, + val avatarUrl: String? = null, + val avatar: String? = null, +) { + val resolvedAvatar: String? get() = avatarUrl ?: avatar +} + +/** Envelope for `GET /api/organizations/{id}/members`; members may be wrapped or bare. */ +@Serializable +data class MembersResponse( + val data: List? = null, + val members: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: members ?: emptyList() +} + +/** Envelope for `GET /api/organizations/{id}/users` (candidate users to add). */ +@Serializable +data class OrgUsersResponse( + val data: List? = null, + val users: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: users ?: emptyList() +} + +/** Body for `POST /api/organizations/{id}/members` — add one user with a role. */ +@Serializable +data class AddMemberRequest( + val userId: String, + val role: String? = null, +) + +/** Body for `PUT /api/organizations/{id}/members/{userId}` — change role/active. */ +@Serializable +data class UpdateMemberRequest( + val role: String? = null, + val active: String? = null, +) diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrganizationDtos.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrganizationDtos.kt new file mode 100644 index 0000000..20ec90e --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/data/remote/dto/OrganizationDtos.kt @@ -0,0 +1,94 @@ +package com.interlinedlist.android.feature.organizations.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the Organizations API. Field names follow the InterlinedList + * REST contract; the shared [kotlinx.serialization.json.Json] is configured with + * `ignoreUnknownKeys`, so extra server fields are tolerated and only the fields + * the UI renders need declaring. + * + * The OpenAPI extract does not pin the response bodies, so these are modelled + * defensively: booleans arrive as either JSON booleans or strings (see + * [FlexibleBoolean]), counts arrive under several names, and member rows arrive + * flattened or nested — the mappers reconcile the variants. + */ + +/** An organization envelope as returned by index and detail endpoints. */ +@Serializable +data class OrganizationDto( + val id: String, + val name: String = "", + val description: String? = null, + val avatar: String? = null, + val avatarUrl: String? = null, + @Serializable(with = FlexibleBooleanSerializer::class) + val isPublic: Boolean? = null, + @Serializable(with = FlexibleBooleanSerializer::class) + val public: Boolean? = null, + // The API reports the member count under a few different names. + val memberCount: Int? = null, + val membersCount: Int? = null, + val members: Int? = null, + // The current user's role in this org, when the endpoint includes it. + val role: String? = null, + val updatedAt: String? = null, +) { + /** The avatar URL under whichever field name the API used. */ + val resolvedAvatar: String? get() = avatarUrl ?: avatar + /** Public flag under either field name; unknown means private. */ + val resolvedPublic: Boolean get() = isPublic ?: public ?: false + /** Member count under whichever name the API used, defaulting to zero. */ + val resolvedMemberCount: Int get() = memberCount ?: membersCount ?: members ?: 0 +} + +/** Pagination block shared by list endpoints. */ +@Serializable +data class PaginationDto( + val total: Int = 0, + val limit: Int = 0, + val offset: Int = 0, + @Serializable(with = FlexibleBooleanSerializer::class) + val hasMore: Boolean? = null, +) + +/** + * Envelope for `GET /api/organizations` and `GET /api/user/organizations`. + * The payload may be `{ data: [...], pagination: {...} }` or bare under + * `organizations`; both list keys are accepted. + */ +@Serializable +data class OrganizationsResponse( + val data: List? = null, + val organizations: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: organizations ?: emptyList() +} + +/** Envelope for `GET /api/organizations/{id}` and mutations; org may be wrapped or bare. */ +@Serializable +data class OrganizationEnvelope( + val organization: OrganizationDto? = null, + val data: OrganizationDto? = null, +) { + val org: OrganizationDto? get() = organization ?: data +} + +/** Body for `POST /api/organizations`. `isPublic` is serialised as a string per the API. */ +@Serializable +data class CreateOrganizationRequest( + val name: String, + val description: String? = null, + val avatar: String? = null, + val isPublic: String? = null, +) + +/** Body for `PUT /api/organizations/{id}` — partial metadata updates. */ +@Serializable +data class UpdateOrganizationRequest( + val name: String? = null, + val description: String? = null, + val avatar: String? = null, + val isPublic: String? = null, +) diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/di/OrganizationsModule.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/di/OrganizationsModule.kt new file mode 100644 index 0000000..550e80f --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/di/OrganizationsModule.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.organizations.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.feature.organizations.data.DefaultOrganizationsRepository +import com.interlinedlist.android.feature.organizations.data.OrganizationsRepository +import com.interlinedlist.android.feature.organizations.data.local.OrganizationDao +import com.interlinedlist.android.feature.organizations.data.local.OrganizationsDatabase +import com.interlinedlist.android.feature.organizations.data.remote.OrganizationsApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class OrganizationsRepositoryModule { + + @Binds + @Singleton + abstract fun bindOrganizationsRepository(impl: DefaultOrganizationsRepository): OrganizationsRepository +} + +/** Provides this module's Retrofit API and its own Room database + DAO. */ +@Module +@InstallIn(SingletonComponent::class) +object OrganizationsDataModule { + + @Provides + @Singleton + fun provideOrganizationsApi(retrofit: Retrofit): OrganizationsApi = + retrofit.create(OrganizationsApi::class.java) + + @Provides + @Singleton + fun provideOrganizationsDatabase(@ApplicationContext context: Context): OrganizationsDatabase = + Room.databaseBuilder( + context, + OrganizationsDatabase::class.java, + "interlinedlist-organizations.db", + ) + // Disposable cache during early development; the cache is re-fetched. + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideOrganizationDao(db: OrganizationsDatabase): OrganizationDao = db.organizationDao() +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgMember.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgMember.kt new file mode 100644 index 0000000..9995cf8 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/OrgMember.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.organizations.domain + +/** + * A member of an organization, together with the role granted to them. Roles gate + * what a member can do; [OrgRole.MEMBER] is the default least-privileged grant. + * A member row reaches the client either flattened (`userId`/`username` on the + * row) or with a nested `user` object — the mapper tolerates both. + */ +data class OrgMember( + val userId: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, + val role: OrgRole, + /** Whether the membership is active; inactive members are still listed. */ + val active: Boolean, +) { + /** Best label for the row: display name when present, else the username. */ + val label: String get() = displayName?.takeIf { it.isNotBlank() } ?: username +} + +/** A user candidate returned by the org user search (not yet a member). */ +data class MemberCandidate( + val userId: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) { + val label: String get() = displayName?.takeIf { it.isNotBlank() } ?: username +} + +/** + * Access level a member holds in an organization. Unknown/absent roles map to + * [MEMBER] so a member is never dropped and defaults to the least-privileged + * grant. Ordered least- to most-privileged. + */ +enum class OrgRole(val apiValue: String) { + MEMBER("member"), + ADMIN("admin"), + OWNER("owner"); + + /** Human label for chips/menus, e.g. "Member". */ + val label: String get() = name.lowercase().replaceFirstChar { it.uppercase() } + + companion object { + /** Maps an API role string (case-insensitive) to an [OrgRole], defaulting to [MEMBER]. */ + fun fromApi(raw: String?): OrgRole = when (raw?.trim()?.lowercase()) { + "owner" -> OWNER + "admin" -> ADMIN + else -> MEMBER + } + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/Organization.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/Organization.kt new file mode 100644 index 0000000..1416a00 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/domain/Organization.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.organizations.domain + +/** + * An organization as shown in the index and detail screens. Enough to render a + * card, open the detail, and (in detail) edit name/description/visibility. The + * member list is loaded separately on demand, so this stays lightweight for the + * index. + */ +data class Organization( + val id: String, + val name: String, + val description: String?, + val avatarUrl: String?, + val isPublic: Boolean, + val memberCount: Int, + /** The current user's role in this org, when the API reports it. */ + val role: OrgRole?, + val updatedAt: String?, +) { + /** Best label for a card: the name, falling back to a placeholder. */ + val displayName: String get() = name.ifBlank { "Untitled organization" } +} + +/** + * A page of results plus whether more remain, so the UI can offer load-more + * without knowing the wire pagination shape. + */ +data class Paged( + val items: List, + val hasMore: Boolean, + val total: Int, + val offset: Int, +) diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt new file mode 100644 index 0000000..7f09967 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/OrganizationsErrorMessages.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.organizations.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the Orgs UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Showing what's saved on this device." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.NotFound -> message ?: "That organization could not be found." + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.SubscriptionRequired -> message ?: "Organizations require an active subscription." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} + +/** True when the error is the subscriber-only gate, so the UI can show an upsell. */ +val AppError.isSubscriptionGate: Boolean get() = this is AppError.SubscriptionRequired diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt new file mode 100644 index 0000000..0e058d6 --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailScreen.kt @@ -0,0 +1,485 @@ +package com.interlinedlist.android.feature.organizations.ui.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization + +/** Stable test tags for the organization detail screen. */ +object OrganizationDetailTestTags { + const val LIST = "orgDetailMembers" + const val SEARCH = "orgDetailMemberSearch" + const val EMPTY = "orgDetailEmpty" + const val PROGRESS = "orgDetailProgress" + const val ERROR = "orgDetailError" + const val SUBSCRIPTION = "orgDetailSubscription" + const val OVERFLOW = "orgDetailOverflow" + const val EDIT = "orgDetailEdit" + const val DELETE = "orgDetailDelete" + const val EDIT_DIALOG = "orgDetailEditDialog" + const val DELETE_DIALOG = "orgDetailDeleteDialog" + const val DELETE_CONFIRM = "orgDetailDeleteConfirm" + fun member(userId: String) = "orgMember_$userId" + fun remove(userId: String) = "orgMemberRemove_$userId" + fun candidate(userId: String) = "orgCandidate_$userId" +} + +/** + * Hilt-wired entry for a single organization. Reads its `orgId` from the nav + * SavedStateHandle (see [ORG_ID_ARG]); [onBack] and [onDeleted] let the app pop + * navigation after viewing or deleting the org. + */ +@Composable +fun OrganizationDetailRoute( + onBack: () -> Unit, + onDeleted: () -> Unit, + modifier: Modifier = Modifier, + viewModel: OrganizationDetailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + OrganizationDetailScreen( + state = state, + onBack = onBack, + onSearchQueryChange = viewModel::onSearchQueryChange, + onAddCandidate = { viewModel.addMember(it) }, + onChangeRole = viewModel::changeRole, + onRemoveMember = viewModel::removeMember, + onSaveEdit = { name, description, isPublic -> viewModel.updateOrganization(name, description, isPublic) }, + onDelete = { viewModel.deleteOrganization(onDeleted) }, + modifier = modifier, + ) +} + +/** Stateless organization detail — metadata header + members management. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrganizationDetailScreen( + state: OrganizationDetailUiState, + onBack: () -> Unit, + onSearchQueryChange: (String) -> Unit, + onAddCandidate: (MemberCandidate) -> Unit, + onChangeRole: (OrgMember, OrgRole) -> Unit, + onRemoveMember: (OrgMember) -> Unit, + onSaveEdit: (name: String?, description: String?, isPublic: Boolean?) -> Unit, + onDelete: () -> Unit, + modifier: Modifier = Modifier, +) { + var menuOpen by remember { mutableStateOf(false) } + var showEdit by remember { mutableStateOf(false) } + var showDeleteConfirm by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(state.title.ifBlank { "Organization" }, maxLines = 1, overflow = TextOverflow.Ellipsis) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + actions = { + IconButton( + onClick = { menuOpen = true }, + modifier = Modifier.testTag(OrganizationDetailTestTags.OVERFLOW), + ) { Icon(Icons.Default.MoreVert, contentDescription = "More actions") } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Edit") }, + leadingIcon = { Icon(Icons.Default.Edit, contentDescription = null) }, + onClick = { menuOpen = false; showEdit = true }, + modifier = Modifier.testTag(OrganizationDetailTestTags.EDIT), + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { Icon(Icons.Default.Delete, contentDescription = null) }, + onClick = { menuOpen = false; showDeleteConfirm = true }, + modifier = Modifier.testTag(OrganizationDetailTestTags.DELETE), + ) + } + }, + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> Centered( + Modifier.padding(padding).testTag(OrganizationDetailTestTags.SUBSCRIPTION), + ) { + Text("Subscribers only", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text(state.errorMessage ?: "Organizations require an active subscription.") + } + + state.isLoading -> Centered(Modifier.padding(padding)) { + CircularProgressIndicator(Modifier.testTag(OrganizationDetailTestTags.PROGRESS)) + } + + state.organization == null && state.errorMessage != null -> Centered( + Modifier.padding(padding).testTag(OrganizationDetailTestTags.ERROR), + ) { Text(state.errorMessage) } + + else -> Column(Modifier.padding(padding)) { + state.organization?.let { org -> OrganizationHeader(org) } + + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag(OrganizationDetailTestTags.ERROR), + ) + } + + OutlinedTextField( + value = state.searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("Add a member") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(OrganizationDetailTestTags.SEARCH), + ) + + MemberList( + members = state.members, + candidates = state.candidates, + isEmpty = state.isEmpty, + onAddCandidate = onAddCandidate, + onChangeRole = onChangeRole, + onRemoveMember = onRemoveMember, + ) + } + } + } + + if (showEdit && state.organization != null) { + EditOrganizationDialog( + organization = state.organization, + onDismiss = { showEdit = false }, + onConfirm = { name, description, isPublic -> + showEdit = false + onSaveEdit(name, description, isPublic) + }, + ) + } + + if (showDeleteConfirm) { + AlertDialog( + onDismissRequest = { showDeleteConfirm = false }, + modifier = Modifier.testTag(OrganizationDetailTestTags.DELETE_DIALOG), + title = { Text("Delete organization?") }, + text = { Text("This permanently removes \"${state.title}\" and its membership. This cannot be undone.") }, + confirmButton = { + TextButton( + onClick = { showDeleteConfirm = false; onDelete() }, + modifier = Modifier.testTag(OrganizationDetailTestTags.DELETE_CONFIRM), + ) { Text("Delete") } + }, + dismissButton = { + TextButton(onClick = { showDeleteConfirm = false }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun OrganizationHeader(org: Organization) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + if (!org.description.isNullOrBlank()) { + Text( + text = org.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "${org.memberCount} ${if (org.memberCount == 1) "member" else "members"}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = if (org.isPublic) "Public" else "Private", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + } +} + +@Composable +private fun MemberList( + members: List, + candidates: List, + isEmpty: Boolean, + onAddCandidate: (MemberCandidate) -> Unit, + onChangeRole: (OrgMember, OrgRole) -> Unit, + onRemoveMember: (OrgMember) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(OrganizationDetailTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (candidates.isNotEmpty()) { + item { Text("Suggestions", style = MaterialTheme.typography.labelLarge) } + items(candidates, key = { "candidate-${it.userId}" }) { candidate -> + CandidateRow(candidate = candidate, onAdd = { onAddCandidate(candidate) }) + } + } + + if (isEmpty && candidates.isEmpty()) { + item { EmptyState() } + } else { + items(members, key = { it.userId }) { member -> + MemberRow( + member = member, + onChangeRole = { onChangeRole(member, it) }, + onRemove = { onRemoveMember(member) }, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MemberRow( + member: OrgMember, + onChangeRole: (OrgRole) -> Unit, + onRemove: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(OrganizationDetailTestTags.member(member.userId)), + ) { + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = member.label, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${member.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.testTag(OrganizationDetailTestTags.remove(member.userId)), + ) { Icon(Icons.Default.Close, contentDescription = "Remove member") } + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OrgRole.entries.forEach { role -> + FilterChip( + selected = member.role == role, + onClick = { onChangeRole(role) }, + label = { Text(role.label) }, + ) + } + } + } + } +} + +@Composable +private fun CandidateRow(candidate: MemberCandidate, onAdd: () -> Unit) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(OrganizationDetailTestTags.candidate(candidate.userId)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(candidate.label, style = MaterialTheme.typography.titleMedium) + Text( + text = "@${candidate.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip( + onClick = onAdd, + label = { Text("Add") }, + leadingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + ) + } + } +} + +@Composable +private fun EditOrganizationDialog( + organization: Organization, + onDismiss: () -> Unit, + onConfirm: (name: String?, description: String?, isPublic: Boolean?) -> Unit, +) { + var name by remember { mutableStateOf(organization.name) } + var description by remember { mutableStateOf(organization.description.orEmpty()) } + var isPublic by remember { mutableStateOf(organization.isPublic) } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.testTag(OrganizationDetailTestTags.EDIT_DIALOG)) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Edit organization", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Public", style = MaterialTheme.typography.bodyLarge) + Switch(checked = isPublic, onCheckedChange = { isPublic = it }) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Button( + onClick = { onConfirm(name.trim(), description, isPublic) }, + enabled = name.isNotBlank(), + ) { Text("Save") } + } + } + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 48.dp) + .testTag(OrganizationDetailTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No members yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Search above to add someone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun Centered(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, content = { content() }) + } +} + +@Preview(showBackground = true) +@Composable +private fun OrganizationDetailScreenPreview() { + InterlinedListTheme { + OrganizationDetailScreen( + state = OrganizationDetailUiState( + organization = Organization("1", "Acme Corp", "We make everything", null, false, 2, OrgRole.OWNER, null), + members = listOf( + OrgMember("u1", "ada", "Ada Lovelace", null, OrgRole.OWNER, active = true), + OrgMember("u2", "grace", null, null, OrgRole.MEMBER, active = true), + ), + isLoading = false, + ), + onBack = {}, + onSearchQueryChange = {}, + onAddCandidate = {}, + onChangeRole = { _, _ -> }, + onRemoveMember = {}, + onSaveEdit = { _, _, _ -> }, + onDelete = {}, + ) + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt new file mode 100644 index 0000000..30078ad --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModel.kt @@ -0,0 +1,185 @@ +package com.interlinedlist.android.feature.organizations.ui.detail + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.organizations.data.OrganizationsRepository +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.ui.isSubscriptionGate +import com.interlinedlist.android.feature.organizations.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the detail route reads its org id from. */ +const val ORG_ID_ARG = "orgId" + +/** UI state for the organization detail screen (metadata + members management). */ +data class OrganizationDetailUiState( + val organization: Organization? = null, + val members: List = emptyList(), + val isLoading: Boolean = true, + val isSaving: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, + val deleted: Boolean = false, + // Member search / add. + val searchQuery: String = "", + val candidates: List = emptyList(), + val isSearching: Boolean = false, +) { + val title: String get() = organization?.displayName.orEmpty() + val isEmpty: Boolean get() = members.isEmpty() && !isLoading && errorMessage == null +} + +@HiltViewModel +class OrganizationDetailViewModel @Inject constructor( + private val repository: OrganizationsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val orgId: String = requireNotNull(savedStateHandle[ORG_ID_ARG]) { + "OrganizationDetailViewModel requires an '$ORG_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(OrganizationDetailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.getOrganization(orgId)) { + is ApiResult.Success -> _uiState.update { it.copy(organization = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy( + isLoading = false, + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + // Members are loaded after metadata; a failure surfaces but keeps the header. + when (val members = repository.getMembers(orgId)) { + is ApiResult.Success -> _uiState.update { it.copy(members = members.data) } + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = it.errorMessage ?: members.error.toUserMessage()) + } + } + } + } + + fun updateOrganization( + name: String?, + description: String?, + isPublic: Boolean?, + onDone: () -> Unit = {}, + ) { + _uiState.update { it.copy(isSaving = true) } + viewModelScope.launch { + when (val result = repository.updateOrganization(orgId, name, description, isPublic)) { + is ApiResult.Success -> { + _uiState.update { it.copy(isSaving = false, organization = result.data) } + onDone() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun deleteOrganization(onDeleted: () -> Unit = {}) { + viewModelScope.launch { + when (val result = repository.deleteOrganization(orgId)) { + is ApiResult.Success -> { + _uiState.update { it.copy(deleted = true) } + onDeleted() + } + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun onSearchQueryChange(query: String) { + _uiState.update { it.copy(searchQuery = query) } + if (query.isBlank()) { + _uiState.update { it.copy(candidates = emptyList(), isSearching = false) } + return + } + _uiState.update { it.copy(isSearching = true) } + viewModelScope.launch { + when (val result = repository.searchMemberCandidates(orgId, query.trim())) { + is ApiResult.Success -> _uiState.update { it.copy(candidates = result.data, isSearching = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(candidates = emptyList(), isSearching = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun addMember(candidate: MemberCandidate, role: OrgRole = OrgRole.MEMBER) { + viewModelScope.launch { + when (val result = repository.addMember(orgId, candidate.userId, role)) { + is ApiResult.Success -> { + // Clear the search and reload so the new member's server-side role shows. + _uiState.update { it.copy(searchQuery = "", candidates = emptyList()) } + reloadMembers() + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun changeRole(member: OrgMember, role: OrgRole) { + if (member.role == role) return + viewModelScope.launch { + when (val result = repository.updateMemberRole(orgId, member.userId, role)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy( + members = state.members.map { + if (it.userId == member.userId) it.copy(role = role) else it + }, + ) + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + fun removeMember(member: OrgMember) { + viewModelScope.launch { + when (val result = repository.removeMember(orgId, member.userId)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(members = state.members.filterNot { it.userId == member.userId }) + } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + /** Reloads only the member list without toggling the header loading spinner. */ + private fun reloadMembers() { + viewModelScope.launch { + when (val members = repository.getMembers(orgId)) { + is ApiResult.Success -> _uiState.update { it.copy(members = members.data) } + is ApiResult.Failure -> Unit // Keep the existing list; the add already succeeded. + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreen.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreen.kt new file mode 100644 index 0000000..755356d --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsScreen.kt @@ -0,0 +1,378 @@ +package com.interlinedlist.android.feature.organizations.ui.list + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.organizations.domain.Organization + +/** Stable test tags for the organizations index. */ +object OrganizationsTestTags { + const val LIST = "organizationsIndex" + const val CREATE_FAB = "organizationsCreateFab" + const val ERROR = "organizationsError" + const val EMPTY = "organizationsEmpty" + const val PROGRESS = "organizationsProgress" + const val SUBSCRIPTION = "organizationsSubscription" + const val CREATE_DIALOG = "organizationsCreateDialog" + const val CREATE_NAME = "organizationsCreateName" + const val CREATE_CONFIRM = "organizationsCreateConfirm" + fun row(id: String) = "organizationRow_$id" +} + +/** + * Hilt-wired entry point for the Organizations index (reached from the Account + * hub). [onOpenOrg] receives the tapped org's id so the app can push the detail + * route; [onBack] pops back to the hub. + */ +@Composable +fun OrganizationsRoute( + onOpenOrg: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: OrganizationsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + OrganizationsScreen( + state = state, + onOpenOrg = onOpenOrg, + onBack = onBack, + onLoadMore = viewModel::loadMore, + onCreateOrganization = { name, description, isPublic -> + viewModel.createOrganization(name, description, isPublic, onCreated = { onOpenOrg(it.id) }) + }, + modifier = modifier, + ) +} + +/** Stateless organizations index — loading / empty / error / subscription / content states. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OrganizationsScreen( + state: OrganizationsUiState, + onOpenOrg: (String) -> Unit, + onBack: () -> Unit, + onLoadMore: () -> Unit, + onCreateOrganization: (name: String, description: String?, isPublic: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + var showCreate by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Organizations") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + floatingActionButton = { + if (!state.subscriptionRequired) { + ExtendedFloatingActionButton( + onClick = { showCreate = true }, + icon = { Icon(Icons.Default.Add, contentDescription = null) }, + text = { Text("New") }, + modifier = Modifier.testTag(OrganizationsTestTags.CREATE_FAB), + ) + } + }, + ) { padding -> + when { + state.subscriptionRequired -> SubscriptionGate( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + + state.organizations.isEmpty() && state.isRefreshing -> Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(OrganizationsTestTags.PROGRESS)) + } + + else -> Column(Modifier.padding(padding)) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(OrganizationsTestTags.ERROR), + ) + } + + if (state.isEmpty) { + EmptyState() + } else { + OrganizationsList( + organizations = state.organizations, + isLoadingMore = state.isLoadingMore, + hasMore = state.hasMore, + onOpenOrg = onOpenOrg, + onLoadMore = onLoadMore, + ) + } + } + } + } + + if (showCreate) { + CreateOrganizationDialog( + onDismiss = { showCreate = false }, + onConfirm = { name, description, isPublic -> + showCreate = false + onCreateOrganization(name, description, isPublic) + }, + ) + } +} + +@Composable +private fun OrganizationsList( + organizations: List, + isLoadingMore: Boolean, + hasMore: Boolean, + onOpenOrg: (String) -> Unit, + onLoadMore: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(OrganizationsTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(organizations, key = { it.id }) { org -> + OrganizationCard(org = org, onClick = { onOpenOrg(org.id) }) + } + if (hasMore) { + item { + // Trigger load-more when the sentinel scrolls into view. + LaunchedLoadMore(onLoadMore) + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + if (isLoadingMore) CircularProgressIndicator(Modifier.height(24.dp)) + } + } + } + } +} + +@Composable +private fun LaunchedLoadMore(onLoadMore: () -> Unit) { + LaunchedEffect(Unit) { onLoadMore() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OrganizationCard(org: Organization, onClick: () -> Unit) { + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .testTag(OrganizationsTestTags.row(org.id)), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = org.displayName, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!org.description.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = org.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "${org.memberCount} ${if (org.memberCount == 1) "member" else "members"}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + if (org.isPublic) { + Text( + text = "Public", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + org.role?.let { role -> + Text( + text = role.label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + } +} + +@Composable +private fun CreateOrganizationDialog( + onDismiss: () -> Unit, + onConfirm: (name: String, description: String?, isPublic: Boolean) -> Unit, +) { + var name by remember { mutableStateOf("") } + var description by remember { mutableStateOf("") } + var isPublic by remember { mutableStateOf(false) } + + Dialog(onDismissRequest = onDismiss) { + Card(modifier = Modifier.testTag(OrganizationsTestTags.CREATE_DIALOG)) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("New organization", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(OrganizationsTestTags.CREATE_NAME), + ) + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description (optional)") }, + modifier = Modifier.fillMaxWidth(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("Public", style = MaterialTheme.typography.bodyLarge) + Switch(checked = isPublic, onCheckedChange = { isPublic = it }) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { Text("Cancel") } + Button( + onClick = { onConfirm(name.trim(), description.ifBlank { null }, isPublic) }, + enabled = name.isNotBlank(), + modifier = Modifier.testTag(OrganizationsTestTags.CREATE_CONFIRM), + ) { Text("Create") } + } + } + } + } +} + +@Composable +private fun EmptyState() { + Box( + modifier = Modifier + .fillMaxSize() + .testTag(OrganizationsTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No organizations yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Tap New to create your first one.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun SubscriptionGate(message: String?, modifier: Modifier = Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .testTag(OrganizationsTestTags.SUBSCRIPTION), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp), + ) { + Text("Subscribers only", style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Organizations require an active subscription.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun OrganizationsScreenPreview() { + InterlinedListTheme { + OrganizationsScreen( + state = OrganizationsUiState( + organizations = listOf( + Organization("1", "Acme Corp", "We make everything", null, false, 12, null, null), + Organization("2", "Open Collective", null, null, true, 4, null, null), + ), + ), + onOpenOrg = {}, + onBack = {}, + onLoadMore = {}, + onCreateOrganization = { _, _, _ -> }, + ) + } +} diff --git a/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModel.kt b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModel.kt new file mode 100644 index 0000000..205bc2e --- /dev/null +++ b/feature/organizations/src/main/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModel.kt @@ -0,0 +1,134 @@ +package com.interlinedlist.android.feature.organizations.ui.list + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.organizations.data.OrganizationsRepository +import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.ui.isSubscriptionGate +import com.interlinedlist.android.feature.organizations.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the organizations index. */ +data class OrganizationsUiState( + val organizations: List = emptyList(), + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val hasMore: Boolean = false, + val nextOffset: Int = 0, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, +) { + val isEmpty: Boolean + get() = organizations.isEmpty() && !isRefreshing && errorMessage == null && !subscriptionRequired +} + +@HiltViewModel +class OrganizationsViewModel @Inject constructor( + private val repository: OrganizationsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(OrganizationsUiState()) + + /** + * Combines the persisted Room stream (source of truth) with transient UI flags + * so the index stays live as the cache changes while refresh/error state layers + * on top. + */ + val uiState: StateFlow = combine( + repository.observeOrganizations(), + _uiState, + ) { cached, transient -> + transient.copy(organizations = cached) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = _uiState.value, + ) + + /** Exposed for tests that assert only the transient flags. */ + val transientState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isRefreshing = true, errorMessage = null, subscriptionRequired = false) } + viewModelScope.launch { + when (val result = repository.refreshOrganizations()) { + is ApiResult.Success -> _uiState.update { + it.copy( + isRefreshing = false, + hasMore = result.data.hasMore, + nextOffset = result.data.offset, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isRefreshing = false, + // Cache still renders via the Room stream; surface the reason. + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun loadMore() { + val current = _uiState.value + if (current.isLoadingMore || !current.hasMore) return + _uiState.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + when (val result = repository.loadMoreOrganizations(offset = current.nextOffset)) { + is ApiResult.Success -> _uiState.update { + it.copy( + isLoadingMore = false, + hasMore = result.data.hasMore, + nextOffset = result.data.offset, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingMore = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun createOrganization( + name: String, + description: String?, + isPublic: Boolean = false, + onCreated: (Organization) -> Unit = {}, + ) { + if (name.isBlank()) return + viewModelScope.launch { + val result = repository.createOrganization( + name = name.trim(), + description = description?.trim()?.ifBlank { null }, + isPublic = isPublic, + ) + when (result) { + is ApiResult.Success -> onCreated(result.data) + is ApiResult.Failure -> _uiState.update { + it.copy( + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt new file mode 100644 index 0000000..9be7885 --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/FakeOrganizationsRepository.kt @@ -0,0 +1,115 @@ +package com.interlinedlist.android.feature.organizations + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.organizations.data.OrganizationsRepository +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.domain.Paged +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * In-memory [OrganizationsRepository] for ViewModel tests. The cache is a + * StateFlow so tests can assert the offline-first stream, and each operation's + * result is configurable to exercise success / failure / subscription-gate paths. + */ +class FakeOrganizationsRepository : OrganizationsRepository { + + val cache = MutableStateFlow>(emptyList()) + + var refreshResult: ApiResult> = + ApiResult.Success(Paged(emptyList(), hasMore = false, total = 0, offset = 0)) + var loadMoreResult: ApiResult> = refreshResult + var createResult: ApiResult? = null + var getResult: ApiResult? = null + var updateResult: ApiResult? = null + var deleteResult: ApiResult = ApiResult.Success(Unit) + var membersResult: ApiResult> = ApiResult.Success(emptyList()) + var candidatesResult: ApiResult> = ApiResult.Success(emptyList()) + var addMemberResult: ApiResult = ApiResult.Success(Unit) + var updateRoleResult: ApiResult = ApiResult.Success(Unit) + var removeMemberResult: ApiResult = ApiResult.Success(Unit) + + var refreshCount = 0 + var loadMoreCount = 0 + var addMemberCount = 0 + var removeMemberCount = 0 + var lastMemberSearch: String? = null + var lastUpdate: Triple? = null + + override fun observeOrganizations(): Flow> = cache + + override suspend fun refreshOrganizations(limit: Int): ApiResult> { + refreshCount++ + (refreshResult as? ApiResult.Success)?.let { cache.value = it.data.items } + return refreshResult + } + + override suspend fun loadMoreOrganizations(offset: Int, limit: Int): ApiResult> { + loadMoreCount++ + (loadMoreResult as? ApiResult.Success)?.let { cache.value = cache.value + it.data.items } + return loadMoreResult + } + + override suspend fun createOrganization( + name: String, + description: String?, + isPublic: Boolean, + ): ApiResult = createResult ?: ApiResult.Success( + Organization("new", name, description, null, isPublic, 1, OrgRole.OWNER, null), + ) + + override suspend fun getOrganization(id: String): ApiResult = + getResult ?: ApiResult.Success( + Organization(id, "Org $id", null, null, false, 0, OrgRole.MEMBER, null), + ) + + override suspend fun updateOrganization( + id: String, + name: String?, + description: String?, + isPublic: Boolean?, + ): ApiResult { + lastUpdate = Triple(name, description, isPublic) + return updateResult ?: ApiResult.Success( + Organization(id, name ?: "Org $id", description, null, isPublic ?: false, 0, OrgRole.OWNER, null), + ) + } + + override suspend fun deleteOrganization(id: String): ApiResult { + if (deleteResult is ApiResult.Success) cache.value = cache.value.filterNot { it.id == id } + return deleteResult + } + + override suspend fun getMembers(orgId: String, limit: Int): ApiResult> = membersResult + + override suspend fun searchMemberCandidates( + orgId: String, + query: String, + limit: Int, + ): ApiResult> { + lastMemberSearch = query + return candidatesResult + } + + override suspend fun addMember(orgId: String, userId: String, role: OrgRole): ApiResult { + addMemberCount++ + return addMemberResult + } + + override suspend fun updateMemberRole(orgId: String, userId: String, role: OrgRole): ApiResult = + updateRoleResult + + override suspend fun removeMember(orgId: String, userId: String): ApiResult { + removeMemberCount++ + return removeMemberResult + } + + companion object { + fun subscriptionFailure(): ApiResult.Failure = + ApiResult.Failure(AppError.SubscriptionRequired("Organizations require an active subscription")) + } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt new file mode 100644 index 0000000..0558266 --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/DefaultOrganizationsRepositoryTest.kt @@ -0,0 +1,266 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.organizations.data.local.CachedOrganizationEntity +import com.interlinedlist.android.feature.organizations.data.local.OrganizationDao +import com.interlinedlist.android.feature.organizations.data.remote.OrganizationsApi +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Repository behaviour against a real HTTP stack (Retrofit + OkHttp) driven by + * MockWebServer, with an in-memory DAO standing in for Room. Verifies DTO→domain + * mapping, offline-first caching, error normalisation, and the subscription gate + * across the list/create/detail/update/delete/members endpoints. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultOrganizationsRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: OrganizationsApi + private lateinit var dao: FakeOrganizationDao + private lateinit var repository: DefaultOrganizationsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(OrganizationsApi::class.java) + dao = FakeOrganizationDao() + repository = DefaultOrganizationsRepository(api, dao, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `refreshOrganizations maps DTOs and replaces the Room cache`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "data": [ + { "id": "1", "name": "Acme", "description": "Makers", "memberCount": 3 }, + { "id": "2", "name": "Open", "membersCount": 5, "isPublic": "true" } + ], + "pagination": { "total": 2, "limit": 20, "offset": 0, "hasMore": false } + } + """.trimIndent(), + ), + ) + + val result = repository.refreshOrganizations() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val page = (result as ApiResult.Success).data + assertThat(page.items.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(page.items[0].memberCount).isEqualTo(3) + assertThat(page.items[1].memberCount).isEqualTo(5) + // "isPublic" arrived as the string "true" and was normalised to a boolean. + assertThat(page.items[1].isPublic).isTrue() + assertThat(page.hasMore).isFalse() + // Room is the source of truth: the cache now streams the same two orgs. + assertThat(dao.observeOrganizations().first().map { it.id }).containsExactly("1", "2") + } + + @Test + fun `refreshOrganizations maps a subscription 403 to SubscriptionRequired`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription" }"""), + ) + + val result = repository.refreshOrganizations() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + @Test + fun `createOrganization posts name and caches the created org`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "organization": { "id": "o9", "name": "Newco", "isPublic": true, "memberCount": 1 } }"""), + ) + + val result = repository.createOrganization("Newco", description = "hi", isPublic = true) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val org = (result as ApiResult.Success).data + assertThat(org.id).isEqualTo("o9") + assertThat(org.isPublic).isTrue() + // Cached on create so the index shows it immediately. + assertThat(dao.observeOrganizations().first().map { it.id }).containsExactly("o9") + + val request: RecordedRequest = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/organizations") + val body = request.body.readUtf8() + assertThat(body).contains("\"name\":\"Newco\"") + // isPublic is serialised as a string per the API contract. + assertThat(body).contains("\"isPublic\":\"true\"") + } + + @Test + fun `getOrganization maps the bare data envelope and caches it`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "data": { "id": "o1", "name": "Acme", "role": "owner" } }"""), + ) + + val result = repository.getOrganization("o1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val org = (result as ApiResult.Success).data + assertThat(org.name).isEqualTo("Acme") + assertThat(org.role).isEqualTo(OrgRole.OWNER) + assertThat(dao.observeOrganizations().first().map { it.id }).containsExactly("o1") + } + + @Test + fun `updateOrganization sends the changed fields and refreshes the cache`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody("""{ "organization": { "id": "o1", "name": "Renamed", "isPublic": false } }"""), + ) + + val result = repository.updateOrganization("o1", name = "Renamed", description = null, isPublic = false) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.name).isEqualTo("Renamed") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/organizations/o1") + val body = request.body.readUtf8() + assertThat(body).contains("\"name\":\"Renamed\"") + assertThat(body).contains("\"isPublic\":\"false\"") + } + + @Test + fun `deleteOrganization evicts from cache on success`() = runTest(dispatcher) { + dao.upsert(CachedOrganizationEntity("gone", "X", null, null, false, 0, null, null)) + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.deleteOrganization("gone") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(dao.observeOrganizations().first()).isEmpty() + } + + @Test + fun `getMembers maps flattened and nested member rows`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "data": [ + { "userId": "u1", "username": "ada", "role": "owner" }, + { "role": "member", "user": { "id": "u2", "username": "grace" } } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getMembers("o1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val members = (result as ApiResult.Success).data + assertThat(members.map { it.userId }).containsExactly("u1", "u2").inOrder() + assertThat(members[0].role).isEqualTo(OrgRole.OWNER) + assertThat(members[1].username).isEqualTo("grace") + } + + @Test + fun `addMember posts the user id and role`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + + val result = repository.addMember("o1", userId = "u5", role = OrgRole.ADMIN) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/organizations/o1/members") + val body = request.body.readUtf8() + assertThat(body).contains("\"userId\":\"u5\"") + assertThat(body).contains("\"role\":\"admin\"") + } + + @Test + fun `updateMemberRole puts the new role for the member`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.updateMemberRole("o1", userId = "u5", role = OrgRole.OWNER) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/organizations/o1/members/u5") + assertThat(request.body.readUtf8()).contains("\"role\":\"owner\"") + } + + @Test + fun `removeMember deletes the membership`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.removeMember("o1", userId = "u5") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/organizations/o1/members/u5") + } +} + +/** Minimal in-memory [OrganizationDao] backed by a StateFlow, for JVM repository tests. */ +private class FakeOrganizationDao : OrganizationDao { + private val state = MutableStateFlow>(emptyList()) + + override fun observeOrganizations(): Flow> = state + + override suspend fun upsertAll(orgs: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + orgs.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun upsert(org: CachedOrganizationEntity) = upsertAll(listOf(org)) + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/FlexibleBooleanSerializerTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/FlexibleBooleanSerializerTest.kt new file mode 100644 index 0000000..11ec005 --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/FlexibleBooleanSerializerTest.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationDto +import kotlinx.serialization.json.Json +import org.junit.Test + +/** + * The API types several booleans as strings but real payloads mix booleans and + * strings; the flexible serializer normalises both so `isPublic` is reliable. + */ +class FlexibleBooleanSerializerTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private fun publicOf(body: String): Boolean = + json.decodeFromString(OrganizationDto.serializer(), body).resolvedPublic + + @Test + fun `decodes a real json boolean`() { + assertThat(publicOf("""{ "id": "1", "isPublic": true }""")).isTrue() + assertThat(publicOf("""{ "id": "1", "isPublic": false }""")).isFalse() + } + + @Test + fun `decodes a string boolean`() { + assertThat(publicOf("""{ "id": "1", "isPublic": "true" }""")).isTrue() + assertThat(publicOf("""{ "id": "1", "isPublic": "false" }""")).isFalse() + assertThat(publicOf("""{ "id": "1", "isPublic": "1" }""")).isTrue() + } + + @Test + fun `absent flag defaults to private`() { + assertThat(publicOf("""{ "id": "1" }""")).isFalse() + } + + @Test + fun `falls back to the public field when isPublic is absent`() { + assertThat(publicOf("""{ "id": "1", "public": "yes" }""")).isTrue() + } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapperTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapperTest.kt new file mode 100644 index 0000000..b164317 --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/MemberMapperTest.kt @@ -0,0 +1,61 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.organizations.data.remote.dto.MemberDto +import com.interlinedlist.android.feature.organizations.data.remote.dto.MemberUserDto +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import org.junit.Test + +/** + * Member rows arrive flattened or nested and with varied role strings; the mapper + * tolerates both shapes and normalises the role so a member is never dropped. + */ +class MemberMapperTest { + + @Test + fun `maps a flattened member row`() { + val member = MemberMapper.fromDto( + MemberDto(userId = "u1", username = "ada", displayName = "Ada", role = "admin", active = true), + ) + + assertThat(member).isNotNull() + assertThat(member!!.userId).isEqualTo("u1") + assertThat(member.username).isEqualTo("ada") + assertThat(member.label).isEqualTo("Ada") + assertThat(member.role).isEqualTo(OrgRole.ADMIN) + assertThat(member.active).isTrue() + } + + @Test + fun `maps a nested user object and defaults an unknown role to member`() { + val member = MemberMapper.fromDto( + MemberDto( + role = "wizard", + user = MemberUserDto(id = "u2", username = "grace", displayName = null), + ), + ) + + assertThat(member!!.userId).isEqualTo("u2") + assertThat(member.username).isEqualTo("grace") + // No display name → the row labels by username. + assertThat(member.label).isEqualTo("grace") + assertThat(member.role).isEqualTo(OrgRole.MEMBER) + // Absent active flag defaults to true (a listed member is active). + assertThat(member.active).isTrue() + } + + @Test + fun `returns null when no user id can be resolved`() { + assertThat(MemberMapper.fromDto(MemberDto(role = "member"))).isNull() + } + + @Test + fun `maps a candidate user`() { + val candidate = MemberMapper.candidateFromDto( + MemberUserDto(id = "u3", username = "linus", displayName = "Linus"), + ) + + assertThat(candidate.userId).isEqualTo("u3") + assertThat(candidate.label).isEqualTo("Linus") + } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapperTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapperTest.kt new file mode 100644 index 0000000..b1c68f4 --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/data/OrganizationMapperTest.kt @@ -0,0 +1,62 @@ +package com.interlinedlist.android.feature.organizations.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.organizations.data.remote.dto.OrganizationDto +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import org.junit.Test + +/** + * Organizations arrive with counts under several field names and an optional role; + * the mapper resolves those, and entity round-tripping preserves the domain shape. + */ +class OrganizationMapperTest { + + @Test + fun `maps a dto resolving avatar public and member count`() { + val org = OrganizationMapper.fromDto( + OrganizationDto( + id = "o1", + name = "Acme", + description = "Makers", + avatar = "https://img/acme.png", + isPublic = true, + membersCount = 7, + role = "admin", + ), + ) + + assertThat(org.id).isEqualTo("o1") + assertThat(org.avatarUrl).isEqualTo("https://img/acme.png") + assertThat(org.isPublic).isTrue() + assertThat(org.memberCount).isEqualTo(7) + assertThat(org.role).isEqualTo(OrgRole.ADMIN) + } + + @Test + fun `defaults an absent public flag to private and a missing count to zero`() { + val org = OrganizationMapper.fromDto(OrganizationDto(id = "o2", name = "Nameless")) + + assertThat(org.isPublic).isFalse() + assertThat(org.memberCount).isEqualTo(0) + assertThat(org.role).isNull() + } + + @Test + fun `entity round-trip preserves the organization`() { + val org = Organization( + id = "o3", + name = "Round Trip", + description = "desc", + avatarUrl = "a", + isPublic = true, + memberCount = 3, + role = OrgRole.OWNER, + updatedAt = "2026-01-01", + ) + + val restored = OrganizationMapper.fromEntity(OrganizationMapper.toEntity(org)) + + assertThat(restored).isEqualTo(org) + } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt new file mode 100644 index 0000000..f7557e3 --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/detail/OrganizationDetailViewModelTest.kt @@ -0,0 +1,177 @@ +package com.interlinedlist.android.feature.organizations.ui.detail + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.organizations.FakeOrganizationsRepository +import com.interlinedlist.android.feature.organizations.domain.MemberCandidate +import com.interlinedlist.android.feature.organizations.domain.OrgMember +import com.interlinedlist.android.feature.organizations.domain.OrgRole +import com.interlinedlist.android.feature.organizations.domain.Organization +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class OrganizationDetailViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun member(id: String, role: OrgRole = OrgRole.MEMBER) = + OrgMember(id, "user$id", "User $id", null, role, active = true) + + private fun vmFor(repo: FakeOrganizationsRepository, orgId: String = "o1") = + OrganizationDetailViewModel(repo, SavedStateHandle(mapOf(ORG_ID_ARG to orgId))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `load fetches metadata and members`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + getResult = ApiResult.Success( + Organization("o1", "Acme", "Makers", null, true, 2, OrgRole.OWNER, null), + ) + membersResult = ApiResult.Success(listOf(member("u1", OrgRole.OWNER), member("u2"))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.organization?.name).isEqualTo("Acme") + assertThat(state.members.map { it.userId }).containsExactly("u1", "u2").inOrder() + } + + @Test + fun `subscription gate is flagged when metadata is gated`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + getResult = FakeOrganizationsRepository.subscriptionFailure() + } + val vm = vmFor(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + } + + @Test + fun `updateOrganization forwards the edited fields and swaps the header`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + updateResult = ApiResult.Success( + Organization("o1", "Renamed", "new desc", null, false, 2, OrgRole.OWNER, null), + ) + } + val vm = vmFor(repo) + advanceUntilIdle() + + var done = false + vm.updateOrganization("Renamed", "new desc", isPublic = false) { done = true } + advanceUntilIdle() + + assertThat(done).isTrue() + assertThat(repo.lastUpdate).isEqualTo(Triple("Renamed", "new desc", false)) + assertThat(vm.uiState.value.organization?.name).isEqualTo("Renamed") + } + + @Test + fun `deleteOrganization flags deleted and invokes the callback`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository() + val vm = vmFor(repo) + advanceUntilIdle() + + var deleted = false + vm.deleteOrganization { deleted = true } + advanceUntilIdle() + + assertThat(deleted).isTrue() + assertThat(vm.uiState.value.deleted).isTrue() + } + + @Test + fun `searching surfaces candidates and clearing resets them`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + candidatesResult = ApiResult.Success(listOf(MemberCandidate("c1", "newbie", "Newbie", null))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.onSearchQueryChange("new") + advanceUntilIdle() + assertThat(vm.uiState.value.candidates.map { it.userId }).containsExactly("c1") + assertThat(repo.lastMemberSearch).isEqualTo("new") + + vm.onSearchQueryChange("") + advanceUntilIdle() + assertThat(vm.uiState.value.candidates).isEmpty() + } + + @Test + fun `addMember clears the search and reloads members`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + membersResult = ApiResult.Success(listOf(member("u1"))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + // After adding, the repo returns an expanded member list. + repo.membersResult = ApiResult.Success(listOf(member("u1"), member("c1"))) + vm.addMember(MemberCandidate("c1", "newbie", null, null)) + advanceUntilIdle() + + assertThat(repo.addMemberCount).isEqualTo(1) + assertThat(vm.uiState.value.members.map { it.userId }).containsExactly("u1", "c1") + assertThat(vm.uiState.value.searchQuery).isEmpty() + } + + @Test + fun `changeRole updates the member's role in place`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + membersResult = ApiResult.Success(listOf(member("u1", OrgRole.MEMBER))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.changeRole(vm.uiState.value.members.first(), OrgRole.ADMIN) + advanceUntilIdle() + + assertThat(vm.uiState.value.members.first().role).isEqualTo(OrgRole.ADMIN) + } + + @Test + fun `changeRole is a no-op when the role is unchanged`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + membersResult = ApiResult.Success(listOf(member("u1", OrgRole.ADMIN))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.changeRole(vm.uiState.value.members.first(), OrgRole.ADMIN) + advanceUntilIdle() + + // Role unchanged → no update call made; state stays the same. + assertThat(vm.uiState.value.members.first().role).isEqualTo(OrgRole.ADMIN) + } + + @Test + fun `removeMember drops the member from the list`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + membersResult = ApiResult.Success(listOf(member("u1"), member("u2"))) + } + val vm = vmFor(repo) + advanceUntilIdle() + + vm.removeMember(vm.uiState.value.members.first { it.userId == "u1" }) + advanceUntilIdle() + + assertThat(repo.removeMemberCount).isEqualTo(1) + assertThat(vm.uiState.value.members.map { it.userId }).containsExactly("u2") + } +} diff --git a/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModelTest.kt b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModelTest.kt new file mode 100644 index 0000000..f7de7ae --- /dev/null +++ b/feature/organizations/src/test/kotlin/com/interlinedlist/android/feature/organizations/ui/list/OrganizationsViewModelTest.kt @@ -0,0 +1,142 @@ +package com.interlinedlist.android.feature.organizations.ui.list + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.organizations.FakeOrganizationsRepository +import com.interlinedlist.android.feature.organizations.domain.Organization +import com.interlinedlist.android.feature.organizations.domain.Paged +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class OrganizationsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun org(id: String) = Organization(id, "Org $id", null, null, false, 0, null, null) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `init refreshes and streams cached organizations from the repository`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + refreshResult = ApiResult.Success( + Paged(listOf(org("1"), org("2")), hasMore = true, total = 5, offset = 2), + ) + } + val vm = OrganizationsViewModel(repo) + + vm.uiState.test { + awaitItem() // initial + advanceUntilIdle() + val loaded = expectMostRecentItem() + assertThat(loaded.organizations.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(loaded.isRefreshing).isFalse() + assertThat(loaded.hasMore).isTrue() + assertThat(loaded.nextOffset).isEqualTo(2) + } + assertThat(repo.refreshCount).isEqualTo(1) + } + + @Test + fun `refresh failure surfaces error but keeps cached organizations visible`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + cache.value = listOf(org("cached")) + refreshResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = OrganizationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } // keep the combined flow active + advanceUntilIdle() + + val state = vm.uiState.value + // Offline-first: the Room stream still shows what was cached. + assertThat(state.organizations.map { it.id }).containsExactly("cached") + assertThat(state.errorMessage).isNotNull() + assertThat(state.isRefreshing).isFalse() + } + + @Test + fun `subscription gate is flagged for an upsell state`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + refreshResult = FakeOrganizationsRepository.subscriptionFailure() + } + val vm = OrganizationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + } + + @Test + fun `loadMore appends the next page and updates pagination`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + refreshResult = ApiResult.Success(Paged(listOf(org("1")), hasMore = true, total = 2, offset = 1)) + loadMoreResult = ApiResult.Success(Paged(listOf(org("2")), hasMore = false, total = 2, offset = 2)) + } + val vm = OrganizationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(1) + assertThat(vm.uiState.value.organizations.map { it.id }).containsExactly("1", "2").inOrder() + assertThat(vm.uiState.value.hasMore).isFalse() + } + + @Test + fun `loadMore is skipped when no more pages remain`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + refreshResult = ApiResult.Success(Paged(listOf(org("1")), hasMore = false, total = 1, offset = 1)) + } + val vm = OrganizationsViewModel(repo) + advanceUntilIdle() + + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.loadMoreCount).isEqualTo(0) + } + + @Test + fun `createOrganization reports the created org id via callback`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository() + val vm = OrganizationsViewModel(repo) + advanceUntilIdle() + + var createdId: String? = null + vm.createOrganization("Acme", "desc", isPublic = true) { createdId = it.id } + advanceUntilIdle() + + assertThat(createdId).isEqualTo("new") + } + + @Test + fun `createOrganization surfaces the subscription gate on failure`() = runTest(dispatcher) { + val repo = FakeOrganizationsRepository().apply { + createResult = FakeOrganizationsRepository.subscriptionFailure() + } + val vm = OrganizationsViewModel(repo) + backgroundScope.launch { vm.uiState.collect { } } + advanceUntilIdle() + + vm.createOrganization("Acme", null) + advanceUntilIdle() + + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + } +} diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/FollowScreensTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/FollowScreensTest.kt new file mode 100644 index 0000000..50a144b --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/FollowScreensTest.kt @@ -0,0 +1,97 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.ui.follow.FollowListScreen +import com.interlinedlist.android.feature.profile.ui.follow.FollowListTestTags +import com.interlinedlist.android.feature.profile.ui.follow.FollowListUiState +import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsScreen +import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsTestTags +import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class FollowScreensTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun followList_rowOpensUserProfile() { + var opened: String? = null + composeRule.setContent { + InterlinedListTheme { + FollowListScreen( + title = "Followers", + state = FollowListUiState( + users = listOf( + FollowUser("1", "ada", "Ada Lovelace", null), + FollowUser("2", "adron", "Adron Hall", null), + ), + isLoading = false, + ), + onOpenUser = { opened = it }, + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(FollowListTestTags.LIST).assertIsDisplayed() + composeRule.onNodeWithTag(FollowListTestTags.row("ada")).performClick() + assert(opened == "ada") + } + + @Test + fun followList_showsEmptyState() { + composeRule.setContent { + InterlinedListTheme { + FollowListScreen( + title = "Following", + state = FollowListUiState(users = emptyList(), isLoading = false), + onOpenUser = {}, + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(FollowListTestTags.EMPTY).assertIsDisplayed() + } + + @Test + fun followRequests_approveAndRejectInvokeCallbacks() { + var approved: String? = null + var rejected: String? = null + composeRule.setContent { + InterlinedListTheme { + FollowRequestsScreen( + state = FollowRequestsUiState( + requests = listOf( + FollowUser("1", "eve", "Eve", null), + FollowUser("2", "frank", "Frank", null), + ), + isLoading = false, + ), + onApprove = { approved = it }, + onReject = { rejected = it }, + onOpenUser = {}, + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(FollowRequestsTestTags.approve("eve")).performClick() + composeRule.onNodeWithTag(FollowRequestsTestTags.reject("frank")).performClick() + assert(approved == "1") + assert(rejected == "2") + } +} diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt index d9839fd..81fb6f9 100644 --- a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt @@ -8,8 +8,10 @@ import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult +import com.interlinedlist.android.feature.profile.ui.profile.AccountMenuTestTags import com.interlinedlist.android.feature.profile.ui.profile.ProfileScreen import com.interlinedlist.android.feature.profile.ui.profile.ProfileTestTags import com.interlinedlist.android.feature.profile.ui.profile.ProfileUiState @@ -38,19 +40,37 @@ class ProfileScreenTest { isCurrentUser = true, ) - @Test - fun profile_showsNameUsernameAndSubscriberBadge() { + private fun stubbedProfileScreen( + state: ProfileUiState, + onEditProfile: () -> Unit = {}, + onSearchUsers: () -> Unit = {}, + onOpenFollowers: () -> Unit = {}, + onOpenFollowing: () -> Unit = {}, + onOpenRequests: () -> Unit = {}, + onSignOut: () -> Unit = {}, + ) { composeRule.setContent { InterlinedListTheme { ProfileScreen( - state = ProfileUiState(user = sampleUser(), isLoading = false), - onEditProfile = {}, - onSearchUsers = {}, - onSignOut = {}, + state = state, + onEditProfile = onEditProfile, + onSearchUsers = onSearchUsers, + onOpenFollowers = onOpenFollowers, + onOpenFollowing = onOpenFollowing, + onOpenRequests = onOpenRequests, + onOpenNotifications = {}, + onOpenOrganizations = {}, + onOpenIntegrations = {}, + onSignOut = onSignOut, onRetry = {}, ) } } + } + + @Test + fun profile_showsNameUsernameAndSubscriberBadge() { + stubbedProfileScreen(ProfileUiState(user = sampleUser(), isLoading = false)) composeRule.onNodeWithTag(ProfileTestTags.DISPLAY_NAME).assertIsDisplayed() composeRule.onNodeWithTag(ProfileTestTags.USERNAME).assertIsDisplayed() @@ -59,44 +79,58 @@ class ProfileScreenTest { } @Test - fun profile_editSearchAndSignOut_invokeCallbacks() { + fun profile_showsTappableFollowerCounts() { + var openFollowers = false + var openFollowing = false + stubbedProfileScreen( + state = ProfileUiState( + user = sampleUser(), + isLoading = false, + followCounts = FollowCounts(followers = 12, following = 7), + ), + onOpenFollowers = { openFollowers = true }, + onOpenFollowing = { openFollowing = true }, + ) + + composeRule.onNodeWithTag(ProfileTestTags.FOLLOWERS_COUNT).performClick() + composeRule.onNodeWithTag(ProfileTestTags.FOLLOWING_COUNT).performClick() + + assert(openFollowers) + assert(openFollowing) + } + + @Test + fun profile_menuRows_invokeCallbacks() { var edit = false var search = false + var followers = false + var requests = false var signOut = false - composeRule.setContent { - InterlinedListTheme { - ProfileScreen( - state = ProfileUiState(user = sampleUser(), isLoading = false), - onEditProfile = { edit = true }, - onSearchUsers = { search = true }, - onSignOut = { signOut = true }, - onRetry = {}, - ) - } - } + stubbedProfileScreen( + state = ProfileUiState(user = sampleUser(), isLoading = false), + onEditProfile = { edit = true }, + onSearchUsers = { search = true }, + onOpenFollowers = { followers = true }, + onOpenRequests = { requests = true }, + onSignOut = { signOut = true }, + ) - composeRule.onNodeWithTag(ProfileTestTags.EDIT).performClick() - composeRule.onNodeWithTag(ProfileTestTags.SEARCH).performClick() + composeRule.onNodeWithTag(AccountMenuTestTags.EDIT_PROFILE).performClick() + composeRule.onNodeWithTag(AccountMenuTestTags.SEARCH_USERS).performClick() + composeRule.onNodeWithTag(AccountMenuTestTags.FOLLOWERS).performClick() + composeRule.onNodeWithTag(AccountMenuTestTags.REQUESTS).performClick() composeRule.onNodeWithTag(ProfileTestTags.SIGN_OUT).performClick() assert(edit) assert(search) + assert(followers) + assert(requests) assert(signOut) } @Test fun profile_showsProgress_whileLoadingWithNoCache() { - composeRule.setContent { - InterlinedListTheme { - ProfileScreen( - state = ProfileUiState(user = null, isLoading = true), - onEditProfile = {}, - onSearchUsers = {}, - onSignOut = {}, - onRetry = {}, - ) - } - } + stubbedProfileScreen(ProfileUiState(user = null, isLoading = true)) composeRule.onNodeWithTag(ProfileTestTags.PROGRESS).assertIsDisplayed() } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt index cbc7088..da24d77 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt @@ -8,12 +8,19 @@ import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.profile.data.local.ProfileDao import com.interlinedlist.android.feature.profile.data.local.toDomain import com.interlinedlist.android.feature.profile.data.local.toEntity +import com.interlinedlist.android.feature.profile.data.mapper.toFollowCounts +import com.interlinedlist.android.feature.profile.data.mapper.toFollowStatus +import com.interlinedlist.android.feature.profile.data.mapper.toFollowUser +import com.interlinedlist.android.feature.profile.data.mapper.toFollowUserOrNull import com.interlinedlist.android.feature.profile.data.mapper.toProfileUser import com.interlinedlist.android.feature.profile.data.mapper.toSearchResult import com.interlinedlist.android.feature.profile.data.remote.ProfileApi import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.Flow @@ -127,6 +134,58 @@ class DefaultProfileRepository @Inject constructor( .map { response -> response.usersOrEmpty.map { it.toSearchResult() } } } + override suspend fun getFollowStatus(userId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getFollowStatus(userId).toFollowStatus() } + } + + override suspend fun getFollowCounts(userId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getFollowCounts(userId).toFollowCounts() } + } + + override suspend fun followUser(userId: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.followUser(userId) } } + + override suspend fun unfollowUser(userId: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.unfollowUser(userId) } } + + override suspend fun getFollowers(username: String): ApiResult> = + withContext(dispatchers.io) { + when (val id = resolveUserId(username)) { + is ApiResult.Success -> safeApiCall(json) { + api.getFollowers(id.data, limit = LIST_LIMIT).usersOrEmpty.map { it.toFollowUser() } + } + is ApiResult.Failure -> id + } + } + + override suspend fun getFollowing(username: String): ApiResult> = + withContext(dispatchers.io) { + when (val id = resolveUserId(username)) { + is ApiResult.Success -> safeApiCall(json) { + api.getFollowing(id.data, limit = LIST_LIMIT).usersOrEmpty.map { it.toFollowUser() } + } + is ApiResult.Failure -> id + } + } + + override suspend fun getFollowRequests(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getFollowRequests().requestsOrEmpty.mapNotNull { it.toFollowUserOrNull() } + } + } + + override suspend fun approveFollowRequest(userId: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.approveFollowRequest(userId) } } + + override suspend fun rejectFollowRequest(userId: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.rejectFollowRequest(userId) } } + + override suspend fun removeFollower(userId: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.removeFollower(userId) } } + /** Caches [dto] as the current user, clearing the flag from any stale row first. */ private suspend fun cacheCurrentUser(dto: ProfileUserDto): ProfileUser { val domain = dto.toProfileUser(isCurrentUser = true) @@ -135,7 +194,27 @@ class DefaultProfileRepository @Inject constructor( return domain } + /** + * Resolves a [username] to its stable id — the follow endpoints key on the id, + * while the UI navigates by username. Prefers the Room cache (populated when the + * profile was viewed) and falls back to `GET /api/users/{username}`. + */ + private suspend fun resolveUserId(username: String): ApiResult { + profileDao.getByUsername(username)?.let { return ApiResult.Success(it.id) } + return safeApiCall(json) { api.getUserByUsername(username).userOrSelf }.let { result -> + when (result) { + is ApiResult.Success -> { + val dto = result.data + ?: return ApiResult.Failure(AppError.NotFound("User not found")) + ApiResult.Success(dto.id) + } + is ApiResult.Failure -> result + } + } + } + private companion object { const val SEARCH_LIMIT = 20 + const val LIST_LIMIT = 50 } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt index aa2fc8b..3d2f8fb 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt @@ -1,6 +1,9 @@ package com.interlinedlist.android.feature.profile.data import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.Flow @@ -51,4 +54,40 @@ interface ProfileRepository { /** One-shot user search against `GET /api/users/search` (not cached). */ suspend fun searchUsers(query: String): ApiResult> + + // --- Following --- + + /** The current user's follow relationship to [userId] via `GET /api/follow/{userId}/status`. */ + suspend fun getFollowStatus(userId: String): ApiResult + + /** Follower / following counts for [userId] via `GET /api/follow/{userId}/counts`. */ + suspend fun getFollowCounts(userId: String): ApiResult + + /** Follows [userId] via `POST /api/follow/{userId}`. */ + suspend fun followUser(userId: String): ApiResult + + /** Unfollows [userId] via `DELETE /api/follow/{userId}`. */ + suspend fun unfollowUser(userId: String): ApiResult + + /** + * The users following the user named [username], reached by drilling down from a + * profile. Resolves the username to an id, then reads + * `GET /api/follow/{userId}/followers`. + */ + suspend fun getFollowers(username: String): ApiResult> + + /** The users the user named [username] follows via `GET /api/follow/{userId}/following`. */ + suspend fun getFollowing(username: String): ApiResult> + + /** The current user's pending follow requests via `GET /api/follow/requests`. */ + suspend fun getFollowRequests(): ApiResult> + + /** Approves a pending request from [userId] via `POST /api/follow/{userId}/approve`. */ + suspend fun approveFollowRequest(userId: String): ApiResult + + /** Rejects a pending request from [userId] via `POST /api/follow/{userId}/reject`. */ + suspend fun rejectFollowRequest(userId: String): ApiResult + + /** Removes [userId] as a follower via `DELETE /api/follow/{userId}/remove`. */ + suspend fun removeFollower(userId: String): ApiResult } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/FollowMappers.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/FollowMappers.kt new file mode 100644 index 0000000..5f63db2 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/FollowMappers.kt @@ -0,0 +1,34 @@ +package com.interlinedlist.android.feature.profile.data.mapper + +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowCountsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowRequestDto +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowStatusResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.FollowUser + +/** Maps a wire user into a lightweight [FollowUser] for list/request rows. */ +fun ProfileUserDto.toFollowUser(): FollowUser = FollowUser( + id = id, + username = username, + displayName = displayName, + avatarUrl = avatarOrNull, +) + +/** Maps the status envelope to the domain [FollowStatus] (SELF is decided elsewhere). */ +fun FollowStatusResponse.toFollowStatus(): FollowStatus = when (resolvedStatus) { + "following" -> FollowStatus.FOLLOWING + "requested" -> FollowStatus.REQUESTED + else -> FollowStatus.NOT_FOLLOWING +} + +/** Maps the counts envelope to the domain [FollowCounts]. */ +fun FollowCountsResponse.toFollowCounts(): FollowCounts = FollowCounts( + followers = followersOrZero, + following = followingOrZero, +) + +/** Maps a pending-request entry to a [FollowUser], dropping entries with no user. */ +fun FollowRequestDto.toFollowUserOrNull(): FollowUser? = + requesterOrSelf?.takeIf { it.id.isNotBlank() }?.toFollowUser() diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt index d14783b..668244a 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt @@ -2,11 +2,16 @@ package com.interlinedlist.android.feature.profile.data.remote import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowCountsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowListResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowRequestsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowStatusResponse import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileResponse import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest import com.interlinedlist.android.feature.profile.data.remote.dto.UserSearchResponse import okhttp3.MultipartBody import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.Multipart import retrofit2.http.PATCH @@ -50,4 +55,54 @@ interface ProfileApi { @Query("q") query: String, @Query("limit") limit: Int? = null, ): UserSearchResponse + + // --- Following --- + + /** Follows a user. */ + @POST("api/follow/{userId}") + suspend fun followUser(@Path("userId") userId: String) + + /** Unfollows a user. */ + @DELETE("api/follow/{userId}") + suspend fun unfollowUser(@Path("userId") userId: String) + + /** The current user's follow relationship to a target user. */ + @GET("api/follow/{userId}/status") + suspend fun getFollowStatus(@Path("userId") userId: String): FollowStatusResponse + + /** Follower / following counts for a user. */ + @GET("api/follow/{userId}/counts") + suspend fun getFollowCounts(@Path("userId") userId: String): FollowCountsResponse + + /** Users following a user. */ + @GET("api/follow/{userId}/followers") + suspend fun getFollowers( + @Path("userId") userId: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): FollowListResponse + + /** Users a user is following. */ + @GET("api/follow/{userId}/following") + suspend fun getFollowing( + @Path("userId") userId: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): FollowListResponse + + /** Pending follow requests for the current user (private accounts). */ + @GET("api/follow/requests") + suspend fun getFollowRequests(): FollowRequestsResponse + + /** Approves a pending follow request from [userId]. */ + @POST("api/follow/{userId}/approve") + suspend fun approveFollowRequest(@Path("userId") userId: String) + + /** Rejects a pending follow request from [userId]. */ + @POST("api/follow/{userId}/reject") + suspend fun rejectFollowRequest(@Path("userId") userId: String) + + /** Removes a follower (only callable by the user being followed). */ + @DELETE("api/follow/{userId}/remove") + suspend fun removeFollower(@Path("userId") userId: String) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/FollowResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/FollowResponses.kt new file mode 100644 index 0000000..bc7e104 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/FollowResponses.kt @@ -0,0 +1,111 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/follow/{userId}/status`. The API is undocumented about the exact shape, + * so this tolerates the common conventions: an explicit `status` string + * (`following` / `requested` / `pending` / `none`), or boolean flags + * (`isFollowing` / `requested`). [resolvedStatus] normalises them to one of + * `following` / `requested` / `none`. + */ +@Serializable +data class FollowStatusResponse( + val status: String? = null, + val isFollowing: Boolean? = null, + val following: Boolean? = null, + val requested: Boolean? = null, + val isRequested: Boolean? = null, + val pending: Boolean? = null, +) { + /** Normalised status token: `following`, `requested`, or `none`. */ + val resolvedStatus: String + get() { + status?.lowercase()?.let { raw -> + return when { + raw.contains("follow") && !raw.contains("not") -> "following" + raw.contains("request") || raw.contains("pending") -> "requested" + else -> "none" + } + } + return when { + isFollowing == true || following == true -> "following" + requested == true || isRequested == true || pending == true -> "requested" + else -> "none" + } + } +} + +/** + * `GET /api/follow/{userId}/counts`. Follower / following tallies. Field names vary + * across similar APIs, so a couple of common aliases are accepted. + */ +@Serializable +data class FollowCountsResponse( + val followers: Int? = null, + val following: Int? = null, + val followersCount: Int? = null, + val followingCount: Int? = null, +) { + val followersOrZero: Int get() = followers ?: followersCount ?: 0 + val followingOrZero: Int get() = following ?: followingCount ?: 0 +} + +/** + * `GET /api/follow/{userId}/followers` and `/following`. A list of users under one + * of the common envelope keys (`users`, `followers`, `following`, or the generic + * `data`). [usersOrEmpty] reads whichever the server populated. + */ +@Serializable +data class FollowListResponse( + val users: List? = null, + val followers: List? = null, + val following: List? = null, + val data: List? = null, +) { + val usersOrEmpty: List + get() = users ?: followers ?: following ?: data ?: emptyList() +} + +/** + * A single pending follow request. The requester may be nested under `user` / + * `follower` / `requester`, or inlined at the top level; [requesterOrSelf] resolves + * whichever shape the server used. + */ +@Serializable +data class FollowRequestDto( + val user: ProfileUserDto? = null, + val follower: ProfileUserDto? = null, + val requester: ProfileUserDto? = null, + val id: String? = null, + val username: String? = null, + val displayName: String? = null, + val avatarUrl: String? = null, + val avatar: String? = null, +) { + /** The requesting user, whether nested or inlined. */ + val requesterOrSelf: ProfileUserDto? + get() = user ?: follower ?: requester ?: id?.let { + ProfileUserDto( + id = it, + username = username ?: "", + displayName = displayName, + avatarUrl = avatarUrl, + avatar = avatar, + ) + } +} + +/** + * `GET /api/follow/requests`. Pending requests under one of the common envelope keys + * (`requests`, `users`, or the generic `data`). + */ +@Serializable +data class FollowRequestsResponse( + val requests: List? = null, + val users: List? = null, + val data: List? = null, +) { + val requestsOrEmpty: List + get() = requests ?: users ?: data ?: emptyList() +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowCounts.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowCounts.kt new file mode 100644 index 0000000..69c1183 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowCounts.kt @@ -0,0 +1,11 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * Follower / following tallies for a user, shown as tappable counts on the profile + * header. Defaults to zero so the UI always has something to render before the + * counts load. + */ +data class FollowCounts( + val followers: Int = 0, + val following: Int = 0, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowStatus.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowStatus.kt new file mode 100644 index 0000000..9f3603a --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowStatus.kt @@ -0,0 +1,20 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * The current user's follow relationship to a target user. Drives the follow button + * on another user's profile. Private accounts return [REQUESTED] until the target + * approves, so the button can read "Requested" instead of "Following". + */ +enum class FollowStatus { + /** Not following and no pending request. */ + NOT_FOLLOWING, + + /** A follow request is pending approval (target account is private). */ + REQUESTED, + + /** Actively following the target. */ + FOLLOWING, + + /** Viewing your own profile — no follow affordance applies. */ + SELF, +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowUser.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowUser.kt new file mode 100644 index 0000000..720fcc4 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/FollowUser.kt @@ -0,0 +1,17 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * A user entry in a followers / following list or a pending follow request. Tapping + * one drills down into that user's full profile by [username], mirroring how search + * results open a profile. + */ +data class FollowUser( + val id: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) { + /** The best label to show for the user: display name if set, else the @username. */ + val displayLabel: String + get() = displayName?.takeIf { it.isNotBlank() } ?: "@$username" +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt index 233ee70..dbfd80b 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/common/ProfileComponents.kt @@ -17,8 +17,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -import com.interlinedlist.android.core.designsystem.theme.AmberGold -import com.interlinedlist.android.core.designsystem.theme.OceanBlue /** * A circular user avatar. Loads [avatarUrl] with Coil when present, otherwise falls @@ -47,7 +45,7 @@ fun UserAvatar( modifier = modifier .size(size) .clip(shape) - .background(OceanBlue), + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center, ) { Text( @@ -64,7 +62,7 @@ fun UserAvatar( @Composable fun SubscriberBadge(modifier: Modifier = Modifier) { Surface( - color = AmberGold, + color = MaterialTheme.colorScheme.tertiaryContainer, shape = MaterialTheme.shapes.small, modifier = modifier, ) { @@ -72,7 +70,7 @@ fun SubscriberBadge(modifier: Modifier = Modifier) { text = "Subscriber", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.colorScheme.onTertiaryContainer, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), ) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListScreen.kt new file mode 100644 index 0000000..a413cc1 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListScreen.kt @@ -0,0 +1,212 @@ +package com.interlinedlist.android.feature.profile.ui.follow + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.ui.common.UserAvatar + +/** Stable test tags for the followers / following lists. */ +object FollowListTestTags { + const val LIST = "followList" + const val EMPTY = "followListEmpty" + const val PROGRESS = "followListProgress" + const val ERROR = "followListError" + const val BACK = "followListBack" + fun row(username: String) = "followListRow_$username" +} + +/** + * The followers list for a user (route `followers/{username}`). Tapping a row drills + * down into that user's profile, mirroring the drill-down navigation pattern. + * + * @param onOpenUser navigate to `profile/{username}` for the tapped user. + * @param onBack pop back to the previous screen. + */ +@Composable +fun FollowersRoute( + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: FollowersViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + FollowListScreen( + title = "Followers", + state = state, + onOpenUser = onOpenUser, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** + * The following list for a user (route `following/{username}`). Tapping a row drills + * down into that user's profile. + * + * @param onOpenUser navigate to `profile/{username}` for the tapped user. + * @param onBack pop back to the previous screen. + */ +@Composable +fun FollowingRoute( + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: FollowingViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + FollowListScreen( + title = "Following", + state = state, + onOpenUser = onOpenUser, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless followers / following list UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun FollowListScreen( + title: String, + state: FollowListUiState, + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(FollowListTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(FollowListTestTags.PROGRESS), + ) + + state.errorMessage != null -> Column( + Modifier.align(Alignment.Center).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(FollowListTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + + state.isEmpty -> Text( + text = "No one here yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).testTag(FollowListTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(FollowListTestTags.LIST), + ) { + items(state.users, key = { it.id }) { user -> + FollowUserRow(user = user, onClick = { onOpenUser(user.username) }) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun FollowUserRow(user: FollowUser, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(FollowListTestTags.row(user.username)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar(avatarUrl = user.avatarUrl, seedLabel = user.displayLabel, size = 40.dp) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = user.displayLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${user.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FollowListScreenPreview() { + InterlinedListTheme { + FollowListScreen( + title = "Followers", + state = FollowListUiState( + users = listOf( + FollowUser("1", "ada", "Ada Lovelace", null), + FollowUser("2", "adron", "Adron Hall", null), + ), + isLoading = false, + ), + onOpenUser = {}, + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListViewModel.kt new file mode 100644 index 0000000..10b846d --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowListViewModel.kt @@ -0,0 +1,87 @@ +package com.interlinedlist.android.feature.profile.ui.follow + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav arg key the followers/following routes read their target username from. */ +const val FOLLOW_USERNAME_ARG = "username" + +/** UI state for a followers or following list. */ +data class FollowListUiState( + val users: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, +) { + /** A load finished with no users and no error. */ + val isEmpty: Boolean get() = users.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Base for the followers and following list ViewModels. Reads the target [username] + * from the nav arg (routes `followers/{username}` / `following/{username}`) and loads + * the list via [loadUsers]. Tapping a row drills down into that user's profile. + */ +abstract class FollowListViewModel( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + protected val username: String = checkNotNull(savedStateHandle[FOLLOW_USERNAME_ARG]) { + "FollowListViewModel requires a '$FOLLOW_USERNAME_ARG' nav arg" + } + + private val _uiState = MutableStateFlow(FollowListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + /** Fetches the appropriate list (followers or following) for [username]. */ + protected abstract suspend fun loadUsers(username: String): ApiResult> + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = loadUsers(username)) { + is ApiResult.Success -> _uiState.update { + it.copy(users = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} + +/** Drives the followers list (`GET /api/follow/{userId}/followers`). */ +@HiltViewModel +class FollowersViewModel @Inject constructor( + private val repository: ProfileRepository, + savedStateHandle: SavedStateHandle, +) : FollowListViewModel(savedStateHandle) { + override suspend fun loadUsers(username: String) = repository.getFollowers(username) +} + +/** Drives the following list (`GET /api/follow/{userId}/following`). */ +@HiltViewModel +class FollowingViewModel @Inject constructor( + private val repository: ProfileRepository, + savedStateHandle: SavedStateHandle, +) : FollowListViewModel(savedStateHandle) { + override suspend fun loadUsers(username: String) = repository.getFollowing(username) +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsScreen.kt new file mode 100644 index 0000000..eb69b1b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsScreen.kt @@ -0,0 +1,223 @@ +package com.interlinedlist.android.feature.profile.ui.follow + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.ui.common.UserAvatar + +/** Stable test tags for the follow-requests screen. */ +object FollowRequestsTestTags { + const val LIST = "followRequestsList" + const val EMPTY = "followRequestsEmpty" + const val PROGRESS = "followRequestsProgress" + const val ERROR = "followRequestsError" + const val BACK = "followRequestsBack" + fun approve(username: String) = "followRequestApprove_$username" + fun reject(username: String) = "followRequestReject_$username" + fun row(username: String) = "followRequestRow_$username" +} + +/** + * The current user's pending follow requests (private accounts). Approve/reject each + * request; tapping a row drills into the requester's profile. + * + * @param onOpenUser navigate to `profile/{username}` for the tapped requester. + * @param onBack pop back to the account hub. + */ +@Composable +fun FollowRequestsRoute( + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: FollowRequestsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + FollowRequestsScreen( + state = state, + onApprove = viewModel::approve, + onReject = viewModel::reject, + onOpenUser = onOpenUser, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless follow-requests UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun FollowRequestsScreen( + state: FollowRequestsUiState, + onApprove: (String) -> Unit, + onReject: (String) -> Unit, + onOpenUser: (String) -> Unit, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Follow requests") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(FollowRequestsTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(FollowRequestsTestTags.PROGRESS), + ) + + state.errorMessage != null && state.requests.isEmpty() -> Column( + Modifier.align(Alignment.Center).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(FollowRequestsTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + + state.isEmpty -> Text( + text = "No pending requests.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).testTag(FollowRequestsTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(FollowRequestsTestTags.LIST), + ) { + items(state.requests, key = { it.id }) { user -> + FollowRequestRow( + user = user, + inProgress = user.id in state.pendingActionIds, + onOpen = { onOpenUser(user.username) }, + onApprove = { onApprove(user.id) }, + onReject = { onReject(user.id) }, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun FollowRequestRow( + user: FollowUser, + inProgress: Boolean, + onOpen: () -> Unit, + onApprove: () -> Unit, + onReject: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .testTag(FollowRequestsTestTags.row(user.username)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar(avatarUrl = user.avatarUrl, seedLabel = user.displayLabel, size = 40.dp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = user.displayLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${user.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (inProgress) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + OutlinedButton( + onClick = onReject, + modifier = Modifier.testTag(FollowRequestsTestTags.reject(user.username)), + ) { + Text("Reject") + } + Spacer(Modifier.width(8.dp)) + Button( + onClick = onApprove, + modifier = Modifier.testTag(FollowRequestsTestTags.approve(user.username)), + ) { + Text("Approve") + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun FollowRequestsScreenPreview() { + InterlinedListTheme { + FollowRequestsScreen( + state = FollowRequestsUiState( + requests = listOf( + FollowUser("1", "ada", "Ada Lovelace", null), + FollowUser("2", "grace", "Grace Hopper", null), + ), + isLoading = false, + ), + onApprove = {}, + onReject = {}, + onOpenUser = {}, + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsViewModel.kt new file mode 100644 index 0000000..8b0843b --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/follow/FollowRequestsViewModel.kt @@ -0,0 +1,86 @@ +package com.interlinedlist.android.feature.profile.ui.follow + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the pending follow-requests screen. */ +data class FollowRequestsUiState( + val requests: List = emptyList(), + val isLoading: Boolean = true, + // Ids currently being approved/rejected, so their row can show progress. + val pendingActionIds: Set = emptySet(), + val errorMessage: String? = null, +) { + /** A load finished with no requests and no error. */ + val isEmpty: Boolean get() = requests.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the current user's pending follow requests (private accounts). Loads via + * `GET /api/follow/requests` and approves/rejects each, removing the row on success. + */ +@HiltViewModel +class FollowRequestsViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FollowRequestsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getFollowRequests()) { + is ApiResult.Success -> _uiState.update { + it.copy(requests = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun approve(userId: String) = act(userId) { repository.approveFollowRequest(it) } + + fun reject(userId: String) = act(userId) { repository.rejectFollowRequest(it) } + + /** Runs an approve/reject [action], flipping per-row progress and dropping the row on success. */ + private fun act(userId: String, action: suspend (String) -> ApiResult) { + if (userId in _uiState.value.pendingActionIds) return + _uiState.update { it.copy(pendingActionIds = it.pendingActionIds + userId, errorMessage = null) } + viewModelScope.launch { + when (val result = action(userId)) { + is ApiResult.Success -> _uiState.update { + it.copy( + requests = it.requests.filterNot { user -> user.id == userId }, + pendingActionIds = it.pendingActionIds - userId, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + pendingActionIds = it.pendingActionIds - userId, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt index eeca614..d145e94 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt @@ -1,21 +1,29 @@ package com.interlinedlist.android.feature.profile.ui.profile +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.ui.common.SubscriberBadge import com.interlinedlist.android.feature.profile.ui.common.UserAvatar @@ -33,17 +41,34 @@ object ProfileTestTags { const val ERROR = "profileError" const val SEARCH = "profileSearch" const val BACK = "profileBack" + const val FOLLOW_TOGGLE = "profileFollowToggle" + const val FOLLOWERS_COUNT = "profileFollowersCount" + const val FOLLOWING_COUNT = "profileFollowingCount" } /** - * Stateless profile body — avatar, name, @username, subscriber badge, and bio. - * Shared by the current-user ("Account") and other-user profile screens so both - * render identically. + * Stateless profile body — avatar, name, @username, subscriber badge, tappable + * follower/following counts, an optional follow button, and bio. Shared by the + * current-user ("Account") and other-user profile screens so both render identically. + * + * @param counts follower / following tallies shown as tappable stats. + * @param onOpenFollowers open the followers list for this user. + * @param onOpenFollowing open the following list for this user. + * @param followStatus the current user's relationship to this profile; [FollowStatus.SELF] + * hides the follow button (own profile). + * @param isFollowActionInProgress disables the follow button while a toggle is in flight. + * @param onToggleFollow follow/unfollow the viewed user. */ @Composable fun ProfileContent( user: ProfileUser, modifier: Modifier = Modifier, + counts: FollowCounts = FollowCounts(), + onOpenFollowers: () -> Unit = {}, + onOpenFollowing: () -> Unit = {}, + followStatus: FollowStatus = FollowStatus.SELF, + isFollowActionInProgress: Boolean = false, + onToggleFollow: () -> Unit = {}, ) { Column( modifier = modifier @@ -80,6 +105,22 @@ fun ProfileContent( SubscriberBadge(modifier = Modifier.testTag(ProfileTestTags.SUBSCRIBER_BADGE)) } + Spacer(Modifier.height(20.dp)) + FollowCountsRow( + counts = counts, + onOpenFollowers = onOpenFollowers, + onOpenFollowing = onOpenFollowing, + ) + + if (followStatus != FollowStatus.SELF) { + Spacer(Modifier.height(16.dp)) + FollowButton( + status = followStatus, + inProgress = isFollowActionInProgress, + onClick = onToggleFollow, + ) + } + if (!user.bio.isNullOrBlank()) { Spacer(Modifier.height(20.dp)) Text( @@ -94,3 +135,86 @@ fun ProfileContent( } } } + +/** A row of tappable follower / following stats. */ +@Composable +private fun FollowCountsRow( + counts: FollowCounts, + onOpenFollowers: () -> Unit, + onOpenFollowing: () -> Unit, +) { + Row(horizontalArrangement = Arrangement.Center) { + CountStat( + value = counts.followers, + label = "Followers", + onClick = onOpenFollowers, + modifier = Modifier.testTag(ProfileTestTags.FOLLOWERS_COUNT), + ) + Spacer(Modifier.width(32.dp)) + CountStat( + value = counts.following, + label = "Following", + onClick = onOpenFollowing, + modifier = Modifier.testTag(ProfileTestTags.FOLLOWING_COUNT), + ) + } +} + +@Composable +private fun CountStat( + value: Int, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = value.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** A follow / following / requested toggle button reflecting [status]. */ +@Composable +private fun FollowButton( + status: FollowStatus, + inProgress: Boolean, + onClick: () -> Unit, +) { + val isFollowing = status == FollowStatus.FOLLOWING || status == FollowStatus.REQUESTED + val label = when (status) { + FollowStatus.FOLLOWING -> "Following" + FollowStatus.REQUESTED -> "Requested" + else -> "Follow" + } + if (isFollowing) { + OutlinedButton( + onClick = onClick, + enabled = !inProgress, + modifier = Modifier.testTag(ProfileTestTags.FOLLOW_TOGGLE), + ) { + Text(label) + } + } else { + Button( + onClick = onClick, + enabled = !inProgress, + modifier = Modifier.testTag(ProfileTestTags.FOLLOW_TOGGLE), + ) { + Text(label) + } + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt index cd5b44b..3b90d58 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -1,21 +1,31 @@ package com.interlinedlist.android.feature.profile.ui.profile +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Business import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -23,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -30,70 +41,110 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.ProfileUser +/** Stable test tags for the Account hub's menu rows. */ +object AccountMenuTestTags { + const val FOLLOWERS = "accountMenuFollowers" + const val FOLLOWING = "accountMenuFollowing" + const val REQUESTS = "accountMenuRequests" + const val NOTIFICATIONS = "accountMenuNotifications" + const val ORGANIZATIONS = "accountMenuOrganizations" + const val INTEGRATIONS = "accountMenuIntegrations" + const val EDIT_PROFILE = "accountMenuEditProfile" + const val SEARCH_USERS = "accountMenuSearchUsers" +} + /** - * The app's "Account" tab: the current signed-in user's profile with entries to - * edit the profile, search users, and sign out. + * The app's "Account" tab: a hub built around the current user's profile header + * (with tappable follower/following counts) and a menu that links out to the rest of + * the app. + * + * The follows/edit/search callbacks navigate within this module; the notifications, + * organizations, and integrations callbacks navigate to OTHER feature modules — this + * module only exposes them, the app wires the destinations. * * @param onEditProfile navigate to the edit-profile route. * @param onSearchUsers navigate to the user-search route. - * @param onSignOut invoked after the caller performs sign-out (mirrors HomeScreen's - * `onLoggedOut`); the profile module does not own session state, so the app wires - * this to the auth logout + navigation. + * @param onOpenFollowers navigate to the current user's followers list. + * @param onOpenFollowing navigate to the current user's following list. + * @param onOpenRequests navigate to the pending follow-requests screen. + * @param onOpenNotifications navigate to the notifications module. + * @param onOpenOrganizations navigate to the organizations module. + * @param onOpenIntegrations navigate to the integrations module. + * @param onSignOut invoked after the caller performs sign-out; the profile module does + * not own session state, so the app wires this to the auth logout + navigation. */ @Composable fun ProfileRoute( onEditProfile: () -> Unit, onSearchUsers: () -> Unit, + onOpenFollowers: (String) -> Unit, + onOpenFollowing: (String) -> Unit, + onOpenRequests: () -> Unit, + onOpenNotifications: () -> Unit, + onOpenOrganizations: () -> Unit, + onOpenIntegrations: () -> Unit, onSignOut: () -> Unit, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + // The hub's own follower/following lists are keyed on the signed-in user's + // username, which the loaded profile carries; ignore taps until it's loaded. ProfileScreen( state = state, onEditProfile = onEditProfile, onSearchUsers = onSearchUsers, + onOpenFollowers = { state.user?.username?.let(onOpenFollowers) }, + onOpenFollowing = { state.user?.username?.let(onOpenFollowing) }, + onOpenRequests = onOpenRequests, + onOpenNotifications = onOpenNotifications, + onOpenOrganizations = onOpenOrganizations, + onOpenIntegrations = onOpenIntegrations, onSignOut = onSignOut, onRetry = viewModel::refresh, modifier = modifier, ) } -/** Stateless "Account" UI — easy to preview and to drive from Compose tests. */ +/** Stateless "Account" hub UI — easy to preview and to drive from Compose tests. */ @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun ProfileScreen( state: ProfileUiState, onEditProfile: () -> Unit, onSearchUsers: () -> Unit, + onOpenFollowers: () -> Unit, + onOpenFollowing: () -> Unit, + onOpenRequests: () -> Unit, + onOpenNotifications: () -> Unit, + onOpenOrganizations: () -> Unit, + onOpenIntegrations: () -> Unit, onSignOut: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text("Account") }, - actions = { - IconButton(onClick = onSearchUsers, modifier = Modifier.testTag(ProfileTestTags.SEARCH)) { - Icon(Icons.Default.Search, contentDescription = "Search users") - } - IconButton(onClick = onEditProfile, modifier = Modifier.testTag(ProfileTestTags.EDIT)) { - Icon(Icons.Default.Edit, contentDescription = "Edit profile") - } - }, - ) - }, + topBar = { TopAppBar(title = { Text("Account") }) }, ) { padding -> when { state.user != null -> Column( - Modifier.fillMaxSize().padding(padding), - horizontalAlignment = Alignment.CenterHorizontally, + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), ) { - ProfileContent(user = state.user, modifier = Modifier.weight(1f, fill = false)) + // Profile header with tappable follower/following counts. + ProfileContent( + user = state.user, + counts = state.followCounts, + onOpenFollowers = onOpenFollowers, + onOpenFollowing = onOpenFollowing, + // followStatus defaults to SELF here, so no follow button renders. + ) if (state.errorMessage != null) { Text( @@ -107,16 +158,63 @@ fun ProfileScreen( ) } - Spacer(Modifier.height(24.dp)) - OutlinedButton( + HorizontalDivider() + + AccountMenuRow( + icon = Icons.Default.Group, + label = "Followers", + onClick = onOpenFollowers, + tag = AccountMenuTestTags.FOLLOWERS, + ) + AccountMenuRow( + icon = Icons.Default.Group, + label = "Following", + onClick = onOpenFollowing, + tag = AccountMenuTestTags.FOLLOWING, + ) + AccountMenuRow( + icon = Icons.Default.PersonAdd, + label = "Follow requests", + onClick = onOpenRequests, + tag = AccountMenuTestTags.REQUESTS, + ) + AccountMenuRow( + icon = Icons.Default.Notifications, + label = "Notifications", + onClick = onOpenNotifications, + tag = AccountMenuTestTags.NOTIFICATIONS, + ) + AccountMenuRow( + icon = Icons.Default.Business, + label = "Organizations", + onClick = onOpenOrganizations, + tag = AccountMenuTestTags.ORGANIZATIONS, + ) + AccountMenuRow( + icon = Icons.Default.Extension, + label = "Integrations", + onClick = onOpenIntegrations, + tag = AccountMenuTestTags.INTEGRATIONS, + ) + AccountMenuRow( + icon = Icons.Default.Edit, + label = "Edit profile", + onClick = onEditProfile, + tag = AccountMenuTestTags.EDIT_PROFILE, + ) + AccountMenuRow( + icon = Icons.Default.Search, + label = "Search users", + onClick = onSearchUsers, + tag = AccountMenuTestTags.SEARCH_USERS, + ) + AccountMenuRow( + icon = Icons.AutoMirrored.Filled.Logout, + label = "Sign out", onClick = onSignOut, - modifier = Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth() - .testTag(ProfileTestTags.SIGN_OUT), - ) { - Text("Sign out") - } + tag = ProfileTestTags.SIGN_OUT, + ) + Spacer(Modifier.height(24.dp)) } @@ -139,13 +237,45 @@ fun ProfileScreen( modifier = Modifier.testTag(ProfileTestTags.ERROR), ) Spacer(Modifier.height(16.dp)) - Button(onClick = onRetry) { Text("Retry") } + androidx.compose.material3.Button(onClick = onRetry) { Text("Retry") } } } } } } +/** A single tappable row in the Account hub's menu. */ +@Composable +private fun AccountMenuRow( + icon: ImageVector, + label: String, + onClick: () -> Unit, + tag: String, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(tag) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(16.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + @Preview(showBackground = true) @Composable private fun ProfileScreenPreview() { @@ -162,9 +292,16 @@ private fun ProfileScreenPreview() { isCurrentUser = true, ), isLoading = false, + followCounts = FollowCounts(followers = 128, following = 87), ), onEditProfile = {}, onSearchUsers = {}, + onOpenFollowers = {}, + onOpenFollowing = {}, + onOpenRequests = {}, + onOpenNotifications = {}, + onOpenOrganizations = {}, + onOpenIntegrations = {}, onSignOut = {}, onRetry = {}, ) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt index dad7020..9b56882 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -14,14 +16,27 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -/** UI state for a profile screen (current user or another user). */ +/** + * UI state for a profile screen (current user or another user). + * + * The follow fields ([followStatus], [followCounts], [isFollowActionInProgress]) are + * populated only for another user's profile; on the account screen [followStatus] is + * [FollowStatus.SELF]. [followCounts] doubles as the tappable follower/following + * tallies on both screens. + */ data class ProfileUiState( val user: ProfileUser? = null, val isLoading: Boolean = true, val errorMessage: String? = null, + val followStatus: FollowStatus = FollowStatus.SELF, + val followCounts: FollowCounts = FollowCounts(), + val isFollowActionInProgress: Boolean = false, ) { /** No cached user and not loading — nothing to render yet. */ val isEmpty: Boolean get() = user == null && !isLoading + + /** Whether a follow/unfollow affordance should be shown (another user, status known). */ + val canFollow: Boolean get() = followStatus != FollowStatus.SELF } /** @@ -46,6 +61,7 @@ class ProfileViewModel @Inject constructor( repository.observeCurrentUser().collect { cached -> if (cached != null) { _uiState.update { it.copy(user = cached) } + loadCounts(cached.id) } } } @@ -55,8 +71,9 @@ class ProfileViewModel @Inject constructor( _uiState.update { it.copy(isLoading = it.user == null, errorMessage = null) } viewModelScope.launch { when (val result = repository.refreshCurrentUser()) { - is ApiResult.Success -> _uiState.update { - it.copy(user = result.data, isLoading = false) + is ApiResult.Success -> { + _uiState.update { it.copy(user = result.data, isLoading = false) } + loadCounts(result.data.id) } is ApiResult.Failure -> _uiState.update { it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) @@ -65,5 +82,15 @@ class ProfileViewModel @Inject constructor( } } + /** Loads the current user's own follower/following tallies for the tappable header. */ + private fun loadCounts(userId: String) { + viewModelScope.launch { + val result = repository.getFollowCounts(userId) + if (result is ApiResult.Success) { + _uiState.update { it.copy(followCounts = result.data) } + } + } + } + fun clearError() = _uiState.update { it.copy(errorMessage = null) } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt index 1d7f140..db62dd5 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt @@ -27,18 +27,25 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.ProfileUser /** * Another user's public profile, reached by drilling down from search (route * `profile/{username}`). Includes a back affordance to ascend, mirroring the app's - * drill-down navigation pattern. + * drill-down navigation pattern, plus a follow/unfollow button and tappable + * follower/following counts. * * @param onBack pop back to the previous screen (search). + * @param onOpenFollowers open this user's followers list (`followers/{username}`). + * @param onOpenFollowing open this user's following list (`following/{username}`). */ @Composable fun UserProfileRoute( onBack: () -> Unit, + onOpenFollowers: (String) -> Unit, + onOpenFollowing: (String) -> Unit, modifier: Modifier = Modifier, viewModel: UserProfileViewModel = hiltViewModel(), ) { @@ -47,6 +54,9 @@ fun UserProfileRoute( state = state, onBack = onBack, onRetry = viewModel::refresh, + onToggleFollow = viewModel::toggleFollow, + onOpenFollowers = { state.user?.username?.let(onOpenFollowers) }, + onOpenFollowing = { state.user?.username?.let(onOpenFollowing) }, modifier = modifier, ) } @@ -58,6 +68,9 @@ fun UserProfileScreen( state: ProfileUiState, onBack: () -> Unit, onRetry: () -> Unit, + onToggleFollow: () -> Unit = {}, + onOpenFollowers: () -> Unit = {}, + onOpenFollowing: () -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -77,6 +90,12 @@ fun UserProfileScreen( state.user != null -> ProfileContent( user = state.user, modifier = Modifier.fillMaxSize().padding(padding), + counts = state.followCounts, + onOpenFollowers = onOpenFollowers, + onOpenFollowing = onOpenFollowing, + followStatus = state.followStatus, + isFollowActionInProgress = state.isFollowActionInProgress, + onToggleFollow = onToggleFollow, ) state.isLoading -> Box( @@ -121,6 +140,8 @@ private fun UserProfileScreenPreview() { isCurrentUser = false, ), isLoading = false, + followStatus = FollowStatus.NOT_FOLLOWING, + followCounts = FollowCounts(followers = 128, following = 87), ), onBack = {}, onRetry = {}, diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt index bb0f3e1..f98bdcf 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -55,8 +56,9 @@ class UserProfileViewModel @Inject constructor( _uiState.update { it.copy(isLoading = it.user == null, errorMessage = null) } viewModelScope.launch { when (val result = repository.refreshUser(username)) { - is ApiResult.Success -> _uiState.update { - it.copy(user = result.data, isLoading = false) + is ApiResult.Success -> { + _uiState.update { it.copy(user = result.data, isLoading = false) } + loadFollow(result.data.id, result.data.isCurrentUser) } is ApiResult.Failure -> _uiState.update { it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) @@ -65,5 +67,69 @@ class UserProfileViewModel @Inject constructor( } } + /** Loads the follow status and counts for the viewed user once its id is known. */ + private fun loadFollow(userId: String, isCurrentUser: Boolean) { + viewModelScope.launch { + val status = if (isCurrentUser) { + FollowStatus.SELF + } else { + when (val result = repository.getFollowStatus(userId)) { + is ApiResult.Success -> result.data + is ApiResult.Failure -> FollowStatus.NOT_FOLLOWING + } + } + _uiState.update { it.copy(followStatus = status) } + } + viewModelScope.launch { + val result = repository.getFollowCounts(userId) + if (result is ApiResult.Success) { + _uiState.update { it.copy(followCounts = result.data) } + } + } + } + + /** Toggles the follow relationship, optimistically flipping the button state. */ + fun toggleFollow() { + val state = _uiState.value + val user = state.user ?: return + if (state.isFollowActionInProgress || state.followStatus == FollowStatus.SELF) return + + val wasFollowing = state.followStatus == FollowStatus.FOLLOWING || + state.followStatus == FollowStatus.REQUESTED + _uiState.update { it.copy(isFollowActionInProgress = true, errorMessage = null) } + viewModelScope.launch { + val result = if (wasFollowing) { + repository.unfollowUser(user.id) + } else { + repository.followUser(user.id) + } + when (result) { + is ApiResult.Success -> { + // Re-read the authoritative status (private accounts land on REQUESTED, + // not FOLLOWING) and refresh the follower tally. + val newStatus = when (val s = repository.getFollowStatus(user.id)) { + is ApiResult.Success -> s.data + is ApiResult.Failure -> + if (wasFollowing) FollowStatus.NOT_FOLLOWING else FollowStatus.FOLLOWING + } + _uiState.update { it.copy(followStatus = newStatus, isFollowActionInProgress = false) } + refreshCounts(user.id) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isFollowActionInProgress = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + private fun refreshCounts(userId: String) { + viewModelScope.launch { + val result = repository.getFollowCounts(userId) + if (result is ApiResult.Success) { + _uiState.update { it.copy(followCounts = result.data) } + } + } + } + fun clearError() = _uiState.update { it.copy(errorMessage = null) } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt index 917003a..7e62804 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.core.model.CustomerStatus import com.interlinedlist.android.feature.profile.data.remote.ProfileApi +import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -256,4 +257,215 @@ class DefaultProfileRepositoryTest { assertThat(result).isInstanceOf(ApiResult.Success::class.java) assertThat((result as ApiResult.Success).data.single().username).isEqualTo("ada") } + + // --- Following --- + + @Test + fun `followUser posts to the follow endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.followUser("u2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/follow/u2") + } + + @Test + fun `unfollowUser deletes on the follow endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200)) + + val result = repository.unfollowUser("u2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/follow/u2") + } + + @Test + fun `getFollowStatus maps an explicit following status`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "status": "following" }""")) + + val result = repository.getFollowStatus("u2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEqualTo(FollowStatus.FOLLOWING) + assertThat(server.takeRequest().path).isEqualTo("/api/follow/u2/status") + } + + @Test + fun `getFollowStatus reads boolean flags into a requested status`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "isFollowing": false, "requested": true }""")) + + val result = repository.getFollowStatus("u2") + + assertThat((result as ApiResult.Success).data).isEqualTo(FollowStatus.REQUESTED) + } + + @Test + fun `getFollowStatus falls back to not-following when nothing is set`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.getFollowStatus("u2") + + assertThat((result as ApiResult.Success).data).isEqualTo(FollowStatus.NOT_FOLLOWING) + } + + @Test + fun `getFollowCounts maps follower and following tallies`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "followers": 12, "following": 34 }""")) + + val result = repository.getFollowCounts("u2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val counts = (result as ApiResult.Success).data + assertThat(counts.followers).isEqualTo(12) + assertThat(counts.following).isEqualTo(34) + assertThat(server.takeRequest().path).isEqualTo("/api/follow/u2/counts") + } + + @Test + fun `getFollowCounts reads the count-suffixed aliases`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{ "followersCount": 5, "followingCount": 8 }"""), + ) + + val counts = (repository.getFollowCounts("u2") as ApiResult.Success).data + + assertThat(counts.followers).isEqualTo(5) + assertThat(counts.following).isEqualTo(8) + } + + @Test + fun `getFollowers resolves the username via a cached id then lists followers`() = runTest(testDispatcher) { + // Seed the cache so no username-lookup round-trip is needed. + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{ "user": { "id": "u2", "username": "ada" } }"""), + ) + repository.refreshUser("ada") + server.takeRequest() // consume the /api/users/ada request + + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "users": [ { "id": "f1", "username": "bob", "displayName": "Bob" } ] }""", + ), + ) + + val result = repository.getFollowers("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.single().username).isEqualTo("bob") + // Cached id means the followers request is the only one left — no re-lookup. + val recorded = server.takeRequest() + assertThat(recorded.path).startsWith("/api/follow/u2/followers") + } + + @Test + fun `getFollowers resolves the id via a username lookup when uncached`() = runTest(testDispatcher) { + // First request: resolve username -> id. + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{ "user": { "id": "u9", "username": "ada" } }"""), + ) + // Second request: the followers list keyed on the resolved id. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "followers": [ { "id": "f2", "username": "cara" } ] }""", + ), + ) + + val result = repository.getFollowers("ada") + + assertThat((result as ApiResult.Success).data.single().username).isEqualTo("cara") + assertThat(server.takeRequest().path).isEqualTo("/api/users/ada") + assertThat(server.takeRequest().path).startsWith("/api/follow/u9/followers") + } + + @Test + fun `getFollowing lists the following users`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody("""{ "user": { "id": "u2", "username": "ada" } }"""), + ) + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "following": [ { "id": "g1", "username": "dan" } ] }""", + ), + ) + + val result = repository.getFollowing("ada") + + assertThat((result as ApiResult.Success).data.single().username).isEqualTo("dan") + server.takeRequest() + assertThat(server.takeRequest().path).startsWith("/api/follow/u2/following") + } + + @Test + fun `getFollowRequests maps nested and inlined requesters`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "requests": [ + { "user": { "id": "r1", "username": "eve", "displayName": "Eve" } }, + { "id": "r2", "username": "frank" } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getFollowRequests() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val users = (result as ApiResult.Success).data + assertThat(users.map { it.username }).containsExactly("eve", "frank").inOrder() + assertThat(server.takeRequest().path).isEqualTo("/api/follow/requests") + } + + @Test + fun `approveFollowRequest posts to the approve endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.approveFollowRequest("r1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/follow/r1/approve") + } + + @Test + fun `rejectFollowRequest posts to the reject endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.rejectFollowRequest("r1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/follow/r1/reject") + } + + @Test + fun `removeFollower deletes on the remove endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200)) + + val result = repository.removeFollower("f1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/follow/f1/remove") + } + + @Test + fun `followUser maps a 404 to NotFound`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "No such user" }""")) + + val result = repository.followUser("ghost") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FollowMappersTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FollowMappersTest.kt new file mode 100644 index 0000000..b2accaf --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/FollowMappersTest.kt @@ -0,0 +1,77 @@ +package com.interlinedlist.android.feature.profile.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.profile.data.mapper.toFollowCounts +import com.interlinedlist.android.feature.profile.data.mapper.toFollowStatus +import com.interlinedlist.android.feature.profile.data.mapper.toFollowUser +import com.interlinedlist.android.feature.profile.data.mapper.toFollowUserOrNull +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowCountsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowRequestDto +import com.interlinedlist.android.feature.profile.data.remote.dto.FollowStatusResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.domain.FollowStatus +import org.junit.Test + +class FollowMappersTest { + + @Test + fun `status string tokens map to the domain status`() { + assertThat(FollowStatusResponse(status = "following").toFollowStatus()) + .isEqualTo(FollowStatus.FOLLOWING) + assertThat(FollowStatusResponse(status = "requested").toFollowStatus()) + .isEqualTo(FollowStatus.REQUESTED) + assertThat(FollowStatusResponse(status = "pending").toFollowStatus()) + .isEqualTo(FollowStatus.REQUESTED) + assertThat(FollowStatusResponse(status = "none").toFollowStatus()) + .isEqualTo(FollowStatus.NOT_FOLLOWING) + assertThat(FollowStatusResponse(status = "not_following").toFollowStatus()) + .isEqualTo(FollowStatus.NOT_FOLLOWING) + } + + @Test + fun `status booleans resolve when no explicit string is present`() { + assertThat(FollowStatusResponse(isFollowing = true).toFollowStatus()) + .isEqualTo(FollowStatus.FOLLOWING) + assertThat(FollowStatusResponse(pending = true).toFollowStatus()) + .isEqualTo(FollowStatus.REQUESTED) + assertThat(FollowStatusResponse().toFollowStatus()) + .isEqualTo(FollowStatus.NOT_FOLLOWING) + } + + @Test + fun `counts prefer the plain fields but fall back to the suffixed aliases`() { + assertThat(FollowCountsResponse(followers = 3, following = 4).toFollowCounts().followers).isEqualTo(3) + val aliased = FollowCountsResponse(followersCount = 9, followingCount = 2).toFollowCounts() + assertThat(aliased.followers).isEqualTo(9) + assertThat(aliased.following).isEqualTo(2) + assertThat(FollowCountsResponse().toFollowCounts().followers).isEqualTo(0) + } + + @Test + fun `follow user maps to a lightweight entry with a display label`() { + val user = ProfileUserDto(id = "1", username = "ada", displayName = "Ada", avatar = "u").toFollowUser() + assertThat(user.id).isEqualTo("1") + assertThat(user.username).isEqualTo("ada") + assertThat(user.avatarUrl).isEqualTo("u") + assertThat(user.displayLabel).isEqualTo("Ada") + } + + @Test + fun `request dto resolves a nested requester`() { + val dto = FollowRequestDto(user = ProfileUserDto(id = "r1", username = "eve")) + assertThat(dto.toFollowUserOrNull()?.username).isEqualTo("eve") + } + + @Test + fun `request dto resolves an inlined requester`() { + val dto = FollowRequestDto(id = "r2", username = "frank", displayName = "Frank") + val user = dto.toFollowUserOrNull() + assertThat(user?.id).isEqualTo("r2") + assertThat(user?.displayLabel).isEqualTo("Frank") + } + + @Test + fun `request dto with no user is dropped`() { + assertThat(FollowRequestDto().toFollowUserOrNull()).isNull() + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt index 8da0f56..424c34c 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt @@ -4,6 +4,9 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.core.model.CustomerStatus import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.MutableStateFlow @@ -65,8 +68,94 @@ class FakeProfileRepository : ProfileRepository { lastSearchQuery = query return searchResult } + + // --- Following --- + + var followStatusResult: ApiResult = ApiResult.Success(FollowStatus.NOT_FOLLOWING) + var followCountsResult: ApiResult = ApiResult.Success(FollowCounts()) + var followResult: ApiResult = ApiResult.Success(Unit) + var unfollowResult: ApiResult = ApiResult.Success(Unit) + var followersResult: ApiResult> = ApiResult.Success(emptyList()) + var followingResult: ApiResult> = ApiResult.Success(emptyList()) + var followRequestsResult: ApiResult> = ApiResult.Success(emptyList()) + var approveResult: ApiResult = ApiResult.Success(Unit) + var rejectResult: ApiResult = ApiResult.Success(Unit) + var removeFollowerResult: ApiResult = ApiResult.Success(Unit) + + var followStatusUserId: String? = null + var followCountsUserId: String? = null + var followedUserId: String? = null + var unfollowedUserId: String? = null + var followersUsername: String? = null + var followingUsername: String? = null + var approvedUserId: String? = null + var rejectedUserId: String? = null + var removedFollowerUserId: String? = null + var followCount = 0 + var unfollowCount = 0 + var followRequestsCount = 0 + + override suspend fun getFollowStatus(userId: String): ApiResult { + followStatusUserId = userId + return followStatusResult + } + + override suspend fun getFollowCounts(userId: String): ApiResult { + followCountsUserId = userId + return followCountsResult + } + + override suspend fun followUser(userId: String): ApiResult { + followedUserId = userId + followCount++ + return followResult + } + + override suspend fun unfollowUser(userId: String): ApiResult { + unfollowedUserId = userId + unfollowCount++ + return unfollowResult + } + + override suspend fun getFollowers(username: String): ApiResult> { + followersUsername = username + return followersResult + } + + override suspend fun getFollowing(username: String): ApiResult> { + followingUsername = username + return followingResult + } + + override suspend fun getFollowRequests(): ApiResult> { + followRequestsCount++ + return followRequestsResult + } + + override suspend fun approveFollowRequest(userId: String): ApiResult { + approvedUserId = userId + return approveResult + } + + override suspend fun rejectFollowRequest(userId: String): ApiResult { + rejectedUserId = userId + return rejectResult + } + + override suspend fun removeFollower(userId: String): ApiResult { + removedFollowerUserId = userId + return removeFollowerResult + } } +/** Shorthand for building a follow-list/request user in tests. */ +fun testFollowUser( + id: String = "f1", + username: String = "ada", + displayName: String? = "Ada Lovelace", + avatarUrl: String? = null, +) = FollowUser(id = id, username = username, displayName = displayName, avatarUrl = avatarUrl) + /** Shorthand for building a domain profile user in tests. */ fun testUser( id: String = "u1", diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowListViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowListViewModelTest.kt new file mode 100644 index 0000000..879152e --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowListViewModelTest.kt @@ -0,0 +1,95 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.follow.FOLLOW_USERNAME_ARG +import com.interlinedlist.android.feature.profile.ui.follow.FollowersViewModel +import com.interlinedlist.android.feature.profile.ui.follow.FollowingViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FollowListViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun followers(username: String) = + FollowersViewModel(repo, SavedStateHandle(mapOf(FOLLOW_USERNAME_ARG to username))) + + private fun following(username: String) = + FollowingViewModel(repo, SavedStateHandle(mapOf(FOLLOW_USERNAME_ARG to username))) + + @Test + fun `followers vm loads the followers for the nav-arg username`() = runTest(dispatcher) { + repo.followersResult = ApiResult.Success(listOf(testFollowUser(id = "1", username = "bob"))) + + val vm = followers("ada") + advanceUntilIdle() + + assertThat(repo.followersUsername).isEqualTo("ada") + assertThat(vm.uiState.value.users.map { it.username }).containsExactly("bob") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `following vm loads the following for the nav-arg username`() = runTest(dispatcher) { + repo.followingResult = ApiResult.Success(listOf(testFollowUser(id = "2", username = "cara"))) + + val vm = following("ada") + advanceUntilIdle() + + assertThat(repo.followingUsername).isEqualTo("ada") + assertThat(vm.uiState.value.users.map { it.username }).containsExactly("cara") + } + + @Test + fun `an empty list flags isEmpty`() = runTest(dispatcher) { + repo.followersResult = ApiResult.Success(emptyList()) + + val vm = followers("ada") + advanceUntilIdle() + + assertThat(vm.uiState.value.isEmpty).isTrue() + } + + @Test + fun `a failure surfaces a mapped error`() = runTest(dispatcher) { + repo.followersResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = followers("ada") + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `missing nav arg fails fast`() { + try { + FollowersViewModel(repo, SavedStateHandle()) + throw AssertionError("Expected IllegalStateException for missing nav arg") + } catch (e: IllegalStateException) { + assertThat(e).hasMessageThat().contains(FOLLOW_USERNAME_ARG) + } + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowRequestsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowRequestsViewModelTest.kt new file mode 100644 index 0000000..032ef3b --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FollowRequestsViewModelTest.kt @@ -0,0 +1,110 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FollowRequestsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads pending requests`() = runTest(dispatcher) { + repo.followRequestsResult = ApiResult.Success( + listOf(testFollowUser(id = "1", username = "eve"), testFollowUser(id = "2", username = "frank")), + ) + + val vm = FollowRequestsViewModel(repo) + advanceUntilIdle() + + assertThat(repo.followRequestsCount).isEqualTo(1) + assertThat(vm.uiState.value.requests.map { it.username }).containsExactly("eve", "frank").inOrder() + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `no requests flags isEmpty`() = runTest(dispatcher) { + repo.followRequestsResult = ApiResult.Success(emptyList()) + + val vm = FollowRequestsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isEmpty).isTrue() + } + + @Test + fun `approve removes the request row and calls the repository`() = runTest(dispatcher) { + repo.followRequestsResult = ApiResult.Success( + listOf(testFollowUser(id = "1", username = "eve"), testFollowUser(id = "2", username = "frank")), + ) + val vm = FollowRequestsViewModel(repo) + advanceUntilIdle() + + vm.approve("1") + advanceUntilIdle() + + assertThat(repo.approvedUserId).isEqualTo("1") + assertThat(vm.uiState.value.requests.map { it.id }).containsExactly("2") + assertThat(vm.uiState.value.pendingActionIds).isEmpty() + } + + @Test + fun `reject removes the request row and calls the repository`() = runTest(dispatcher) { + repo.followRequestsResult = ApiResult.Success(listOf(testFollowUser(id = "1", username = "eve"))) + val vm = FollowRequestsViewModel(repo) + advanceUntilIdle() + + vm.reject("1") + advanceUntilIdle() + + assertThat(repo.rejectedUserId).isEqualTo("1") + assertThat(vm.uiState.value.requests).isEmpty() + } + + @Test + fun `a failed action keeps the row and surfaces an error`() = runTest(dispatcher) { + repo.followRequestsResult = ApiResult.Success(listOf(testFollowUser(id = "1", username = "eve"))) + repo.approveResult = ApiResult.Failure(AppError.Server("boom")) + val vm = FollowRequestsViewModel(repo) + advanceUntilIdle() + + vm.approve("1") + advanceUntilIdle() + + assertThat(vm.uiState.value.requests.map { it.id }).containsExactly("1") + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.pendingActionIds).isEmpty() + } + + @Test + fun `a load failure surfaces a mapped error`() = runTest(dispatcher) { + repo.followRequestsResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = FollowRequestsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt index c375b1e..90b3b26 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileViewModelTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.ui.profile.ProfileViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -68,6 +69,20 @@ class ProfileViewModelTest { assertThat(vm.uiState.value.errorMessage).isNotNull() } + @Test + fun `loads the current user's own follower and following counts`() = runTest(dispatcher) { + val user = testUser(id = "me", username = "adron") + repo.refreshCurrentUserResult = ApiResult.Success(user) + repo.followCountsResult = ApiResult.Success(FollowCounts(followers = 42, following = 17)) + + val vm = ProfileViewModel(repo) + advanceUntilIdle() + + assertThat(repo.followCountsUserId).isEqualTo("me") + assertThat(vm.uiState.value.followCounts.followers).isEqualTo(42) + assertThat(vm.uiState.value.followCounts.following).isEqualTo(17) + } + @Test fun `state updates via Turbine when the cached user changes`() = runTest(dispatcher) { repo.refreshCurrentUserResult = ApiResult.Success(testUser()) diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt index 9a5cd1a..86e832e 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileViewModelTest.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.ui.profile.PROFILE_USERNAME_ARG import com.interlinedlist.android.feature.profile.ui.profile.UserProfileViewModel import kotlinx.coroutines.Dispatchers @@ -69,4 +71,102 @@ class UserProfileViewModelTest { assertThat(e).hasMessageThat().contains(PROFILE_USERNAME_ARG) } } + + @Test + fun `loads follow status and counts for another user`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.followStatusResult = ApiResult.Success(FollowStatus.NOT_FOLLOWING) + repo.followCountsResult = ApiResult.Success(FollowCounts(followers = 5, following = 3)) + + val vm = viewModel("ada") + advanceUntilIdle() + + assertThat(repo.followStatusUserId).isEqualTo("u2") + assertThat(repo.followCountsUserId).isEqualTo("u2") + assertThat(vm.uiState.value.followStatus).isEqualTo(FollowStatus.NOT_FOLLOWING) + assertThat(vm.uiState.value.followCounts.followers).isEqualTo(5) + assertThat(vm.uiState.value.canFollow).isTrue() + } + + @Test + fun `viewing your own profile marks the status as SELF`() = runTest(dispatcher) { + val me = testUser(id = "me", username = "adron", isCurrentUser = true) + repo.refreshUserResult = ApiResult.Success(me) + + val vm = viewModel("adron") + advanceUntilIdle() + + assertThat(vm.uiState.value.followStatus).isEqualTo(FollowStatus.SELF) + assertThat(vm.uiState.value.canFollow).isFalse() + // No status call is made for your own profile. + assertThat(repo.followStatusUserId).isNull() + } + + @Test + fun `toggle follow follows a not-followed user and re-reads the status`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.followStatusResult = ApiResult.Success(FollowStatus.NOT_FOLLOWING) + val vm = viewModel("ada") + advanceUntilIdle() + + // After following, the status endpoint reports FOLLOWING. + repo.followStatusResult = ApiResult.Success(FollowStatus.FOLLOWING) + vm.toggleFollow() + advanceUntilIdle() + + assertThat(repo.followCount).isEqualTo(1) + assertThat(repo.followedUserId).isEqualTo("u2") + assertThat(vm.uiState.value.followStatus).isEqualTo(FollowStatus.FOLLOWING) + assertThat(vm.uiState.value.isFollowActionInProgress).isFalse() + } + + @Test + fun `toggle follow unfollows a followed user`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.followStatusResult = ApiResult.Success(FollowStatus.FOLLOWING) + val vm = viewModel("ada") + advanceUntilIdle() + + repo.followStatusResult = ApiResult.Success(FollowStatus.NOT_FOLLOWING) + vm.toggleFollow() + advanceUntilIdle() + + assertThat(repo.unfollowCount).isEqualTo(1) + assertThat(repo.unfollowedUserId).isEqualTo("u2") + assertThat(vm.uiState.value.followStatus).isEqualTo(FollowStatus.NOT_FOLLOWING) + } + + @Test + fun `toggle follow surfaces an error and leaves the status unchanged`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.followStatusResult = ApiResult.Success(FollowStatus.NOT_FOLLOWING) + repo.followResult = ApiResult.Failure(AppError.Server("boom")) + val vm = viewModel("ada") + advanceUntilIdle() + + vm.toggleFollow() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.followStatus).isEqualTo(FollowStatus.NOT_FOLLOWING) + assertThat(vm.uiState.value.isFollowActionInProgress).isFalse() + } + + @Test + fun `toggle follow is a no-op on your own profile`() = runTest(dispatcher) { + val me = testUser(id = "me", username = "adron", isCurrentUser = true) + repo.refreshUserResult = ApiResult.Success(me) + val vm = viewModel("adron") + advanceUntilIdle() + + vm.toggleFollow() + advanceUntilIdle() + + assertThat(repo.followCount).isEqualTo(0) + assertThat(repo.unfollowCount).isEqualTo(0) + } } From 7068e6f953728178dc4e9e697f2d58cbec1eacdd Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sat, 18 Jul 2026 14:39:12 -0700 Subject: [PATCH 09/25] fix(profile): don't nest verticalScroll in the Account hub ProfileContent owned a verticalScroll while the Account hub also wrapped it in a scrolling Column, throwing IllegalStateException (infinite-height measure) at render. ProfileContent is now a plain content block; the hub scrolls header+menu together, and UserProfileScreen supplies its own scroll. Verified live on emulator. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/feature/profile/ui/profile/ProfileContent.kt | 4 +++- .../feature/profile/ui/profile/UserProfileScreen.kt | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt index d145e94..6133a58 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileContent.kt @@ -70,10 +70,12 @@ fun ProfileContent( isFollowActionInProgress: Boolean = false, onToggleFollow: () -> Unit = {}, ) { + // A content block, not a scroll container: the caller owns scrolling so the + // Account hub can scroll this header together with its menu rows (nesting + // two verticalScroll containers throws an infinite-height measure error). Column( modifier = modifier .fillMaxWidth() - .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt index db62dd5..91df18f 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -89,7 +91,10 @@ fun UserProfileScreen( when { state.user != null -> ProfileContent( user = state.user, - modifier = Modifier.fillMaxSize().padding(padding), + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()), counts = state.followCounts, onOpenFollowers = onOpenFollowers, onOpenFollowing = onOpenFollowing, From c6d644e2bc8434057ff5f41cd07c9144106f530a Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 14:44:24 -0700 Subject: [PATCH 10/25] feat(notifications): add Notification Preferences settings screen (Milestone E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/PATCH /api/user/notification-preferences — per-event push/inApp/email toggles with optimistic update + rollback. Files integrated from the worktree build onto the real :feature:notifications module (its build.gradle, DI, and shared TestDoubles kept; only the new NotificationPreferences* files added). Nav wiring into the Account hub deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/NotificationPreferencesScreenTest.kt | 148 +++++++++ ...efaultNotificationPreferencesRepository.kt | 35 ++ .../data/NotificationPreferencesRepository.kt | 21 ++ .../data/remote/NotificationPreferencesApi.kt | 23 ++ .../remote/dto/NotificationPreferencesDto.kt | 66 ++++ .../di/NotificationPreferencesModule.kt | 38 +++ .../domain/NotificationPreference.kt | 51 +++ .../NotificationPreferencesErrorMessages.kt | 14 + .../ui/NotificationPreferencesScreen.kt | 304 ++++++++++++++++++ .../ui/NotificationPreferencesViewModel.kt | 98 ++++++ ...ltNotificationPreferencesRepositoryTest.kt | 187 +++++++++++ .../FakeNotificationPreferencesRepository.kt | 46 +++ .../NotificationPreferencesViewModelTest.kt | 196 +++++++++++ 13 files changed, 1227 insertions(+) create mode 100644 feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreenTest.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepository.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationPreferencesRepository.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationPreferencesApi.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationPreferencesDto.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationPreferencesModule.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/NotificationPreference.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesErrorMessages.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt create mode 100644 feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModel.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepositoryTest.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationPreferencesRepository.kt create mode 100644 feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModelTest.kt diff --git a/feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreenTest.kt b/feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreenTest.kt new file mode 100644 index 0000000..e5dc2f5 --- /dev/null +++ b/feature/notifications/src/androidTest/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreenTest.kt @@ -0,0 +1,148 @@ +package com.interlinedlist.android.feature.notifications.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NotificationPreferencesScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun preference( + key: String, + label: String, + description: String = "", + channels: Map, + ) = NotificationPreference(key = key, label = label, description = description, channels = channels) + + /** Hosts the stateless screen, applying toggles to a tiny in-memory state holder. */ + private fun setScreen( + initial: NotificationPreferencesUiState, + onBack: () -> Unit = {}, + onToggle: (String, NotificationChannel, Boolean) -> Unit = { _, _, _ -> }, + ) { + composeRule.setContent { + var state by mutableStateOf(initial) + InterlinedListTheme { + NotificationPreferencesScreen( + state = state, + onBack = onBack, + onRetry = {}, + onToggle = { key, channel, enabled -> + onToggle(key, channel, enabled) + // Reflect the toggle so the Switch flips on-screen, like the ViewModel would. + state = state.copy( + preferences = state.preferences.map { + if (it.key == key) it.withChannel(channel, enabled) else it + }, + ) + }, + ) + } + } + } + + private val sampleState = NotificationPreferencesUiState( + preferences = listOf( + preference( + key = "dig", + label = "Digs on your messages", + description = "When someone digs your message.", + channels = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.IN_APP to false, + ), + ), + preference( + key = "follow", + label = "New followers", + description = "When someone follows you.", + channels = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.EMAIL to true, + ), + ), + ), + ) + + @Test + fun events_areRendered_withLabelsAndDescriptions() { + setScreen(sampleState) + composeRule.onNodeWithTag(NotificationPreferencesTags.LIST).assertIsDisplayed() + composeRule.onNodeWithText("Digs on your messages").assertIsDisplayed() + composeRule.onNodeWithText("When someone digs your message.").assertIsDisplayed() + composeRule.onNodeWithText("New followers").assertIsDisplayed() + } + + @Test + fun onlyAvailableChannels_haveToggles() { + setScreen(sampleState) + // "dig" offers push + in-app, but NOT email. + composeRule.onNodeWithTag( + NotificationPreferencesTags.toggle("dig", NotificationChannel.PUSH), + ).assertIsDisplayed() + composeRule.onNodeWithTag( + NotificationPreferencesTags.toggle("dig", NotificationChannel.IN_APP), + ).assertIsDisplayed() + composeRule.onNodeWithTag( + NotificationPreferencesTags.toggle("dig", NotificationChannel.EMAIL), + ).assertDoesNotExist() + + // "follow" offers push + email, but NOT in-app. + composeRule.onNodeWithTag( + NotificationPreferencesTags.toggle("follow", NotificationChannel.EMAIL), + ).assertIsDisplayed() + composeRule.onNodeWithTag( + NotificationPreferencesTags.toggle("follow", NotificationChannel.IN_APP), + ).assertDoesNotExist() + } + + @Test + fun togglingAChannel_invokesCallback_andFlipsTheSwitch() { + var toggled: Triple? = null + setScreen(sampleState, onToggle = { k, c, e -> toggled = Triple(k, c, e) }) + + val digInApp = NotificationPreferencesTags.toggle("dig", NotificationChannel.IN_APP) + composeRule.onNodeWithTag(digInApp).assertIsOff() + composeRule.onNodeWithTag(digInApp).performClick() + + assert(toggled == Triple("dig", NotificationChannel.IN_APP, true)) + composeRule.onNodeWithTag(digInApp).assertIsOn() + } + + @Test + fun back_invokesOnBack() { + var backed = false + setScreen(sampleState, onBack = { backed = true }) + composeRule.onNodeWithTag(NotificationPreferencesTags.BACK).performClick() + assert(backed) + } + + @Test + fun emptyState_isShown_whenThereAreNoPreferences() { + setScreen(NotificationPreferencesUiState(preferences = emptyList())) + composeRule.onNodeWithTag(NotificationPreferencesTags.EMPTY).assertIsDisplayed() + } + + @Test + fun errorState_isShown_whenLoadFails() { + setScreen(NotificationPreferencesUiState(errorMessage = "No connection.")) + composeRule.onNodeWithTag(NotificationPreferencesTags.ERROR).assertIsDisplayed() + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepository.kt new file mode 100644 index 0000000..77d79f7 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepository.kt @@ -0,0 +1,35 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.notifications.data.remote.NotificationPreferencesApi +import com.interlinedlist.android.feature.notifications.data.remote.dto.toDomain +import com.interlinedlist.android.feature.notifications.data.remote.dto.toUpdateDto +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultNotificationPreferencesRepository @Inject constructor( + private val api: NotificationPreferencesApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : NotificationPreferencesRepository { + + override suspend fun getPreferences(): ApiResult> = + withContext(dispatchers.io) { + safeCall { api.getNotificationPreferences() }.map { it.toDomain() } + } + + override suspend fun updatePreference(preference: NotificationPreference): ApiResult = + withContext(dispatchers.io) { + safeCall { api.updateNotificationPreference(preference.toUpdateDto()) } + } + + // --- helpers ----------------------------------------------------------- + + private suspend fun safeCall(block: suspend () -> T): ApiResult = + safeApiCall(json, block) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationPreferencesRepository.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationPreferencesRepository.kt new file mode 100644 index 0000000..400e367 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/NotificationPreferencesRepository.kt @@ -0,0 +1,21 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference + +/** + * Access to the recipient's notification preferences (`/api/user/notification-preferences`). + * + * Preferences are a small, always-fresh settings surface, so — unlike the notification + * feed — this is a thin network wrapper with no local cache: [getPreferences] reads + * the current state and [updatePreference] applies one event's updated channels. The + * ViewModel owns the optimistic-update/rollback around [updatePreference]. + */ +interface NotificationPreferencesRepository { + + /** Fetches the current per-event preferences and their enabled channels. */ + suspend fun getPreferences(): ApiResult> + + /** Persists a single event's updated channel map. */ + suspend fun updatePreference(preference: NotificationPreference): ApiResult +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationPreferencesApi.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationPreferencesApi.kt new file mode 100644 index 0000000..aa0fd37 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/NotificationPreferencesApi.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.feature.notifications.data.remote + +import com.interlinedlist.android.feature.notifications.data.remote.dto.NotificationPreferenceUpdateDto +import com.interlinedlist.android.feature.notifications.data.remote.dto.NotificationPreferencesResponse +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH + +/** + * Retrofit description of the notification-preferences endpoints. Provided from the + * shared, already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), + * so every call here is authed. + */ +interface NotificationPreferencesApi { + + /** The recipient's per-event notification preferences and enabled channels. */ + @GET("api/user/notification-preferences") + suspend fun getNotificationPreferences(): NotificationPreferencesResponse + + /** Applies a single event's updated channels (OpenAPI body: `{ key, channels }`). */ + @PATCH("api/user/notification-preferences") + suspend fun updateNotificationPreference(@Body body: NotificationPreferenceUpdateDto) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationPreferencesDto.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationPreferencesDto.kt new file mode 100644 index 0000000..069ad7e --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/data/remote/dto/NotificationPreferencesDto.kt @@ -0,0 +1,66 @@ +package com.interlinedlist.android.feature.notifications.data.remote.dto + +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import kotlinx.serialization.Serializable + +/** + * Wire envelope for `GET /api/user/notification-preferences`: + * `{ "events": [ { key, label, description, channels: { push, inApp, email } }, ... ] }` + * (verified live 2026-07-31). + * + * Everything is defaulted and the shared [kotlinx.serialization.json.Json] is + * configured with `ignoreUnknownKeys`, so extra/renamed fields decode cleanly rather + * than throwing. [toDomain] is the single point of change if the shape shifts. + */ +@Serializable +data class NotificationPreferencesResponse( + val events: List = emptyList(), +) + +/** + * One preference event. CRITICAL: the [channels] map carries only the channel keys + * this event actually supports — the SET varies per event — so it is modelled as an + * open map rather than a fixed push/inApp/email triple. Unknown channel keys are + * dropped by [toDomain]. + */ +@Serializable +data class NotificationPreferenceDto( + val key: String = "", + val label: String? = null, + val description: String? = null, + /** Present channels only, each mapped to its current on/off state. */ + val channels: Map = emptyMap(), +) + +/** + * Body for `PATCH /api/user/notification-preferences` (OpenAPI: `{ key, channels }`). + * Sends the single event being changed plus its full channel map, so the server + * applies exactly the toggled state. + */ +@Serializable +data class NotificationPreferenceUpdateDto( + val key: String, + val channels: Map, +) + +/** Maps the wire event into the domain [NotificationPreference], dropping unknown channels. */ +fun NotificationPreferenceDto.toDomain(): NotificationPreference = NotificationPreference( + key = key, + label = label?.takeIf { it.isNotBlank() } ?: key, + description = description.orEmpty(), + channels = channels.mapNotNull { (rawKey, enabled) -> + NotificationChannel.fromWire(rawKey)?.let { it to enabled } + }.toMap(), +) + +/** Maps the whole response into an ordered list of domain preferences. */ +fun NotificationPreferencesResponse.toDomain(): List = + events.map { it.toDomain() } + +/** Builds the PATCH body for a single toggled [NotificationPreference]. */ +fun NotificationPreference.toUpdateDto(): NotificationPreferenceUpdateDto = + NotificationPreferenceUpdateDto( + key = key, + channels = channels.entries.associate { (channel, enabled) -> channel.wireKey to enabled }, + ) diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationPreferencesModule.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationPreferencesModule.kt new file mode 100644 index 0000000..4c8bf06 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/di/NotificationPreferencesModule.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.notifications.di + +import com.interlinedlist.android.feature.notifications.data.DefaultNotificationPreferencesRepository +import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository +import com.interlinedlist.android.feature.notifications.data.remote.NotificationPreferencesApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the notification-preferences repository interface to its implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationPreferencesRepositoryModule { + + @Binds + @Singleton + abstract fun bindNotificationPreferencesRepository( + impl: DefaultNotificationPreferencesRepository, + ): NotificationPreferencesRepository +} + +/** + * Provides the notification-preferences data layer: a Retrofit API built from the + * shared, authenticated [Retrofit] singleton (base URL + Bearer interceptor). + */ +@Module +@InstallIn(SingletonComponent::class) +object NotificationPreferencesDataModule { + + @Provides + @Singleton + fun provideNotificationPreferencesApi(retrofit: Retrofit): NotificationPreferencesApi = + retrofit.create(NotificationPreferencesApi::class.java) +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/NotificationPreference.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/NotificationPreference.kt new file mode 100644 index 0000000..9fa1489 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/domain/NotificationPreference.kt @@ -0,0 +1,51 @@ +package com.interlinedlist.android.feature.notifications.domain + +/** + * A single notification-preference event and the delivery channels the recipient + * has enabled for it. Normalised from `GET /api/user/notification-preferences` into + * a platform-independent domain type (see the DTO mapper). + * + * CRITICAL: the SET of available channels VARIES per event — some events support + * push + in-app, some push + email, some all three, and a few only one. Only the + * channels actually present on the event carry a meaningful on/off state; the UI + * must render a toggle only for [availableChannels] and never assume a fixed triple. + */ +data class NotificationPreference( + /** Stable event identifier (e.g. "dig", "follow", "mention"); the PATCH key. */ + val key: String, + /** Human-readable event name shown as the row title. */ + val label: String, + /** One-line explanation of when this event fires, shown under the label. */ + val description: String, + /** The delivery channels supported by this event, each with its current state. */ + val channels: Map, +) { + /** The channels this event actually offers, in a stable display order. */ + val availableChannels: List + get() = NotificationChannel.entries.filter { it in channels } + + /** Whether [channel] is currently enabled for this event (false when absent). */ + fun isEnabled(channel: NotificationChannel): Boolean = channels[channel] == true + + /** Returns a copy with [channel] flipped to [enabled]; no-op if unsupported. */ + fun withChannel(channel: NotificationChannel, enabled: Boolean): NotificationPreference = + if (channel !in channels) this + else copy(channels = channels + (channel to enabled)) +} + +/** + * A delivery channel a notification can be sent through. The wire keys are + * `push` / `inApp` / `email`; unknown keys are ignored by the mapper so new + * server channels never crash the screen (they simply don't render until modelled). + */ +enum class NotificationChannel(val wireKey: String) { + PUSH("push"), + IN_APP("inApp"), + EMAIL("email"); + + companion object { + /** Resolves a wire channel key to a [NotificationChannel], or null if unknown. */ + fun fromWire(key: String?): NotificationChannel? = + entries.firstOrNull { it.wireKey.equals(key?.trim(), ignoreCase = true) } + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesErrorMessages.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesErrorMessages.kt new file mode 100644 index 0000000..2b78940 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesErrorMessages.kt @@ -0,0 +1,14 @@ +package com.interlinedlist.android.feature.notifications.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the preferences UI. */ +fun AppError.toPreferencesMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> "Your session expired. Please sign in again." + is AppError.SubscriptionRequired -> message ?: "This setting requires an active subscription." + is AppError.NotFound -> "That preference is no longer available." + is AppError.RateLimited -> "Slow down a moment and try again." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Couldn't save your change. Please try again." +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt new file mode 100644 index 0000000..f52f2a5 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesScreen.kt @@ -0,0 +1,304 @@ +package com.interlinedlist.android.feature.notifications.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference + +/** Stable test tags for the notification-preferences screen. */ +object NotificationPreferencesTags { + const val LIST = "notificationPreferencesList" + const val EMPTY = "notificationPreferencesEmpty" + const val ERROR = "notificationPreferencesError" + const val PROGRESS = "notificationPreferencesProgress" + const val BACK = "notificationPreferencesBack" + + /** Tag for a single channel toggle, unique per event + channel. */ + fun toggle(key: String, channel: NotificationChannel): String = + "notificationPreferenceToggle_${key}_${channel.wireKey}" +} + +/** Human-readable channel label shown next to each toggle. */ +private fun NotificationChannel.displayLabel(): String = when (this) { + NotificationChannel.PUSH -> "Push" + NotificationChannel.IN_APP -> "In-app" + NotificationChannel.EMAIL -> "Email" +} + +/** + * Hilt-wired notification-preferences entry point. Reached from the Account hub as a + * drill-down; mirrors the back pattern used by the other detail screens. + * + * @param onBack pops the preferences screen off the back stack. + */ +@Composable +fun NotificationPreferencesRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: NotificationPreferencesViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + NotificationPreferencesScreen( + state = state, + onBack = onBack, + onRetry = viewModel::refresh, + onToggle = viewModel::onToggle, + modifier = modifier, + ) +} + +/** Stateless preferences UI — drives the list/empty/error/loading states from [state]. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotificationPreferencesScreen( + state: NotificationPreferencesUiState, + onBack: () -> Unit, + onRetry: () -> Unit, + onToggle: (key: String, channel: NotificationChannel, enabled: Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Notification preferences") }, + navigationIcon = { + IconButton( + onClick = onBack, + modifier = Modifier.testTag(NotificationPreferencesTags.BACK), + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Content( + state = state, + contentPadding = padding, + onRetry = onRetry, + onToggle = onToggle, + ) + } +} + +@Composable +private fun Content( + state: NotificationPreferencesUiState, + contentPadding: PaddingValues, + onRetry: () -> Unit, + onToggle: (key: String, channel: NotificationChannel, enabled: Boolean) -> Unit, +) { + Box( + Modifier + .fillMaxSize() + .padding(contentPadding), + ) { + when { + state.isEmpty && state.isLoading -> LoadingState() + state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRetry) + state.isEmpty -> EmptyState() + else -> PreferenceList(state = state, onToggle = onToggle) + } + } +} + +@Composable +private fun PreferenceList( + state: NotificationPreferencesUiState, + onToggle: (key: String, channel: NotificationChannel, enabled: Boolean) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(NotificationPreferencesTags.LIST), + ) { + items(state.preferences, key = { it.key }) { preference -> + PreferenceRow(preference = preference, onToggle = onToggle) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } +} + +/** + * A single event: its label + description, and one [Switch] per AVAILABLE channel. + * Only the channels the event actually offers are rendered — the set varies per event. + */ +@Composable +private fun PreferenceRow( + preference: NotificationPreference, + onToggle: (key: String, channel: NotificationChannel, enabled: Boolean) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + ) { + Text( + text = preference.label, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (preference.description.isNotBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = preference.description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + preference.availableChannels.forEach { channel -> + ChannelToggleRow( + eventKey = preference.key, + channel = channel, + enabled = preference.isEnabled(channel), + onToggle = onToggle, + ) + } + } +} + +@Composable +private fun ChannelToggleRow( + eventKey: String, + channel: NotificationChannel, + enabled: Boolean, + onToggle: (key: String, channel: NotificationChannel, enabled: Boolean) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = channel.displayLabel(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Switch( + checked = enabled, + onCheckedChange = { onToggle(eventKey, channel, it) }, + modifier = Modifier + .testTag(NotificationPreferencesTags.toggle(eventKey, channel)) + .semantics { contentDescription = "${channel.displayLabel()} for $eventKey" }, + ) + } +} + +@Composable +private fun LoadingState() { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(NotificationPreferencesTags.PROGRESS)) + } +} + +@Composable +private fun EmptyState() { + Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = "No notification preferences to configure.", + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(NotificationPreferencesTags.EMPTY), + ) + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(NotificationPreferencesTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } +} + +@Preview(showBackground = true) +@Composable +private fun NotificationPreferencesPreview() { + InterlinedListTheme { + NotificationPreferencesScreen( + state = NotificationPreferencesUiState( + preferences = listOf( + NotificationPreference( + key = "dig", + label = "Digs on your messages", + description = "When someone presses “I Dig!” on one of your messages.", + channels = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.IN_APP to true, + ), + ), + NotificationPreference( + key = "follow", + label = "New followers & follow requests", + description = "When someone follows you or requests to follow you.", + channels = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.EMAIL to false, + ), + ), + NotificationPreference( + key = "reply", + label = "Replies", + description = "When someone replies to your message.", + channels = mapOf(NotificationChannel.EMAIL to true), + ), + ), + ), + onBack = {}, + onRetry = {}, + onToggle = { _, _, _ -> }, + ) + } +} diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModel.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModel.kt new file mode 100644 index 0000000..a785424 --- /dev/null +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModel.kt @@ -0,0 +1,98 @@ +package com.interlinedlist.android.feature.notifications.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Notification-preferences screen state: the loaded per-event preferences plus + * transient loading/error flags. The event list itself is the source of truth here + * (there is no cache), so it lives directly in the state. + */ +data class NotificationPreferencesUiState( + val preferences: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, +) { + val isEmpty: Boolean get() = preferences.isEmpty() +} + +@HiltViewModel +class NotificationPreferencesViewModel @Inject constructor( + private val repository: NotificationPreferencesRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(NotificationPreferencesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + /** Loads (or reloads) the preferences from the API. */ + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getPreferences()) { + is ApiResult.Success -> _uiState.update { + it.copy(preferences = result.data, isLoading = false, errorMessage = null) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toPreferencesMessage()) + } + } + } + } + + /** + * Flips one [channel] of the event identified by [key] to [enabled], updating the + * UI optimistically and persisting via the repository. On failure the change is + * rolled back to its previous value and an error is surfaced. Toggling a channel + * the event does not support is a no-op. + */ + fun onToggle(key: String, channel: NotificationChannel, enabled: Boolean) { + val previous = _uiState.value.preferences.firstOrNull { it.key == key } ?: return + // The event doesn't offer this channel — nothing to change or send. + if (channel !in previous.channels) return + if (previous.isEnabled(channel) == enabled) return + + val updated = previous.withChannel(channel, enabled) + // Optimistic: reflect the new value immediately. + replacePreference(updated) + + viewModelScope.launch { + val result = repository.updatePreference(updated) + if (result is ApiResult.Failure) { + // Roll back to the pre-toggle value and surface the error. + replacePreference(previous) + surface(result.error) + } + } + } + + fun dismissError() = _uiState.update { it.copy(errorMessage = null) } + + private fun replacePreference(preference: NotificationPreference) { + _uiState.update { state -> + state.copy( + preferences = state.preferences.map { + if (it.key == preference.key) preference else it + }, + ) + } + } + + private fun surface(error: AppError) = + _uiState.update { it.copy(errorMessage = error.toPreferencesMessage()) } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepositoryTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepositoryTest.kt new file mode 100644 index 0000000..8181720 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/data/DefaultNotificationPreferencesRepositoryTest.kt @@ -0,0 +1,187 @@ +package com.interlinedlist.android.feature.notifications.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.notifications.data.remote.NotificationPreferencesApi +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.Json.Default.parseToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultNotificationPreferencesRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private lateinit var server: MockWebServer + private lateinit var api: NotificationPreferencesApi + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val contentType = "application/json".toMediaType() + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory(contentType)) + .build() + .create(NotificationPreferencesApi::class.java) + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultNotificationPreferencesRepository( + api = api, + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueueJson(code: Int, body: String) { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + @Test + fun `getPreferences parses varying channel sets across events`() = runTest(dispatcher) { + // Live-shaped payload: each event exposes a DIFFERENT set of channel keys. + enqueueJson( + 200, + """ + { + "events": [ + { "key": "dig", "label": "Digs on your messages", + "description": "When someone digs your message.", + "channels": { "push": true, "inApp": false } }, + { "key": "follow", "label": "New followers", + "description": "When someone follows you.", + "channels": { "push": true, "email": true } }, + { "key": "mention", "label": "Mentions", + "description": "When someone @-mentions you.", + "channels": { "email": true, "inApp": true, "push": false } }, + { "key": "reply", "label": "Replies", + "description": "When someone replies.", + "channels": { "email": true } } + ] + } + """.trimIndent(), + ) + val repo = repository() + + val result = repo.getPreferences() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val prefs = (result as ApiResult.Success).data + assertThat(prefs.map { it.key }).containsExactly("dig", "follow", "mention", "reply").inOrder() + + val dig = prefs.first { it.key == "dig" } + assertThat(dig.availableChannels) + .containsExactly(NotificationChannel.PUSH, NotificationChannel.IN_APP).inOrder() + assertThat(dig.isEnabled(NotificationChannel.PUSH)).isTrue() + assertThat(dig.isEnabled(NotificationChannel.IN_APP)).isFalse() + // A channel the event does NOT offer reports disabled and is not rendered. + assertThat(dig.isEnabled(NotificationChannel.EMAIL)).isFalse() + + val follow = prefs.first { it.key == "follow" } + assertThat(follow.availableChannels) + .containsExactly(NotificationChannel.PUSH, NotificationChannel.EMAIL).inOrder() + + val mention = prefs.first { it.key == "mention" } + assertThat(mention.availableChannels).containsExactly( + NotificationChannel.PUSH, NotificationChannel.IN_APP, NotificationChannel.EMAIL, + ).inOrder() + + val reply = prefs.first { it.key == "reply" } + assertThat(reply.availableChannels).containsExactly(NotificationChannel.EMAIL) + } + + @Test + fun `getPreferences drops unknown channel keys`() = runTest(dispatcher) { + enqueueJson( + 200, + """ + { "events": [ { "key": "dig", "label": "Digs", "description": "d", + "channels": { "push": true, "sms": true } } ] } + """.trimIndent(), + ) + val repo = repository() + + val prefs = (repo.getPreferences() as ApiResult.Success).data + val dig = prefs.single() + // "sms" is unknown and dropped; only push survives. + assertThat(dig.availableChannels).containsExactly(NotificationChannel.PUSH) + } + + @Test + fun `getPreferences maps a 401 to Unauthorized`() = runTest(dispatcher) { + enqueueJson(401, """{ "error": "Sign in required." }""") + val repo = repository() + + val result = repo.getPreferences() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.Unauthorized::class.java) + } + + @Test + fun `updatePreference PATCHes the event key and its channel map`() = runTest(dispatcher) { + enqueueJson(200, "") + val repo = repository() + + val result = repo.updatePreference( + NotificationPreference( + key = "dig", + label = "Digs", + description = "d", + channels = mapOf( + NotificationChannel.PUSH to false, + NotificationChannel.IN_APP to true, + ), + ), + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PATCH") + assertThat(request.path).contains("api/user/notification-preferences") + + // Round-trip the body: it carries the event key + the exact channel booleans. + val body = parseToJsonElement(request.body.readUtf8()).jsonObject + assertThat(body["key"]!!.jsonPrimitive.content).isEqualTo("dig") + val channels = body["channels"]!!.jsonObject + assertThat(channels["push"]!!.jsonPrimitive.content).isEqualTo("false") + assertThat(channels["inApp"]!!.jsonPrimitive.content).isEqualTo("true") + // Only the event's OWN channels are sent — no email key on a push/inApp event. + assertThat(channels.containsKey("email")).isFalse() + } + + @Test + fun `updatePreference maps a 400 to a failure`() = runTest(dispatcher) { + enqueueJson(400, """{ "error": "Invalid channel." }""") + val repo = repository() + + val result = repo.updatePreference( + NotificationPreference( + key = "dig", label = "Digs", description = "d", + channels = mapOf(NotificationChannel.PUSH to true), + ), + ) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + } +} diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationPreferencesRepository.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationPreferencesRepository.kt new file mode 100644 index 0000000..83ced26 --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/FakeNotificationPreferencesRepository.kt @@ -0,0 +1,46 @@ +package com.interlinedlist.android.feature.notifications.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.notifications.data.NotificationPreferencesRepository +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import com.interlinedlist.android.feature.notifications.domain.NotificationPreference + +/** + * Configurable in-memory [NotificationPreferencesRepository] for ViewModel tests. + * The GET result and each PATCH result can be pre-set to drive success/failure paths, + * and every [updatePreference] call is recorded for assertions. + */ +class FakeNotificationPreferencesRepository : NotificationPreferencesRepository { + + var getResult: ApiResult> = ApiResult.Success(emptyList()) + var updateResult: ApiResult = ApiResult.Success(Unit) + + var getCount = 0 + val updated = mutableListOf() + + override suspend fun getPreferences(): ApiResult> { + getCount++ + return getResult + } + + override suspend fun updatePreference(preference: NotificationPreference): ApiResult { + updated += preference + return updateResult + } +} + +/** Builds a sample [NotificationPreference] for tests. */ +fun samplePreference( + key: String = "dig", + label: String = "Digs on your messages", + description: String = "When someone digs your message.", + channels: Map = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.IN_APP to false, + ), +) = NotificationPreference( + key = key, + label = label, + description = description, + channels = channels, +) diff --git a/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModelTest.kt b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModelTest.kt new file mode 100644 index 0000000..f36bf2b --- /dev/null +++ b/feature/notifications/src/test/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationPreferencesViewModelTest.kt @@ -0,0 +1,196 @@ +package com.interlinedlist.android.feature.notifications.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.notifications.domain.NotificationChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NotificationPreferencesViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `load emits the fetched preferences and clears loading`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Success( + listOf( + samplePreference(key = "dig"), + samplePreference(key = "follow"), + ), + ) + } + val vm = NotificationPreferencesViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(repo.getCount).isEqualTo(1) + assertThat(state.isLoading).isFalse() + assertThat(state.preferences.map { it.key }).containsExactly("dig", "follow").inOrder() + } + } + + @Test + fun `load failure surfaces a mapped error`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = NotificationPreferencesViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.isLoading).isFalse() + assertThat(state.errorMessage).isEqualTo("No connection. Check your network and try again.") + } + } + + @Test + fun `toggling a channel updates state optimistically then persists`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Success( + listOf( + samplePreference( + key = "dig", + channels = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.IN_APP to false, + ), + ), + ), + ) + } + val vm = NotificationPreferencesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onToggle("dig", NotificationChannel.IN_APP, enabled = true) + // Immediately (before the PATCH completes) the state reflects the new value. + val optimistic = vm.uiState.value.preferences.single { it.key == "dig" } + assertThat(optimistic.isEnabled(NotificationChannel.IN_APP)).isTrue() + + advanceUntilIdle() + // Persisted: the repo received the toggled event with the flipped channel. + assertThat(repo.updated).hasSize(1) + val sent = repo.updated.single() + assertThat(sent.key).isEqualTo("dig") + assertThat(sent.isEnabled(NotificationChannel.IN_APP)).isTrue() + assertThat(sent.isEnabled(NotificationChannel.PUSH)).isTrue() + // State remains flipped after a successful PATCH. + assertThat( + vm.uiState.value.preferences.single { it.key == "dig" } + .isEnabled(NotificationChannel.IN_APP), + ).isTrue() + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `a failed toggle rolls back to the previous value and surfaces an error`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Success( + listOf( + samplePreference( + key = "dig", + channels = mapOf( + NotificationChannel.PUSH to true, + NotificationChannel.IN_APP to false, + ), + ), + ), + ) + updateResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = NotificationPreferencesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onToggle("dig", NotificationChannel.IN_APP, enabled = true) + // Optimistically true... + assertThat( + vm.uiState.value.preferences.single { it.key == "dig" } + .isEnabled(NotificationChannel.IN_APP), + ).isTrue() + + advanceUntilIdle() + // ...then rolled back to the original false on failure. + val rolledBack = vm.uiState.value.preferences.single { it.key == "dig" } + assertThat(rolledBack.isEnabled(NotificationChannel.IN_APP)).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotEmpty() + } + + @Test + fun `toggling an unsupported channel is a no-op`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Success( + listOf( + samplePreference( + key = "reply", + channels = mapOf(NotificationChannel.EMAIL to true), + ), + ), + ) + } + val vm = NotificationPreferencesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + // PUSH is not a channel this event supports. + vm.onToggle("reply", NotificationChannel.PUSH, enabled = true) + advanceUntilIdle() + + assertThat(repo.updated).isEmpty() + val reply = vm.uiState.value.preferences.single { it.key == "reply" } + assertThat(reply.availableChannels).containsExactly(NotificationChannel.EMAIL) + } + + @Test + fun `dismissError clears the error`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = NotificationPreferencesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + assertThat(vm.uiState.value.errorMessage).isNotNull() + + vm.dismissError() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `retry re-fetches after a failed load`() = runTest(dispatcher) { + val repo = FakeNotificationPreferencesRepository().apply { + getResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = NotificationPreferencesViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + assertThat(repo.getCount).isEqualTo(1) + + repo.getResult = ApiResult.Success(listOf(samplePreference(key = "dig"))) + vm.refresh() + advanceUntilIdle() + + assertThat(repo.getCount).isEqualTo(2) + assertThat(vm.uiState.value.preferences.map { it.key }).containsExactly("dig") + assertThat(vm.uiState.value.errorMessage).isNull() + } +} From cdd216e8948bb2b2e879dcdec17881d0f3b5d732 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 15:01:34 -0700 Subject: [PATCH 11/25] feat(profile): add Account & Security surface (Milestone K) Active Sessions (list + revoke device), Connected Accounts (view + unlink identity), Change email, and a type-to-confirm Delete Account flow (Play requires an in-app deletion path). Endpoints: /api/user/sessions (GET/DELETE), /api/user/identities (GET, DELETE by ?provider), /api/user/change-email/request, /api/user/delete. 107 unit tests green. Nav wiring deferred (snippet in report). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../feature/profile/ui/AccountScreensTest.kt | 70 ++++ .../feature/profile/ui/ProfileScreenTest.kt | 3 + .../profile/data/DefaultProfileRepository.kt | 32 ++ .../feature/profile/data/ProfileRepository.kt | 26 ++ .../feature/profile/data/remote/ProfileApi.kt | 33 ++ .../data/remote/dto/AccountResponses.kt | 68 ++++ .../data/remote/dto/ProfileRequests.kt | 20 ++ .../feature/profile/domain/LinkedIdentity.kt | 32 ++ .../feature/profile/domain/LoginSession.kt | 22 ++ .../ui/account/AccountSettingsScreen.kt | 311 ++++++++++++++++++ .../ui/account/AccountSettingsViewModel.kt | 115 +++++++ .../ui/account/ConnectedAccountsScreen.kt | 235 +++++++++++++ .../ui/account/ConnectedAccountsViewModel.kt | 94 ++++++ .../profile/ui/account/RelativeTime.kt | 33 ++ .../profile/ui/account/SessionsScreen.kt | 261 +++++++++++++++ .../profile/ui/account/SessionsViewModel.kt | 96 ++++++ .../profile/ui/profile/ProfileScreen.kt | 39 +++ .../data/DefaultProfileRepositoryTest.kt | 166 ++++++++++ .../ui/AccountSettingsViewModelTest.kt | 109 ++++++ .../ui/ConnectedAccountsViewModelTest.kt | 94 ++++++ .../profile/ui/FakeProfileRepository.kt | 78 +++++ .../feature/profile/ui/RelativeTimeTest.kt | 43 +++ .../profile/ui/SessionsViewModelTest.kt | 121 +++++++ 23 files changed, 2101 insertions(+) create mode 100644 feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/AccountResponses.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LinkedIdentity.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LoginSession.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/RelativeTime.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsViewModel.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ConnectedAccountsViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/RelativeTimeTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SessionsViewModelTest.kt diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt new file mode 100644 index 0000000..deba0d9 --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt @@ -0,0 +1,70 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.ui.account.SessionsScreen +import com.interlinedlist.android.feature.profile.ui.account.SessionsTestTags +import com.interlinedlist.android.feature.profile.ui.account.SessionsUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AccountScreensTest { + + @get:Rule + val composeRule = createComposeRule() + + private val sessions = listOf( + LoginSession("s1", "Pixel 8", null, "2026-07-31T21:49:00.000Z", isCurrent = true), + LoginSession("s2", "Chrome on macOS", null, "2026-07-30T09:00:00.000Z", isCurrent = false), + ) + + @Test + fun sessions_renderRowsAndCurrentDeviceBadge() { + composeRule.setContent { + InterlinedListTheme { + SessionsScreen( + state = SessionsUiState(sessions = sessions, isLoading = false), + onRevoke = {}, + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(SessionsTestTags.LIST).assertIsDisplayed() + composeRule.onNodeWithTag(SessionsTestTags.row("s1")).assertIsDisplayed() + composeRule.onNodeWithTag(SessionsTestTags.row("s2")).assertIsDisplayed() + // The current device shows a "This device" badge... + composeRule.onNodeWithTag(SessionsTestTags.CURRENT_BADGE).assertIsDisplayed() + } + + @Test + fun sessions_revokeShowsConfirmDialogThenInvokesCallback() { + var revoked: String? = null + composeRule.setContent { + InterlinedListTheme { + SessionsScreen( + state = SessionsUiState(sessions = sessions, isLoading = false), + onRevoke = { revoked = it }, + onBack = {}, + onRetry = {}, + ) + } + } + + // Tapping "Sign out" on a non-current session opens the confirm dialog. + composeRule.onNodeWithTag(SessionsTestTags.revoke("s2")).performClick() + composeRule.onNodeWithTag(SessionsTestTags.CONFIRM_DIALOG).assertIsDisplayed() + + // Confirming revokes that session. + composeRule.onNodeWithTag(SessionsTestTags.CONFIRM_REVOKE).performClick() + assert(revoked == "s2") + } +} diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt index 81fb6f9..25b6239 100644 --- a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ProfileScreenTest.kt @@ -61,6 +61,9 @@ class ProfileScreenTest { onOpenNotifications = {}, onOpenOrganizations = {}, onOpenIntegrations = {}, + onOpenSessions = {}, + onOpenConnectedAccounts = {}, + onOpenAccountSettings = {}, onSignOut = onSignOut, onRetry = {}, ) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt index da24d77..555072d 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt @@ -16,11 +16,15 @@ import com.interlinedlist.android.feature.profile.data.mapper.toProfileUser import com.interlinedlist.android.feature.profile.data.mapper.toSearchResult import com.interlinedlist.android.feature.profile.data.remote.ProfileApi import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.ChangeEmailRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.DeleteAccountRequest import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.domain.LinkedIdentity +import com.interlinedlist.android.feature.profile.domain.LoginSession import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.Flow @@ -186,6 +190,34 @@ class DefaultProfileRepository @Inject constructor( override suspend fun removeFollower(userId: String): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.removeFollower(userId) } } + // --- Account & Security (always fresh, nothing cached) --- + + override suspend fun getSessions(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getSessions().sessionsOrEmpty.map { it.toDomain() } } + } + + override suspend fun revokeSession(sessionId: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.revokeSession(sessionId) } } + + override suspend fun getIdentities(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getIdentities().identitiesOrEmpty.map { it.toDomain() } } + } + + override suspend fun unlinkIdentity(provider: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.unlinkIdentity(provider) } } + + override suspend fun requestEmailChange(newEmail: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.requestEmailChange(ChangeEmailRequest(newEmail)) } + } + + override suspend fun deleteAccount(username: String, email: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.deleteAccount(DeleteAccountRequest(username = username, email = email)) } + } + /** Caches [dto] as the current user, clearing the flag from any stale row first. */ private suspend fun cacheCurrentUser(dto: ProfileUserDto): ProfileUser { val domain = dto.toProfileUser(isCurrentUser = true) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt index 3d2f8fb..90f0f3b 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt @@ -4,6 +4,8 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.domain.LinkedIdentity +import com.interlinedlist.android.feature.profile.domain.LoginSession import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.Flow @@ -90,4 +92,28 @@ interface ProfileRepository { /** Removes [userId] as a follower via `DELETE /api/follow/{userId}/remove`. */ suspend fun removeFollower(userId: String): ApiResult + + // --- Account & Security --- + // These are always-fresh settings surfaces, so nothing is cached (YAGNI). + + /** The current user's active login sessions via `GET /api/user/sessions`. */ + suspend fun getSessions(): ApiResult> + + /** Revokes (signs out) the session [sessionId] via `DELETE /api/user/sessions/{id}`. */ + suspend fun revokeSession(sessionId: String): ApiResult + + /** The current user's linked social identities via `GET /api/user/identities`. */ + suspend fun getIdentities(): ApiResult> + + /** Unlinks the identity for [provider] via `DELETE /api/user/identities?provider=...`. */ + suspend fun unlinkIdentity(provider: String): ApiResult + + /** Requests an email change to [newEmail] via `POST /api/user/change-email/request`. */ + suspend fun requestEmailChange(newEmail: String): ApiResult + + /** + * Deletes the current user's account via `POST /api/user/delete`, confirming with + * the account's [username] and [email]. On success the caller signs the user out. + */ + suspend fun deleteAccount(username: String, email: String): ApiResult } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt index 668244a..2b6bea0 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt @@ -2,11 +2,15 @@ package com.interlinedlist.android.feature.profile.data.remote import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.ChangeEmailRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.DeleteAccountRequest import com.interlinedlist.android.feature.profile.data.remote.dto.FollowCountsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.FollowListResponse import com.interlinedlist.android.feature.profile.data.remote.dto.FollowRequestsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.FollowStatusResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.IdentitiesResponse import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.SessionsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest import com.interlinedlist.android.feature.profile.data.remote.dto.UserSearchResponse import okhttp3.MultipartBody @@ -105,4 +109,33 @@ interface ProfileApi { /** Removes a follower (only callable by the user being followed). */ @DELETE("api/follow/{userId}/remove") suspend fun removeFollower(@Path("userId") userId: String) + + // --- Account & Security --- + + /** The current user's active login sessions (sync tokens / devices). */ + @GET("api/user/sessions") + suspend fun getSessions(): SessionsResponse + + /** Revokes (signs out) a single session by id. */ + @DELETE("api/user/sessions/{id}") + suspend fun revokeSession(@Path("id") id: String) + + /** The current user's linked social identities. */ + @GET("api/user/identities") + suspend fun getIdentities(): IdentitiesResponse + + /** + * Unlinks a social identity. The API keys on the `provider` query parameter + * (verified against the OpenAPI spec — it is a query param, not a body). + */ + @DELETE("api/user/identities") + suspend fun unlinkIdentity(@Query("provider") provider: String) + + /** Requests an email change; the server emails a verification link to the new address. */ + @POST("api/user/change-email/request") + suspend fun requestEmailChange(@Body body: ChangeEmailRequest) + + /** Deletes the current user's account (requires the username + email to confirm). */ + @POST("api/user/delete") + suspend fun deleteAccount(@Body body: DeleteAccountRequest) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/AccountResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/AccountResponses.kt new file mode 100644 index 0000000..e94382d --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/AccountResponses.kt @@ -0,0 +1,68 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import com.interlinedlist.android.feature.profile.domain.LinkedIdentity +import com.interlinedlist.android.feature.profile.domain.LoginSession +import kotlinx.serialization.Serializable + +/** + * `GET /api/user/sessions` → `{ "sessions": [ ... ] }` (shape verified live 2026-07-31). + * The generic `data` envelope is tolerated too in case the server ever switches keys. + */ +@Serializable +data class SessionsResponse( + val sessions: List? = null, + val data: List? = null, +) { + val sessionsOrEmpty: List get() = sessions ?: data ?: emptyList() +} + +/** A single active login session (sync token). */ +@Serializable +data class LoginSessionDto( + val id: String, + val deviceLabel: String? = null, + val createdAt: String? = null, + val lastUsedAt: String? = null, + val isCurrent: Boolean = false, +) { + fun toDomain(): LoginSession = LoginSession( + id = id, + deviceLabel = deviceLabel.orEmpty(), + createdAt = createdAt, + lastUsedAt = lastUsedAt, + isCurrent = isCurrent, + ) +} + +/** + * `GET /api/user/identities` → `{ "identities": [ ... ] }` (shape verified live 2026-07-31). + * The generic `data` envelope is tolerated too. + */ +@Serializable +data class IdentitiesResponse( + val identities: List? = null, + val data: List? = null, +) { + val identitiesOrEmpty: List get() = identities ?: data ?: emptyList() +} + +/** A single linked social identity. */ +@Serializable +data class LinkedIdentityDto( + val id: String, + val provider: String = "", + val providerUsername: String? = null, + val profileUrl: String? = null, + val avatarUrl: String? = null, + val connectedAt: String? = null, + val lastVerifiedAt: String? = null, +) { + fun toDomain(): LinkedIdentity = LinkedIdentity( + id = id, + provider = provider, + providerUsername = providerUsername, + profileUrl = profileUrl, + avatarUrl = avatarUrl, + connectedAt = connectedAt, + ) +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt index 271b39c..a0c1c77 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileRequests.kt @@ -19,3 +19,23 @@ data class UpdateProfileRequest( data class AvatarFromUrlRequest( val url: String, ) + +/** + * Body for `POST /api/user/change-email/request` (schema verified against the + * OpenAPI spec). The server sends a verification email to [newEmail]. + */ +@Serializable +data class ChangeEmailRequest( + val newEmail: String, +) + +/** + * Body for `POST /api/user/delete` (schema verified against the OpenAPI spec). The + * account is deleted only when both [username] and [email] match the current user — + * the type-to-confirm guard on the UI collects them. + */ +@Serializable +data class DeleteAccountRequest( + val username: String, + val email: String, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LinkedIdentity.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LinkedIdentity.kt new file mode 100644 index 0000000..c97ac6f --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LinkedIdentity.kt @@ -0,0 +1,32 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * A social account linked to the current user (e.g. `mastodon:host`, `linkedin`, + * `twitter`, `bluesky`), shown on the Connected Accounts screen. Unlinking keys on + * [provider], which the API's `DELETE /api/user/identities?provider=...` expects. + * + * [connectedAt] is kept as the raw ISO-8601 string the API returns; the UI layer + * formats it for display. + */ +data class LinkedIdentity( + val id: String, + val provider: String, + val providerUsername: String?, + val profileUrl: String?, + val avatarUrl: String?, + val connectedAt: String?, +) { + /** + * A human-friendly provider name. Mastodon identities encode the host as + * `mastodon:techhub.social`; only the leading provider token is shown, title-cased. + */ + val providerLabel: String + get() { + val token = provider.substringBefore(':').takeIf { it.isNotBlank() } ?: provider + return token.replaceFirstChar { it.uppercase() } + } + + /** The best label for the linked account: username if set, else the provider name. */ + val displayLabel: String + get() = providerUsername?.takeIf { it.isNotBlank() } ?: providerLabel +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LoginSession.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LoginSession.kt new file mode 100644 index 0000000..ccc91d5 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/LoginSession.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * An active login session (sync token) for the current account, shown on the + * Active Sessions screen. [isCurrent] marks the device the user is signed in on + * right now, which cannot be revoked from here. + * + * Timestamps are kept as the raw ISO-8601 strings the API returns; the UI layer + * formats [lastUsedAt] into a relative label so the domain stays free of + * presentation concerns. + */ +data class LoginSession( + val id: String, + val deviceLabel: String, + val createdAt: String?, + val lastUsedAt: String?, + val isCurrent: Boolean, +) { + /** A non-blank label to show for the device, falling back to a generic one. */ + val displayLabel: String + get() = deviceLabel.takeIf { it.isNotBlank() } ?: "Unknown device" +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt new file mode 100644 index 0000000..74946bd --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt @@ -0,0 +1,311 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import kotlinx.coroutines.flow.Flow + +/** Stable test tags for the Account settings screen. */ +object AccountSettingsTestTags { + const val BACK = "accountSettingsBack" + const val EMAIL_FIELD = "accountSettingsEmailField" + const val CHANGE_EMAIL = "accountSettingsChangeEmail" + const val EMAIL_REQUESTED = "accountSettingsEmailRequested" + const val ERROR = "accountSettingsError" + const val DELETE_ACCOUNT = "accountSettingsDeleteAccount" + const val DELETE_DIALOG = "accountSettingsDeleteDialog" + const val DELETE_USERNAME_FIELD = "accountSettingsDeleteUsernameField" + const val DELETE_EMAIL_FIELD = "accountSettingsDeleteEmailField" + const val DELETE_CONFIRM = "accountSettingsDeleteConfirm" +} + +/** + * The Account settings screen (route `account/settings`): change the account email and, + * behind a type-to-confirm guard, delete the account. + * + * @param onBack pop back to the account hub. + * @param onSignedOut invoked after the account is deleted; the app clears the session and + * navigates away (the profile module does not own session state). + */ +@Composable +fun AccountSettingsRoute( + onBack: () -> Unit, + onSignedOut: () -> Unit, + modifier: Modifier = Modifier, + viewModel: AccountSettingsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + AccountSettingsEffects(effects = viewModel.effects, onSignedOut = onSignedOut) + AccountSettingsScreen( + state = state, + onChangeEmail = viewModel::requestEmailChange, + onAcknowledgeEmailChange = viewModel::acknowledgeEmailChange, + onDeleteAccount = viewModel::deleteAccount, + onBack = onBack, + modifier = modifier, + ) +} + +/** Collects the one-shot signed-out effect and forwards it to the app. */ +@Composable +private fun AccountSettingsEffects( + effects: Flow, + onSignedOut: () -> Unit, +) { + LaunchedEffect(effects) { + effects.collect { effect -> + when (effect) { + AccountSettingsEffect.SignedOut -> onSignedOut() + } + } + } +} + +/** Stateless Account settings UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun AccountSettingsScreen( + state: AccountSettingsUiState, + onChangeEmail: (String) -> Unit, + onAcknowledgeEmailChange: () -> Unit, + onDeleteAccount: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + var newEmail by remember { mutableStateOf("") } + var showDeleteDialog by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Account settings") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(AccountSettingsTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) { + // --- Change email --- + Text("Change email", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + Text( + text = "We'll send a verification link to the new address before it takes effect.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = newEmail, + onValueChange = { newEmail = it }, + label = { Text("New email") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier + .fillMaxWidth() + .testTag(AccountSettingsTestTags.EMAIL_FIELD), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { onChangeEmail(newEmail) }, + enabled = newEmail.isNotBlank() && !state.isChangingEmail, + modifier = Modifier.testTag(AccountSettingsTestTags.CHANGE_EMAIL), + ) { + if (state.isChangingEmail) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Request email change") + } + } + + if (state.emailChangeRequested) { + Spacer(Modifier.height(8.dp)) + Text( + text = "Check your new inbox for a verification link.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(AccountSettingsTestTags.EMAIL_REQUESTED), + ) + LaunchedEffect(Unit) { newEmail = "" } + } + + if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag(AccountSettingsTestTags.ERROR), + ) + } + + Spacer(Modifier.height(32.dp)) + HorizontalDivider() + Spacer(Modifier.height(32.dp)) + + // --- Delete account (danger zone) --- + Text( + text = "Delete account", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "This permanently deletes your account and all its data. This cannot be undone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { showDeleteDialog = true }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + modifier = Modifier.testTag(AccountSettingsTestTags.DELETE_ACCOUNT), + ) { + Text("Delete account") + } + } + } + + if (showDeleteDialog) { + DeleteAccountDialog( + expectedUsername = state.username, + isDeleting = state.isDeletingAccount, + onConfirm = { email -> + onDeleteAccount(email) + showDeleteDialog = false + }, + onDismiss = { showDeleteDialog = false }, + ) + } +} + +/** + * The type-to-confirm delete guard: the user must re-type their exact username and enter + * their email before the destructive confirm button enables. + */ +@Composable +private fun DeleteAccountDialog( + expectedUsername: String, + isDeleting: Boolean, + onConfirm: (email: String) -> Unit, + onDismiss: () -> Unit, +) { + var typedUsername by remember { mutableStateOf("") } + var typedEmail by remember { mutableStateOf("") } + val canDelete = typedUsername.trim() == expectedUsername && + typedEmail.isNotBlank() && + !isDeleting + + AlertDialog( + onDismissRequest = { if (!isDeleting) onDismiss() }, + modifier = Modifier.testTag(AccountSettingsTestTags.DELETE_DIALOG), + title = { Text("Delete account?") }, + text = { + Column { + Text( + text = "To confirm, type your username \"$expectedUsername\" and your email.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = typedUsername, + onValueChange = { typedUsername = it }, + label = { Text("Username") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .testTag(AccountSettingsTestTags.DELETE_USERNAME_FIELD), + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = typedEmail, + onValueChange = { typedEmail = it }, + label = { Text("Email") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier + .fillMaxWidth() + .testTag(AccountSettingsTestTags.DELETE_EMAIL_FIELD), + ) + } + }, + confirmButton = { + TextButton( + onClick = { onConfirm(typedEmail) }, + enabled = canDelete, + modifier = Modifier.testTag(AccountSettingsTestTags.DELETE_CONFIRM), + ) { + if (isDeleting) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Delete account", color = MaterialTheme.colorScheme.error) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !isDeleting) { Text("Cancel") } + }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun AccountSettingsScreenPreview() { + InterlinedListTheme { + AccountSettingsScreen( + state = AccountSettingsUiState(username = "adron"), + onChangeEmail = {}, + onAcknowledgeEmailChange = {}, + onDeleteAccount = {}, + onBack = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt new file mode 100644 index 0000000..8f81d9d --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt @@ -0,0 +1,115 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * UI state for the Account settings screen (change email + delete account). + * + * [username] is seeded from the current user's cache and is the value the + * type-to-confirm delete guard checks against. + */ +data class AccountSettingsUiState( + val username: String = "", + val isChangingEmail: Boolean = false, + val isDeletingAccount: Boolean = false, + // A one-shot confirmation to show after a successful email-change request. + val emailChangeRequested: Boolean = false, + val errorMessage: String? = null, +) + +/** One-shot effects the Account settings screen reacts to. */ +sealed interface AccountSettingsEffect { + /** The account was deleted; the app should sign the user out and leave the tab. */ + data object SignedOut : AccountSettingsEffect +} + +/** + * Drives the Account settings screen. Requests an email change via + * `POST /api/user/change-email/request` and deletes the account via + * `POST /api/user/delete`. On a successful delete it emits [AccountSettingsEffect.SignedOut] + * so the app can clear the session and navigate away (the profile module does not own + * session state). + */ +@HiltViewModel +class AccountSettingsViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(AccountSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _effects = Channel(Channel.BUFFERED) + val effects: Flow = _effects.receiveAsFlow() + + init { + seedFromCache() + } + + /** Seeds [AccountSettingsUiState.username] from the cached current user. */ + private fun seedFromCache() { + viewModelScope.launch { + repository.observeCurrentUser().collect { cached -> + if (cached != null) { + _uiState.update { it.copy(username = cached.username) } + } + } + } + } + + /** Requests a verification email to move the account to [newEmail]. */ + fun requestEmailChange(newEmail: String) { + val email = newEmail.trim() + if (email.isBlank() || _uiState.value.isChangingEmail) return + _uiState.update { it.copy(isChangingEmail = true, emailChangeRequested = false, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.requestEmailChange(email)) { + is ApiResult.Success -> _uiState.update { + it.copy(isChangingEmail = false, emailChangeRequested = true) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isChangingEmail = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** + * Deletes the account after the type-to-confirm guard passed. Requires the account's + * [email] (the server verifies both username and email). Emits + * [AccountSettingsEffect.SignedOut] on success. + */ + fun deleteAccount(email: String) { + val state = _uiState.value + if (state.isDeletingAccount || state.username.isBlank()) return + _uiState.update { it.copy(isDeletingAccount = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.deleteAccount(username = state.username, email = email.trim())) { + is ApiResult.Success -> { + _uiState.update { it.copy(isDeletingAccount = false) } + _effects.send(AccountSettingsEffect.SignedOut) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isDeletingAccount = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + fun acknowledgeEmailChange() = _uiState.update { it.copy(emailChangeRequested = false) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsScreen.kt new file mode 100644 index 0000000..1f73a1e --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsScreen.kt @@ -0,0 +1,235 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.LinkedIdentity +import com.interlinedlist.android.feature.profile.ui.common.UserAvatar + +/** Stable test tags for the Connected Accounts screen. */ +object ConnectedAccountsTestTags { + const val LIST = "connectedAccountsList" + const val EMPTY = "connectedAccountsEmpty" + const val PROGRESS = "connectedAccountsProgress" + const val ERROR = "connectedAccountsError" + const val BACK = "connectedAccountsBack" + const val CONFIRM_DIALOG = "connectedAccountsConfirmDialog" + const val CONFIRM_UNLINK = "connectedAccountsConfirmUnlink" + fun row(id: String) = "identityRow_$id" + fun unlink(id: String) = "identityUnlink_$id" +} + +/** + * The current user's linked social accounts (route `account/connected`). Each identity + * can be unlinked behind a confirm dialog. + * + * @param onBack pop back to the account hub. + */ +@Composable +fun ConnectedAccountsRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ConnectedAccountsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ConnectedAccountsScreen( + state = state, + onUnlink = viewModel::unlink, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless Connected Accounts UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun ConnectedAccountsScreen( + state: ConnectedAccountsUiState, + onUnlink: (String) -> Unit, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + var pendingConfirm by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Connected accounts") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(ConnectedAccountsTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(ConnectedAccountsTestTags.PROGRESS), + ) + + state.errorMessage != null && state.identities.isEmpty() -> Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = state.errorMessage, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ConnectedAccountsTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + + state.isEmpty -> Text( + text = "No connected accounts yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).testTag(ConnectedAccountsTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(ConnectedAccountsTestTags.LIST), + ) { + items(state.identities, key = { it.id }) { identity -> + IdentityRow( + identity = identity, + inProgress = identity.id in state.pendingUnlinkIds, + onUnlink = { pendingConfirm = identity }, + ) + HorizontalDivider() + } + } + } + } + } + + pendingConfirm?.let { identity -> + AlertDialog( + onDismissRequest = { pendingConfirm = null }, + modifier = Modifier.testTag(ConnectedAccountsTestTags.CONFIRM_DIALOG), + title = { Text("Unlink ${identity.providerLabel}?") }, + text = { Text("\"${identity.displayLabel}\" will be disconnected from your account.") }, + confirmButton = { + TextButton( + onClick = { + onUnlink(identity.id) + pendingConfirm = null + }, + modifier = Modifier.testTag(ConnectedAccountsTestTags.CONFIRM_UNLINK), + ) { + Text("Unlink", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingConfirm = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun IdentityRow( + identity: LinkedIdentity, + inProgress: Boolean, + onUnlink: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(ConnectedAccountsTestTags.row(identity.id)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + UserAvatar(avatarUrl = identity.avatarUrl, seedLabel = identity.providerLabel, size = 40.dp) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text( + text = identity.providerLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = identity.providerUsername ?: "Connected ${relativeTime(identity.connectedAt)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (inProgress) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + OutlinedButton( + onClick = onUnlink, + modifier = Modifier.testTag(ConnectedAccountsTestTags.unlink(identity.id)), + ) { + Text("Unlink") + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ConnectedAccountsScreenPreview() { + InterlinedListTheme { + ConnectedAccountsScreen( + state = ConnectedAccountsUiState( + identities = listOf( + LinkedIdentity("1", "linkedin", "Adron Hall", null, null, "2026-06-12T07:33:42.447Z"), + LinkedIdentity("2", "mastodon:techhub.social", "crew@techhub.social", null, null, null), + ), + isLoading = false, + ), + onUnlink = {}, + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsViewModel.kt new file mode 100644 index 0000000..ce6b78c --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/ConnectedAccountsViewModel.kt @@ -0,0 +1,94 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.LinkedIdentity +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the Connected Accounts screen. */ +data class ConnectedAccountsUiState( + val identities: List = emptyList(), + val isLoading: Boolean = true, + // Ids currently being unlinked, so their row can show progress and dedupe taps. + val pendingUnlinkIds: Set = emptySet(), + val errorMessage: String? = null, +) { + /** A load finished with no identities and no error. */ + val isEmpty: Boolean get() = identities.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the Connected Accounts screen. Loads linked identities via + * `GET /api/user/identities` and unlinks one via + * `DELETE /api/user/identities?provider=...`, removing the row optimistically and + * rolling it back if the unlink fails. + */ +@HiltViewModel +class ConnectedAccountsViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ConnectedAccountsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getIdentities()) { + is ApiResult.Success -> _uiState.update { + it.copy(identities = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** + * Unlinks the identity [identityId] (keyed on its provider for the API), removing + * its row optimistically. An in-flight unlink is deduped. + */ + fun unlink(identityId: String) { + val state = _uiState.value + val target = state.identities.firstOrNull { it.id == identityId } ?: return + if (identityId in state.pendingUnlinkIds) return + + _uiState.update { + it.copy( + identities = it.identities.filterNot { i -> i.id == identityId }, + pendingUnlinkIds = it.pendingUnlinkIds + identityId, + errorMessage = null, + ) + } + viewModelScope.launch { + when (val result = repository.unlinkIdentity(target.provider)) { + is ApiResult.Success -> _uiState.update { + it.copy(pendingUnlinkIds = it.pendingUnlinkIds - identityId) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + identities = it.identities + target, + pendingUnlinkIds = it.pendingUnlinkIds - identityId, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/RelativeTime.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/RelativeTime.kt new file mode 100644 index 0000000..7d3e385 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/RelativeTime.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import java.time.Instant +import java.time.temporal.ChronoUnit + +/** + * Formats an ISO-8601 [isoTimestamp] as a coarse relative label ("just now", + * "5 minutes ago", "3 days ago") relative to [now]. Returns [fallback] when the + * timestamp is null or unparseable, so the settings rows always show something. + * + * Pure and side-effect free so it can be unit-tested without a device clock. + */ +fun relativeTime( + isoTimestamp: String?, + now: Instant = Instant.now(), + fallback: String = "Never", +): String { + val then = isoTimestamp?.let { runCatching { Instant.parse(it) }.getOrNull() } ?: return fallback + val seconds = ChronoUnit.SECONDS.between(then, now).coerceAtLeast(0) + return when { + seconds < 60 -> "just now" + seconds < 3_600 -> pluralize(seconds / 60, "minute") + seconds < 86_400 -> pluralize(seconds / 3_600, "hour") + seconds < 2_592_000 -> pluralize(seconds / 86_400, "day") + seconds < 31_536_000 -> pluralize(seconds / 2_592_000, "month") + else -> pluralize(seconds / 31_536_000, "year") + } +} + +private fun pluralize(value: Long, unit: String): String { + val plural = if (value == 1L) unit else "${unit}s" + return "$value $plural ago" +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsScreen.kt new file mode 100644 index 0000000..6cee9e1 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsScreen.kt @@ -0,0 +1,261 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.LoginSession + +/** Stable test tags for the Active Sessions screen. */ +object SessionsTestTags { + const val LIST = "sessionsList" + const val EMPTY = "sessionsEmpty" + const val PROGRESS = "sessionsProgress" + const val ERROR = "sessionsError" + const val BACK = "sessionsBack" + const val CONFIRM_DIALOG = "sessionsConfirmDialog" + const val CONFIRM_REVOKE = "sessionsConfirmRevoke" + const val CURRENT_BADGE = "sessionsCurrentBadge" + fun row(id: String) = "sessionRow_$id" + fun revoke(id: String) = "sessionRevoke_$id" +} + +/** + * The current user's active login sessions (route `account/sessions`). Each non-current + * session can be signed out (revoked) behind a confirm dialog; the device the user is on + * shows a "This device" badge and cannot be revoked. + * + * @param onBack pop back to the account hub. + */ +@Composable +fun SessionsRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SessionsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + SessionsScreen( + state = state, + onRevoke = viewModel::revoke, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless Active Sessions UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun SessionsScreen( + state: SessionsUiState, + onRevoke: (String) -> Unit, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + // The session queued for revoke while its confirm dialog is up. + var pendingConfirm by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Active sessions") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(SessionsTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(SessionsTestTags.PROGRESS), + ) + + state.errorMessage != null && state.sessions.isEmpty() -> ErrorState( + message = state.errorMessage, + onRetry = onRetry, + tag = SessionsTestTags.ERROR, + ) + + state.isEmpty -> Text( + text = "No active sessions.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).testTag(SessionsTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(SessionsTestTags.LIST), + ) { + items(state.sessions, key = { it.id }) { session -> + SessionRow( + session = session, + inProgress = session.id in state.pendingRevokeIds, + onRevoke = { pendingConfirm = session }, + ) + HorizontalDivider() + } + } + } + } + } + + pendingConfirm?.let { session -> + AlertDialog( + onDismissRequest = { pendingConfirm = null }, + modifier = Modifier.testTag(SessionsTestTags.CONFIRM_DIALOG), + title = { Text("Sign out this device?") }, + text = { Text("\"${session.displayLabel}\" will be signed out and its session revoked.") }, + confirmButton = { + TextButton( + onClick = { + onRevoke(session.id) + pendingConfirm = null + }, + modifier = Modifier.testTag(SessionsTestTags.CONFIRM_REVOKE), + ) { + Text("Sign out", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { pendingConfirm = null }) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun SessionRow( + session: LoginSession, + inProgress: Boolean, + onRevoke: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(SessionsTestTags.row(session.id)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = session.displayLabel, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (session.isCurrent) { + Spacer(Modifier.width(8.dp)) + AssistChip( + onClick = {}, + enabled = false, + label = { Text("This device") }, + colors = AssistChipDefaults.assistChipColors( + disabledLabelColor = MaterialTheme.colorScheme.primary, + ), + modifier = Modifier.testTag(SessionsTestTags.CURRENT_BADGE), + ) + } + } + Text( + text = "Last used ${relativeTime(session.lastUsedAt)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (!session.isCurrent) { + if (inProgress) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + OutlinedButton( + onClick = onRevoke, + modifier = Modifier.testTag(SessionsTestTags.revoke(session.id)), + ) { + Text("Sign out") + } + } + } + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit, tag: String) { + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(tag), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } +} + +@Preview(showBackground = true) +@Composable +private fun SessionsScreenPreview() { + InterlinedListTheme { + SessionsScreen( + state = SessionsUiState( + sessions = listOf( + LoginSession("1", "Pixel 8 (this device)", null, "2026-07-31T21:49:00.000Z", isCurrent = true), + LoginSession("2", "Chrome on macOS", null, "2026-07-30T09:00:00.000Z", isCurrent = false), + ), + isLoading = false, + ), + onRevoke = {}, + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsViewModel.kt new file mode 100644 index 0000000..df41315 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/SessionsViewModel.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the Active Sessions screen. */ +data class SessionsUiState( + val sessions: List = emptyList(), + val isLoading: Boolean = true, + // Ids currently being revoked, so their row can show progress and dedupe taps. + val pendingRevokeIds: Set = emptySet(), + val errorMessage: String? = null, +) { + /** A load finished with no sessions and no error. */ + val isEmpty: Boolean get() = sessions.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the Active Sessions screen. Loads the current user's login sessions via + * `GET /api/user/sessions` and revokes a non-current session via + * `DELETE /api/user/sessions/{id}`, removing the row optimistically and rolling it + * back if the revoke fails. + */ +@HiltViewModel +class SessionsViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SessionsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getSessions()) { + is ApiResult.Success -> _uiState.update { + it.copy(sessions = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** + * Revokes (signs out) the session [sessionId], removing its row optimistically. + * The current device cannot be revoked here, and an in-flight revoke is deduped. + */ + fun revoke(sessionId: String) { + val state = _uiState.value + val target = state.sessions.firstOrNull { it.id == sessionId } ?: return + if (target.isCurrent || sessionId in state.pendingRevokeIds) return + + // Optimistically drop the row while marking it in-flight for rollback. + _uiState.update { + it.copy( + sessions = it.sessions.filterNot { s -> s.id == sessionId }, + pendingRevokeIds = it.pendingRevokeIds + sessionId, + errorMessage = null, + ) + } + viewModelScope.launch { + when (val result = repository.revokeSession(sessionId)) { + is ApiResult.Success -> _uiState.update { + it.copy(pendingRevokeIds = it.pendingRevokeIds - sessionId) + } + is ApiResult.Failure -> _uiState.update { + // Roll the removed row back into place and surface the error. + it.copy( + sessions = (it.sessions + target).sortedByDescending { s -> s.isCurrent }, + pendingRevokeIds = it.pendingRevokeIds - sessionId, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt index 3b90d58..88eabd7 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -16,9 +16,12 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.filled.Business +import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Extension import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Search @@ -54,6 +57,9 @@ object AccountMenuTestTags { const val INTEGRATIONS = "accountMenuIntegrations" const val EDIT_PROFILE = "accountMenuEditProfile" const val SEARCH_USERS = "accountMenuSearchUsers" + const val SESSIONS = "accountMenuSessions" + const val CONNECTED_ACCOUNTS = "accountMenuConnectedAccounts" + const val ACCOUNT_SETTINGS = "accountMenuAccountSettings" } /** @@ -73,6 +79,9 @@ object AccountMenuTestTags { * @param onOpenNotifications navigate to the notifications module. * @param onOpenOrganizations navigate to the organizations module. * @param onOpenIntegrations navigate to the integrations module. + * @param onOpenSessions navigate to the Active Sessions screen (within this module). + * @param onOpenConnectedAccounts navigate to the Connected Accounts screen (within this module). + * @param onOpenAccountSettings navigate to the Account settings screen (within this module). * @param onSignOut invoked after the caller performs sign-out; the profile module does * not own session state, so the app wires this to the auth logout + navigation. */ @@ -86,6 +95,9 @@ fun ProfileRoute( onOpenNotifications: () -> Unit, onOpenOrganizations: () -> Unit, onOpenIntegrations: () -> Unit, + onOpenSessions: () -> Unit, + onOpenConnectedAccounts: () -> Unit, + onOpenAccountSettings: () -> Unit, onSignOut: () -> Unit, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), @@ -103,6 +115,9 @@ fun ProfileRoute( onOpenNotifications = onOpenNotifications, onOpenOrganizations = onOpenOrganizations, onOpenIntegrations = onOpenIntegrations, + onOpenSessions = onOpenSessions, + onOpenConnectedAccounts = onOpenConnectedAccounts, + onOpenAccountSettings = onOpenAccountSettings, onSignOut = onSignOut, onRetry = viewModel::refresh, modifier = modifier, @@ -122,6 +137,9 @@ fun ProfileScreen( onOpenNotifications: () -> Unit, onOpenOrganizations: () -> Unit, onOpenIntegrations: () -> Unit, + onOpenSessions: () -> Unit, + onOpenConnectedAccounts: () -> Unit, + onOpenAccountSettings: () -> Unit, onSignOut: () -> Unit, onRetry: () -> Unit, modifier: Modifier = Modifier, @@ -196,6 +214,24 @@ fun ProfileScreen( onClick = onOpenIntegrations, tag = AccountMenuTestTags.INTEGRATIONS, ) + AccountMenuRow( + icon = Icons.Default.Devices, + label = "Active sessions", + onClick = onOpenSessions, + tag = AccountMenuTestTags.SESSIONS, + ) + AccountMenuRow( + icon = Icons.Default.Link, + label = "Connected accounts", + onClick = onOpenConnectedAccounts, + tag = AccountMenuTestTags.CONNECTED_ACCOUNTS, + ) + AccountMenuRow( + icon = Icons.Default.ManageAccounts, + label = "Account settings", + onClick = onOpenAccountSettings, + tag = AccountMenuTestTags.ACCOUNT_SETTINGS, + ) AccountMenuRow( icon = Icons.Default.Edit, label = "Edit profile", @@ -302,6 +338,9 @@ private fun ProfileScreenPreview() { onOpenNotifications = {}, onOpenOrganizations = {}, onOpenIntegrations = {}, + onOpenSessions = {}, + onOpenConnectedAccounts = {}, + onOpenAccountSettings = {}, onSignOut = {}, onRetry = {}, ) diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt index 7e62804..17ba115 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt @@ -468,4 +468,170 @@ class DefaultProfileRepositoryTest { assertThat(result).isInstanceOf(ApiResult.Failure::class.java) assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) } + + // --- Account & Security --- + + @Test + fun `getSessions parses the wrapped sessions list`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "sessions": [ + { + "id": "s1", + "deviceLabel": "Pixel 8", + "createdAt": "2026-07-31T21:37:00.000Z", + "lastUsedAt": "2026-07-31T21:49:00.000Z", + "isCurrent": true + }, + { + "id": "s2", + "deviceLabel": "Chrome on macOS", + "createdAt": "2026-07-30T09:00:00.000Z", + "lastUsedAt": null, + "isCurrent": false + } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getSessions() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val sessions = (result as ApiResult.Success).data + assertThat(sessions.map { it.id }).containsExactly("s1", "s2").inOrder() + assertThat(sessions.first().isCurrent).isTrue() + assertThat(sessions[1].lastUsedAt).isNull() + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("GET") + assertThat(recorded.path).isEqualTo("/api/user/sessions") + } + + @Test + fun `getSessions maps a 401 to Unauthorized`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(401).setBody("""{ "error": "Session expired." }""")) + + val result = repository.getSessions() + + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.Unauthorized::class.java) + } + + @Test + fun `revokeSession deletes the session by id`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200)) + + val result = repository.revokeSession("s2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/user/sessions/s2") + } + + @Test + fun `getIdentities parses the wrapped identities list`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "identities": [ + { + "id": "i1", + "provider": "mastodon:techhub.social", + "providerUsername": "crew@techhub.social", + "profileUrl": "https://techhub.social/@crew", + "avatarUrl": null, + "connectedAt": "2026-04-07T16:35:32.476Z", + "lastVerifiedAt": null + }, + { + "id": "i2", + "provider": "linkedin", + "providerUsername": "Adron Hall", + "profileUrl": null, + "avatarUrl": null, + "connectedAt": "2026-06-12T07:33:42.447Z", + "lastVerifiedAt": "2026-07-07T07:47:48.121Z" + } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getIdentities() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val identities = (result as ApiResult.Success).data + assertThat(identities.map { it.provider }).containsExactly("mastodon:techhub.social", "linkedin").inOrder() + // Mastodon host is stripped to the leading provider token for display. + assertThat(identities.first().providerLabel).isEqualTo("Mastodon") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("GET") + assertThat(recorded.path).isEqualTo("/api/user/identities") + } + + @Test + fun `unlinkIdentity deletes with the provider as a query parameter`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200)) + + val result = repository.unlinkIdentity("mastodon:techhub.social") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + // The API keys on ?provider=... (verified against the OpenAPI spec). + assertThat(recorded.path).isEqualTo("/api/user/identities?provider=mastodon%3Atechhub.social") + } + + @Test + fun `requestEmailChange posts the new email`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.requestEmailChange("new@example.com") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/user/change-email/request") + assertThat(recorded.body.readUtf8()).contains("\"newEmail\":\"new@example.com\"") + } + + @Test + fun `requestEmailChange maps a 400 to a failure`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{ "error": "Email already in use." }""")) + + val result = repository.requestEmailChange("taken@example.com") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + } + + @Test + fun `deleteAccount posts the username and email confirmation`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.deleteAccount(username = "adron", email = "adron@example.com") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/user/delete") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"username\":\"adron\"") + assertThat(body).contains("\"email\":\"adron@example.com\"") + } + + @Test + fun `deleteAccount maps a 400 to a failure`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{ "error": "Confirmation did not match." }""")) + + val result = repository.deleteAccount(username = "adron", email = "wrong@example.com") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt new file mode 100644 index 0000000..234f77b --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt @@ -0,0 +1,109 @@ +package com.interlinedlist.android.feature.profile.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsEffect +import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AccountSettingsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `seeds the username from the cached current user`() = runTest(dispatcher) { + repo.currentUserFlow.value = testUser(username = "adron") + + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.username).isEqualTo("adron") + } + + @Test + fun `requestEmailChange sends the trimmed email and flags success`() = runTest(dispatcher) { + repo.requestEmailChangeResult = ApiResult.Success(Unit) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.requestEmailChange(" new@example.com ") + advanceUntilIdle() + + assertThat(repo.requestedEmail).isEqualTo("new@example.com") + assertThat(vm.uiState.value.emailChangeRequested).isTrue() + assertThat(vm.uiState.value.isChangingEmail).isFalse() + } + + @Test + fun `requestEmailChange surfaces an error on failure`() = runTest(dispatcher) { + repo.requestEmailChangeResult = ApiResult.Failure(AppError.Conflict("Email in use")) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.requestEmailChange("taken@example.com") + advanceUntilIdle() + + assertThat(vm.uiState.value.emailChangeRequested).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `deleteAccount confirms with the seeded username and emits a signed-out effect`() = runTest(dispatcher) { + repo.currentUserFlow.value = testUser(username = "adron") + repo.deleteAccountResult = ApiResult.Success(Unit) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.effects.test { + vm.deleteAccount("adron@example.com") + advanceUntilIdle() + + assertThat(awaitItem()).isEqualTo(AccountSettingsEffect.SignedOut) + cancelAndIgnoreRemainingEvents() + } + + assertThat(repo.deleteAccountArgs).isEqualTo("adron" to "adron@example.com") + assertThat(vm.uiState.value.isDeletingAccount).isFalse() + } + + @Test + fun `deleteAccount surfaces an error and emits no effect on failure`() = runTest(dispatcher) { + repo.currentUserFlow.value = testUser(username = "adron") + repo.deleteAccountResult = ApiResult.Failure(AppError.Forbidden("Confirmation did not match")) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.effects.test { + vm.deleteAccount("wrong@example.com") + advanceUntilIdle() + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.isDeletingAccount).isFalse() + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ConnectedAccountsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ConnectedAccountsViewModelTest.kt new file mode 100644 index 0000000..af45ab9 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/ConnectedAccountsViewModelTest.kt @@ -0,0 +1,94 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.account.ConnectedAccountsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ConnectedAccountsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads identities`() = runTest(dispatcher) { + repo.identitiesResult = ApiResult.Success( + listOf( + testIdentity(id = "i1", provider = "linkedin"), + testIdentity(id = "i2", provider = "mastodon:techhub.social"), + ), + ) + + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + assertThat(repo.identitiesCount).isEqualTo(1) + assertThat(vm.uiState.value.identities.map { it.id }).containsExactly("i1", "i2").inOrder() + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `unlink removes the row optimistically and calls the repository with the provider`() = runTest(dispatcher) { + repo.identitiesResult = ApiResult.Success( + listOf( + testIdentity(id = "i1", provider = "linkedin"), + testIdentity(id = "i2", provider = "mastodon:techhub.social"), + ), + ) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.unlink("i2") + advanceUntilIdle() + + // The API keys on the provider, not the identity id. + assertThat(repo.unlinkedProvider).isEqualTo("mastodon:techhub.social") + assertThat(vm.uiState.value.identities.map { it.id }).containsExactly("i1") + assertThat(vm.uiState.value.pendingUnlinkIds).isEmpty() + } + + @Test + fun `unlink rolls the row back and surfaces an error on failure`() = runTest(dispatcher) { + repo.identitiesResult = ApiResult.Success(listOf(testIdentity(id = "i1", provider = "linkedin"))) + repo.unlinkIdentityResult = ApiResult.Failure(AppError.Server("boom")) + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + vm.unlink("i1") + advanceUntilIdle() + + assertThat(vm.uiState.value.identities.map { it.id }).containsExactly("i1") + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.pendingUnlinkIds).isEmpty() + } + + @Test + fun `a load failure surfaces a mapped error`() = runTest(dispatcher) { + repo.identitiesResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = ConnectedAccountsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt index 424c34c..0b17f1b 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt @@ -7,6 +7,8 @@ import com.interlinedlist.android.feature.profile.data.ProfileRepository import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser +import com.interlinedlist.android.feature.profile.domain.LinkedIdentity +import com.interlinedlist.android.feature.profile.domain.LoginSession import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.MutableStateFlow @@ -146,6 +148,52 @@ class FakeProfileRepository : ProfileRepository { removedFollowerUserId = userId return removeFollowerResult } + + // --- Account & Security --- + + var sessionsResult: ApiResult> = ApiResult.Success(emptyList()) + var revokeSessionResult: ApiResult = ApiResult.Success(Unit) + var identitiesResult: ApiResult> = ApiResult.Success(emptyList()) + var unlinkIdentityResult: ApiResult = ApiResult.Success(Unit) + var requestEmailChangeResult: ApiResult = ApiResult.Success(Unit) + var deleteAccountResult: ApiResult = ApiResult.Success(Unit) + + var sessionsCount = 0 + var revokedSessionId: String? = null + var identitiesCount = 0 + var unlinkedProvider: String? = null + var requestedEmail: String? = null + var deleteAccountArgs: Pair? = null + + override suspend fun getSessions(): ApiResult> { + sessionsCount++ + return sessionsResult + } + + override suspend fun revokeSession(sessionId: String): ApiResult { + revokedSessionId = sessionId + return revokeSessionResult + } + + override suspend fun getIdentities(): ApiResult> { + identitiesCount++ + return identitiesResult + } + + override suspend fun unlinkIdentity(provider: String): ApiResult { + unlinkedProvider = provider + return unlinkIdentityResult + } + + override suspend fun requestEmailChange(newEmail: String): ApiResult { + requestedEmail = newEmail + return requestEmailChangeResult + } + + override suspend fun deleteAccount(username: String, email: String): ApiResult { + deleteAccountArgs = username to email + return deleteAccountResult + } } /** Shorthand for building a follow-list/request user in tests. */ @@ -181,3 +229,33 @@ fun testSearchResult( username: String = "user$id", displayName: String? = "User $id", ) = UserSearchResult(id = id, username = username, displayName = displayName, avatarUrl = null) + +/** Shorthand for building a login session in tests. */ +fun testSession( + id: String = "s1", + deviceLabel: String = "Pixel 8", + createdAt: String? = "2026-07-31T21:37:00.000Z", + lastUsedAt: String? = "2026-07-31T21:49:00.000Z", + isCurrent: Boolean = false, +) = LoginSession( + id = id, + deviceLabel = deviceLabel, + createdAt = createdAt, + lastUsedAt = lastUsedAt, + isCurrent = isCurrent, +) + +/** Shorthand for building a linked identity in tests. */ +fun testIdentity( + id: String = "i1", + provider: String = "linkedin", + providerUsername: String? = "Adron Hall", + connectedAt: String? = "2026-06-12T07:33:42.447Z", +) = LinkedIdentity( + id = id, + provider = provider, + providerUsername = providerUsername, + profileUrl = null, + avatarUrl = null, + connectedAt = connectedAt, +) diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/RelativeTimeTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/RelativeTimeTest.kt new file mode 100644 index 0000000..3cbf255 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/RelativeTimeTest.kt @@ -0,0 +1,43 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.feature.profile.ui.account.relativeTime +import org.junit.Test +import java.time.Instant + +class RelativeTimeTest { + + private val now = Instant.parse("2026-07-31T12:00:00.000Z") + + @Test + fun `null timestamp returns the fallback`() { + assertThat(relativeTime(null, now = now)).isEqualTo("Never") + } + + @Test + fun `unparseable timestamp returns the fallback`() { + assertThat(relativeTime("not-a-date", now = now)).isEqualTo("Never") + } + + @Test + fun `under a minute reads as just now`() { + assertThat(relativeTime("2026-07-31T11:59:30.000Z", now = now)).isEqualTo("just now") + } + + @Test + fun `minutes are pluralized`() { + assertThat(relativeTime("2026-07-31T11:59:00.000Z", now = now)).isEqualTo("1 minute ago") + assertThat(relativeTime("2026-07-31T11:55:00.000Z", now = now)).isEqualTo("5 minutes ago") + } + + @Test + fun `hours and days are formatted`() { + assertThat(relativeTime("2026-07-31T09:00:00.000Z", now = now)).isEqualTo("3 hours ago") + assertThat(relativeTime("2026-07-28T12:00:00.000Z", now = now)).isEqualTo("3 days ago") + } + + @Test + fun `a future timestamp is clamped to just now`() { + assertThat(relativeTime("2026-07-31T12:05:00.000Z", now = now)).isEqualTo("just now") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SessionsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SessionsViewModelTest.kt new file mode 100644 index 0000000..e770192 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SessionsViewModelTest.kt @@ -0,0 +1,121 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.account.SessionsViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads sessions`() = runTest(dispatcher) { + repo.sessionsResult = ApiResult.Success( + listOf( + testSession(id = "s1", isCurrent = true), + testSession(id = "s2", isCurrent = false), + ), + ) + + val vm = SessionsViewModel(repo) + advanceUntilIdle() + + assertThat(repo.sessionsCount).isEqualTo(1) + assertThat(vm.uiState.value.sessions.map { it.id }).containsExactly("s1", "s2").inOrder() + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `no sessions flags isEmpty`() = runTest(dispatcher) { + repo.sessionsResult = ApiResult.Success(emptyList()) + + val vm = SessionsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isEmpty).isTrue() + } + + @Test + fun `revoke removes the row optimistically and calls the repository`() = runTest(dispatcher) { + repo.sessionsResult = ApiResult.Success( + listOf( + testSession(id = "s1", isCurrent = true), + testSession(id = "s2", isCurrent = false), + ), + ) + val vm = SessionsViewModel(repo) + advanceUntilIdle() + + vm.revoke("s2") + advanceUntilIdle() + + assertThat(repo.revokedSessionId).isEqualTo("s2") + assertThat(vm.uiState.value.sessions.map { it.id }).containsExactly("s1") + assertThat(vm.uiState.value.pendingRevokeIds).isEmpty() + } + + @Test + fun `revoke rolls the row back and surfaces an error on failure`() = runTest(dispatcher) { + repo.sessionsResult = ApiResult.Success( + listOf( + testSession(id = "s1", isCurrent = true), + testSession(id = "s2", isCurrent = false), + ), + ) + repo.revokeSessionResult = ApiResult.Failure(AppError.Server("boom")) + val vm = SessionsViewModel(repo) + advanceUntilIdle() + + vm.revoke("s2") + advanceUntilIdle() + + assertThat(vm.uiState.value.sessions.map { it.id }).containsExactly("s1", "s2") + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.pendingRevokeIds).isEmpty() + } + + @Test + fun `revoke ignores the current device`() = runTest(dispatcher) { + repo.sessionsResult = ApiResult.Success(listOf(testSession(id = "s1", isCurrent = true))) + val vm = SessionsViewModel(repo) + advanceUntilIdle() + + vm.revoke("s1") + advanceUntilIdle() + + assertThat(repo.revokedSessionId).isNull() + assertThat(vm.uiState.value.sessions.map { it.id }).containsExactly("s1") + } + + @Test + fun `a load failure surfaces a mapped error`() = runTest(dispatcher) { + repo.sessionsResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = SessionsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + } +} From ff6111adbca4ae3107d584656de7975bb2710d9c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 15:20:26 -0700 Subject: [PATCH 12/25] feat(dm): add Direct Messages module (Milestone A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New self-contained :feature:directmessages — inbox (cursor paged), thread with image attach + read receipts + lifecycle-scoped update polling, and a recipient picker. Offline-first (own Room db interlinedlist-dm.db), owns its DirectMessagesApi from the shared authed Retrofit. Endpoints: /api/dm (+ {id} read/trash/restore), /api/dm/recipients, /api/dm/thread/{username}(+/updates), /api/dm/unread-count, /api/dm/images/upload. 25 unit tests green. Nav wiring deferred (snippet in report). Co-Authored-By: Claude Opus 4.8 (1M context) --- feature/directmessages/build.gradle.kts | 83 +++++ .../src/androidTest/AndroidManifest.xml | 2 + .../directmessages/ui/InboxScreenTest.kt | 82 +++++ .../directmessages/ui/ThreadScreenTest.kt | 102 ++++++ .../src/main/AndroidManifest.xml | 2 + .../data/CurrentUserIdProvider.kt | 10 + .../data/DefaultDirectMessagesRepository.kt | 206 ++++++++++++ .../data/DirectMessagesRepository.kt | 61 ++++ .../feature/directmessages/data/DmMappers.kt | 66 ++++ .../feature/directmessages/data/DmModels.kt | 33 ++ .../data/local/ConversationDao.kt | 28 ++ .../data/local/ConversationEntity.kt | 20 ++ .../data/local/DirectMessageDao.kt | 53 ++++ .../data/local/DirectMessageEntity.kt | 28 ++ .../data/local/DirectMessagesDatabase.kt | 20 ++ .../directmessages/data/local/DmConverters.kt | 20 ++ .../data/remote/DirectMessagesApi.kt | 87 +++++ .../data/remote/dto/DmResponses.kt | 104 ++++++ .../data/remote/dto/MessageDto.kt | 36 +++ .../data/remote/dto/RecipientDto.kt | 23 ++ .../directmessages/di/DirectMessagesModule.kt | 75 +++++ .../navigation/DirectMessagesNavigation.kt | 69 ++++ .../feature/directmessages/ui/DmAvatar.kt | 53 ++++ .../directmessages/ui/DmErrorMessages.kt | 15 + .../directmessages/ui/inbox/InboxScreen.kt | 215 +++++++++++++ .../directmessages/ui/inbox/InboxViewModel.kt | 82 +++++ .../directmessages/ui/model/MessageBubble.kt | 28 ++ .../ui/newmessage/NewMessageScreen.kt | 178 +++++++++++ .../ui/newmessage/NewMessageViewModel.kt | 65 ++++ .../directmessages/ui/thread/ThreadScreen.kt | 277 ++++++++++++++++ .../ui/thread/ThreadViewModel.kt | 148 +++++++++ .../DefaultDirectMessagesRepositoryTest.kt | 298 ++++++++++++++++++ .../feature/directmessages/data/FakeDaos.kt | 81 +++++ .../ui/FakeDirectMessagesRepository.kt | 84 +++++ .../ui/inbox/InboxViewModelTest.kt | 117 +++++++ .../ui/newmessage/NewMessageViewModelTest.kt | 70 ++++ .../ui/thread/ThreadViewModelTest.kt | 138 ++++++++ settings.gradle.kts | 1 + 38 files changed, 3060 insertions(+) create mode 100644 feature/directmessages/build.gradle.kts create mode 100644 feature/directmessages/src/androidTest/AndroidManifest.xml create mode 100644 feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt create mode 100644 feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/ThreadScreenTest.kt create mode 100644 feature/directmessages/src/main/AndroidManifest.xml create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/CurrentUserIdProvider.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageDao.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageEntity.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DmConverters.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/RecipientDto.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/di/DirectMessagesModule.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/navigation/DirectMessagesNavigation.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmAvatar.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmErrorMessages.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxScreen.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModel.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/model/MessageBubble.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageScreen.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModel.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadScreen.kt create mode 100644 feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModel.kt create mode 100644 feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt create mode 100644 feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt create mode 100644 feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/FakeDirectMessagesRepository.kt create mode 100644 feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt create mode 100644 feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModelTest.kt create mode 100644 feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModelTest.kt diff --git a/feature/directmessages/build.gradle.kts b/feature/directmessages/build.gradle.kts new file mode 100644 index 0000000..25f41e4 --- /dev/null +++ b/feature/directmessages/build.gradle.kts @@ -0,0 +1,83 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.directmessages" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } + + testOptions { + unitTests { + isReturnDefaultValues = true + } + } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + implementation(project(":core:datastore")) + + // Compose + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.navigation.compose) + implementation(libs.coil.compose) + + // This module owns its Retrofit API surface built from the shared authed + // Retrofit provided by :core:network. + implementation(libs.retrofit.core) + implementation(libs.okhttp.core) + implementation(libs.kotlinx.serialization.json) + + // This module owns its own Room database (interlinedlist-dm.db). + implementation(libs.room.runtime) + implementation(libs.room.ktx) + ksp(libs.room.compiler) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.kotlinx.serialization) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/directmessages/src/androidTest/AndroidManifest.xml b/feature/directmessages/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/directmessages/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt b/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt new file mode 100644 index 0000000..ab9e197 --- /dev/null +++ b/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/InboxScreenTest.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.feature.directmessages.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.directmessages.data.Conversation +import com.interlinedlist.android.feature.directmessages.ui.inbox.InboxScreen +import com.interlinedlist.android.feature.directmessages.ui.inbox.InboxTestTags +import com.interlinedlist.android.feature.directmessages.ui.inbox.InboxUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InboxScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun conversation(username: String, unread: Boolean) = Conversation( + username = username, displayName = "Adron Hall", avatarUrl = null, + lastMessageBody = "hey there", lastMessageAtMillis = 1L, hasUnread = unread, + ) + + @Test + fun emptyState_isShown_whenNoConversations() { + composeRule.setContent { + InterlinedListTheme { + InboxScreen( + state = InboxUiState(conversations = emptyList()), + onRefresh = {}, + onOpenThread = {}, + onComposeNew = {}, + ) + } + } + + composeRule.onNodeWithTag(InboxTestTags.EMPTY).assertIsDisplayed() + } + + @Test + fun conversationRow_showsUnreadDot_andOpensThreadOnClick() { + var opened: String? = null + composeRule.setContent { + InterlinedListTheme { + InboxScreen( + state = InboxUiState( + conversations = listOf(conversation("adron", unread = true)), + ), + onRefresh = {}, + onOpenThread = { opened = it }, + onComposeNew = {}, + ) + } + } + + composeRule.onNodeWithTag(InboxTestTags.UNREAD_DOT).assertIsDisplayed() + composeRule.onNodeWithTag(InboxTestTags.row("adron")).performClick() + assert(opened == "adron") + } + + @Test + fun composeFab_invokesCallback() { + var composed = false + composeRule.setContent { + InterlinedListTheme { + InboxScreen( + state = InboxUiState(conversations = listOf(conversation("adron", false))), + onRefresh = {}, + onOpenThread = {}, + onComposeNew = { composed = true }, + ) + } + } + + composeRule.onNodeWithTag(InboxTestTags.COMPOSE_FAB).performClick() + assert(composed) + } +} diff --git a/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/ThreadScreenTest.kt b/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/ThreadScreenTest.kt new file mode 100644 index 0000000..8844c62 --- /dev/null +++ b/feature/directmessages/src/androidTest/kotlin/com/interlinedlist/android/feature/directmessages/ui/ThreadScreenTest.kt @@ -0,0 +1,102 @@ +package com.interlinedlist.android.feature.directmessages.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.directmessages.ui.model.MessageBubble +import com.interlinedlist.android.feature.directmessages.ui.thread.ThreadScreen +import com.interlinedlist.android.feature.directmessages.ui.thread.ThreadTestTags +import com.interlinedlist.android.feature.directmessages.ui.thread.ThreadUiState +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ThreadScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun bubble(id: String, mine: Boolean, body: String = "hello") = MessageBubble( + id = id, body = body, imageUrls = emptyList(), isMine = mine, + isRead = false, isPending = false, createdAtMillis = 1L, + ) + + @Test + fun rendersMineAndTheirBubbles() { + composeRule.setContent { + InterlinedListTheme { + ThreadScreen( + state = ThreadUiState( + username = "adron", + messages = listOf( + bubble("m1", mine = false, body = "hi from them"), + bubble("m2", mine = true, body = "hi from me"), + ), + isLoading = false, + ), + onBack = {}, + onDraftChange = {}, + onSend = {}, + onAttachImage = {}, + ) + } + } + + composeRule.onNodeWithTag(ThreadTestTags.bubble("m1")).assertIsDisplayed() + composeRule.onNodeWithTag(ThreadTestTags.bubble("m2")).assertIsDisplayed() + // The read-receipt label appears on the user's own message. + composeRule.onNodeWithTag(ThreadTestTags.READ_RECEIPT).assertIsDisplayed() + } + + @Test + fun composer_enablesSend_whenDraftNotBlank_andSends() { + var sent = false + composeRule.setContent { + var state by mutableStateOf( + ThreadUiState(username = "adron", messages = emptyList(), isLoading = false), + ) + InterlinedListTheme { + ThreadScreen( + state = state, + onBack = {}, + onDraftChange = { state = state.copy(draft = it) }, + onSend = { sent = true }, + onAttachImage = {}, + ) + } + } + + composeRule.onNodeWithTag(ThreadTestTags.SEND).assertIsNotEnabled() + composeRule.onNodeWithTag(ThreadTestTags.COMPOSER).performTextInput("hey") + composeRule.onNodeWithTag(ThreadTestTags.SEND).assertIsEnabled() + composeRule.onNodeWithTag(ThreadTestTags.SEND).performClick() + assert(sent) + } + + @Test + fun emptyState_isShown_whenNoMessages() { + composeRule.setContent { + InterlinedListTheme { + ThreadScreen( + state = ThreadUiState(username = "adron", messages = emptyList(), isLoading = false), + onBack = {}, + onDraftChange = {}, + onSend = {}, + onAttachImage = {}, + ) + } + } + + composeRule.onNodeWithTag(ThreadTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/directmessages/src/main/AndroidManifest.xml b/feature/directmessages/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/directmessages/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/CurrentUserIdProvider.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/CurrentUserIdProvider.kt new file mode 100644 index 0000000..a5c4770 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/CurrentUserIdProvider.kt @@ -0,0 +1,10 @@ +package com.interlinedlist.android.feature.directmessages.data + +/** + * Supplies the signed-in user's id so the repository can distinguish messages + * the user sent from ones they received. Abstracted from `SessionStore` (which + * is Android-backed) so the repository stays unit-testable on the plain JVM. + */ +fun interface CurrentUserIdProvider { + fun currentUserId(): String? +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt new file mode 100644 index 0000000..0f3c0af --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepository.kt @@ -0,0 +1,206 @@ +package com.interlinedlist.android.feature.directmessages.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.map +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.directmessages.data.local.ConversationDao +import com.interlinedlist.android.feature.directmessages.data.local.ConversationEntity +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageDao +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity +import com.interlinedlist.android.feature.directmessages.data.remote.DirectMessagesApi +import com.interlinedlist.android.feature.directmessages.data.remote.dto.MessageDto +import com.interlinedlist.android.feature.directmessages.data.remote.dto.RecipientDto +import com.interlinedlist.android.feature.directmessages.data.remote.dto.SendMessageRequest +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.time.Instant +import javax.inject.Inject + +/** + * Offline-first Direct Messages repository. Room is the single source of truth: + * `observe*` return Flows straight from the DAOs, and every network call folds + * its result back into the cache so the UI updates reactively. + */ +class DefaultDirectMessagesRepository @Inject constructor( + private val api: DirectMessagesApi, + private val messageDao: DirectMessageDao, + private val conversationDao: ConversationDao, + private val currentUserIdProvider: CurrentUserIdProvider, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : DirectMessagesRepository { + + override val currentUserId: String? get() = currentUserIdProvider.currentUserId() + + override fun observeConversations(): Flow> = + conversationDao.observeConversations().map { list -> list.map { it.toDomain() } } + + override fun observeThread(username: String): Flow> = + messageDao.observeThread(username).map { list -> list.map { it.toDomain() } } + + override suspend fun refreshInbox(cursor: String?): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getInbox(cursor = cursor) }.let { result -> + when (result) { + is ApiResult.Success -> { + val summaries = result.data.items.mapNotNull { it.toConversationSummary() } + if (summaries.isNotEmpty()) conversationDao.upsertAll(summaries) + ApiResult.Success(result.data.nextCursor) + } + is ApiResult.Failure -> result + } + } + } + + override suspend fun refreshThread(username: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getThread(username) }.let { result -> + when (result) { + is ApiResult.Success -> { + val entities = result.data.items.map { it.toEntity(username) } + if (entities.isNotEmpty()) messageDao.upsertAll(entities) + conversationDao.clearUnread(username) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + } + + override suspend fun pollThreadUpdates(username: String): ApiResult = + withContext(dispatchers.io) { + val after = messageDao.latestCreatedAt(username) + safeApiCall(json) { api.getThreadUpdates(username, after = after) }.let { result -> + when (result) { + is ApiResult.Success -> { + val known = messageDao.existingIds(username).toSet() + val fresh = result.data.items + .map { it.toEntity(username) } + .filter { it.id !in known } + if (fresh.isNotEmpty()) messageDao.upsertAll(fresh) + ApiResult.Success(fresh.size) + } + is ApiResult.Failure -> result + } + } + } + + override suspend fun send( + username: String, + body: String, + imageUrls: List, + ): ApiResult = withContext(dispatchers.io) { + // 1) Optimistic local echo so the message appears instantly. + val nowIso = Instant.now().toString() + val optimisticId = "local-${System.nanoTime()}" + val optimistic = DirectMessageEntity( + id = optimisticId, + conversationUsername = username, + senderId = currentUserId.orEmpty(), + recipientId = "", + body = body, + imageUrls = imageUrls, + createdAt = nowIso, + createdAtMillis = parseIsoMillis(nowIso), + readAt = null, + trashed = false, + pending = true, + ) + messageDao.upsert(optimistic) + + // 2) Send, then swap the optimistic copy for the server's authoritative one. + val request = SendMessageRequest( + recipientUsername = username, + body = body, + imageUrls = imageUrls, + ) + when (val result = safeApiCall(json) { api.send(request) }) { + is ApiResult.Success -> { + val serverMessage = result.data.message() + if (serverMessage != null && serverMessage.id.isNotBlank()) { + messageDao.deleteById(optimisticId) + val entity = serverMessage.toEntity(username) + messageDao.upsert(entity) + ApiResult.Success(entity.toDomain()) + } else { + // Server accepted but returned no body; keep the optimistic copy. + val confirmed = optimistic.copy(pending = false) + messageDao.upsert(confirmed) + ApiResult.Success(confirmed.toDomain()) + } + } + is ApiResult.Failure -> result // optimistic copy remains for retry + } + } + + override suspend fun markRead(id: String): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { api.markRead(id) }.let { result -> + when (result) { + is ApiResult.Success -> { + messageDao.setReadAt(id, Instant.now().toString()) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + } + + override suspend fun trash(id: String): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { api.trash(id) }.let { result -> + when (result) { + is ApiResult.Success -> { + messageDao.setTrashed(id, true) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + } + + override suspend fun restore(id: String, username: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.restore(id) }.let { result -> + when (result) { + is ApiResult.Success -> { + messageDao.setTrashed(id, false) + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + } + + override suspend fun recipients(): ApiResult> = withContext(dispatchers.io) { + safeApiCall(json) { api.getRecipients() } + .map { response -> response.recipients.map(RecipientDto::toDomain) } + } + + override suspend fun unreadCount(): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { api.getUnreadCount() }.map { it.count } + } + + /** + * Folds an inbox message into a conversation summary. The other participant + * is whichever end of the message is not the current user; the embedded + * author sub-object (present on the inbox endpoint) supplies display info. + */ + private fun MessageDto.toConversationSummary(): ConversationEntity? { + val me = currentUserId + val other = embeddedAuthor + val otherUsername = other?.username + ?: return null // Without a username we cannot key the conversation. + val received = me != null && recipientId == me + return ConversationEntity( + username = otherUsername, + displayName = other.displayName, + avatarUrl = other.avatar, + lastMessageId = id, + lastMessageBody = body, + lastMessageAtMillis = parseIsoMillis(createdAt), + hasUnread = received && readAt == null, + ) + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt new file mode 100644 index 0000000..cfaca94 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DirectMessagesRepository.kt @@ -0,0 +1,61 @@ +package com.interlinedlist.android.feature.directmessages.data + +import com.interlinedlist.android.core.common.result.ApiResult +import kotlinx.coroutines.flow.Flow + +/** + * Offline-first access to Direct Messages. Room is the single source of truth: + * reads are exposed as Flows from the local cache and refresh calls reconcile it + * against the API. + */ +interface DirectMessagesRepository { + + /** The signed-in user's id, used to tell "mine" from "theirs" in a thread. */ + val currentUserId: String? + + /** Newest-first stream of cached conversation summaries. */ + fun observeConversations(): Flow> + + /** Oldest-first stream of a single conversation's cached messages. */ + fun observeThread(username: String): Flow> + + /** + * Refreshes one page of the inbox. Pass `null` to load the first page; pass + * the previous result's cursor to page. Returns the next cursor (or null). + */ + suspend fun refreshInbox(cursor: String? = null): ApiResult + + /** Loads and caches the full thread with [username]. */ + suspend fun refreshThread(username: String): ApiResult + + /** + * Polls for messages newer than the newest cached one and merges them. + * Returns the number of newly merged messages. + */ + suspend fun pollThreadUpdates(username: String): ApiResult + + /** + * Sends a message to [username]. The message is cached optimistically before + * the network round-trip and reconciled with the server's copy on success. + */ + suspend fun send( + username: String, + body: String, + imageUrls: List = emptyList(), + ): ApiResult + + /** Marks a received message read locally and on the server. */ + suspend fun markRead(id: String): ApiResult + + /** Soft-deletes the caller's side of a message. */ + suspend fun trash(id: String): ApiResult + + /** Restores a previously trashed message. */ + suspend fun restore(id: String, username: String): ApiResult + + /** The people the current user can DM. */ + suspend fun recipients(): ApiResult> + + /** The number of unread received DMs. */ + suspend fun unreadCount(): ApiResult +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt new file mode 100644 index 0000000..c999b1f --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmMappers.kt @@ -0,0 +1,66 @@ +package com.interlinedlist.android.feature.directmessages.data + +import com.interlinedlist.android.feature.directmessages.data.local.ConversationEntity +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity +import com.interlinedlist.android.feature.directmessages.data.remote.dto.MessageDto +import com.interlinedlist.android.feature.directmessages.data.remote.dto.RecipientDto +import java.time.Instant +import java.time.format.DateTimeParseException + +/** Parses an ISO-8601 timestamp into epoch millis, tolerating blanks/garbage. */ +internal fun parseIsoMillis(value: String?): Long { + if (value.isNullOrBlank()) return 0L + return try { + Instant.parse(value).toEpochMilli() + } catch (_: DateTimeParseException) { + 0L + } +} + +/** + * Maps a message DTO into an entity for a known conversation partner. + * [conversationUsername] is the other participant's username. + */ +internal fun MessageDto.toEntity(conversationUsername: String): DirectMessageEntity = + DirectMessageEntity( + id = id, + conversationUsername = conversationUsername, + senderId = senderId, + recipientId = recipientId, + body = body, + imageUrls = imageUrls, + createdAt = createdAt, + createdAtMillis = parseIsoMillis(createdAt), + readAt = readAt, + trashed = false, + pending = false, + ) + +internal fun DirectMessageEntity.toDomain(): DirectMessage = DirectMessage( + id = id, + conversationUsername = conversationUsername, + senderId = senderId, + recipientId = recipientId, + body = body, + imageUrls = imageUrls, + createdAt = createdAt, + createdAtMillis = createdAtMillis, + readAt = readAt, + pending = pending, +) + +internal fun ConversationEntity.toDomain(): Conversation = Conversation( + username = username, + displayName = displayName, + avatarUrl = avatarUrl, + lastMessageBody = lastMessageBody, + lastMessageAtMillis = lastMessageAtMillis, + hasUnread = hasUnread, +) + +internal fun RecipientDto.toDomain(): Recipient = Recipient( + id = id, + username = username, + displayName = displayName, + avatarUrl = avatar, +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt new file mode 100644 index 0000000..5089a96 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/DmModels.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.directmessages.data + +/** A conversation summary shown in the inbox list. */ +data class Conversation( + val username: String, + val displayName: String?, + val avatarUrl: String?, + val lastMessageBody: String, + val lastMessageAtMillis: Long, + val hasUnread: Boolean, +) + +/** A single direct message shown in a thread. */ +data class DirectMessage( + val id: String, + val conversationUsername: String, + val senderId: String, + val recipientId: String, + val body: String, + val imageUrls: List, + val createdAt: String, + val createdAtMillis: Long, + val readAt: String?, + val pending: Boolean, +) + +/** A person the current user can start a conversation with. */ +data class Recipient( + val id: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt new file mode 100644 index 0000000..ad43c1a --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationDao.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.directmessages.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Access to cached conversation summaries. Declared as an interface so tests can + * substitute a fast in-memory fake without pulling in the Android/Room runtime. + */ +@Dao +interface ConversationDao { + + /** Newest-first stream of conversations for the inbox; re-emits on change. */ + @Query("SELECT * FROM dm_conversation ORDER BY lastMessageAtMillis DESC") + fun observeConversations(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(conversations: List) + + @Query("UPDATE dm_conversation SET hasUnread = 0 WHERE username = :username") + suspend fun clearUnread(username: String) + + @Query("DELETE FROM dm_conversation") + suspend fun clear() +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt new file mode 100644 index 0000000..bdccf78 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/ConversationEntity.kt @@ -0,0 +1,20 @@ +package com.interlinedlist.android.feature.directmessages.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A per-conversation summary backing the inbox list. Keyed by the other + * participant's username so it aligns with the thread endpoints. + */ +@Entity(tableName = "dm_conversation") +data class ConversationEntity( + @PrimaryKey val username: String, + val displayName: String?, + val avatarUrl: String?, + val lastMessageId: String, + val lastMessageBody: String, + val lastMessageAtMillis: Long, + /** Whether the latest received message in this conversation is unread. */ + val hasUnread: Boolean, +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageDao.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageDao.kt new file mode 100644 index 0000000..4266014 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageDao.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.directmessages.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +/** + * Access to cached direct messages. An interface so unit tests can back it with + * an in-memory fake, keeping repository tests on the plain JVM. + */ +@Dao +interface DirectMessageDao { + + /** Oldest-first stream of a single conversation's non-trashed messages. */ + @Query( + "SELECT * FROM dm_message " + + "WHERE conversationUsername = :username AND trashed = 0 " + + "ORDER BY createdAtMillis ASC", + ) + fun observeThread(username: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(messages: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(message: DirectMessageEntity) + + @Query("UPDATE dm_message SET readAt = :readAt WHERE id = :id") + suspend fun setReadAt(id: String, readAt: String) + + @Query("UPDATE dm_message SET trashed = :trashed WHERE id = :id") + suspend fun setTrashed(id: String, trashed: Boolean) + + @Query("DELETE FROM dm_message WHERE id = :id") + suspend fun deleteById(id: String) + + /** The raw ISO timestamp of the newest cached message in a conversation. */ + @Query( + "SELECT createdAt FROM dm_message " + + "WHERE conversationUsername = :username " + + "ORDER BY createdAtMillis DESC LIMIT 1", + ) + suspend fun latestCreatedAt(username: String): String? + + /** Ids of every cached message in a conversation, for merge de-duplication. */ + @Query("SELECT id FROM dm_message WHERE conversationUsername = :username") + suspend fun existingIds(username: String): List + + @Query("DELETE FROM dm_message") + suspend fun clear() +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageEntity.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageEntity.kt new file mode 100644 index 0000000..8c30d58 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessageEntity.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.directmessages.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A locally cached direct message — the single source of truth for a thread. + * + * [conversationUsername] is the *other* participant's username, so all messages + * of a conversation can be queried regardless of send direction. [createdAtMillis] + * is the parsed epoch of [createdAt] for stable ordering; [createdAt] keeps the + * raw ISO string for round-tripping to the API's `after` cursor. + */ +@Entity(tableName = "dm_message") +data class DirectMessageEntity( + @PrimaryKey val id: String, + val conversationUsername: String, + val senderId: String, + val recipientId: String, + val body: String, + val imageUrls: List, + val createdAt: String, + val createdAtMillis: Long, + val readAt: String?, + val trashed: Boolean, + /** True while an optimistic local send has not yet been confirmed by the server. */ + val pending: Boolean = false, +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt new file mode 100644 index 0000000..33f050f --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DirectMessagesDatabase.kt @@ -0,0 +1,20 @@ +package com.interlinedlist.android.feature.directmessages.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +/** + * This module's own Room database, `interlinedlist-dm.db`, kept separate from + * `:core:database` so Direct Messages own their offline cache end-to-end. + */ +@Database( + entities = [DirectMessageEntity::class, ConversationEntity::class], + version = 1, + exportSchema = false, +) +@TypeConverters(DmConverters::class) +abstract class DirectMessagesDatabase : RoomDatabase() { + abstract fun messageDao(): DirectMessageDao + abstract fun conversationDao(): ConversationDao +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DmConverters.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DmConverters.kt new file mode 100644 index 0000000..10047fd --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/local/DmConverters.kt @@ -0,0 +1,20 @@ +package com.interlinedlist.android.feature.directmessages.data.local + +import androidx.room.TypeConverter + +/** Room converters for the module's non-primitive columns. */ +class DmConverters { + + @TypeConverter + fun fromStringList(value: List): String = + if (value.isEmpty()) "" else value.joinToString(SEPARATOR) + + @TypeConverter + fun toStringList(value: String): List = + if (value.isBlank()) emptyList() else value.split(SEPARATOR) + + private companion object { + // Image URLs never contain a newline, so it is a safe delimiter. + const val SEPARATOR = "\n" + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt new file mode 100644 index 0000000..645710c --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/DirectMessagesApi.kt @@ -0,0 +1,87 @@ +package com.interlinedlist.android.feature.directmessages.data.remote + +import com.interlinedlist.android.feature.directmessages.data.remote.dto.ImageUploadResponse +import com.interlinedlist.android.feature.directmessages.data.remote.dto.InboxResponse +import com.interlinedlist.android.feature.directmessages.data.remote.dto.MessageDto +import com.interlinedlist.android.feature.directmessages.data.remote.dto.RecipientsResponse +import com.interlinedlist.android.feature.directmessages.data.remote.dto.SendMessageRequest +import com.interlinedlist.android.feature.directmessages.data.remote.dto.SendMessageResponse +import com.interlinedlist.android.feature.directmessages.data.remote.dto.ThreadResponse +import com.interlinedlist.android.feature.directmessages.data.remote.dto.ThreadUpdatesResponse +import com.interlinedlist.android.feature.directmessages.data.remote.dto.UnreadCountResponse +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Retrofit description of the Direct Messages REST API. + * + * This interface is owned by `:feature:directmessages` and is created from the + * shared, already-authenticated `Retrofit` provided by `:core:network`; the + * bearer token is injected by the shared OkHttp interceptor, so no auth headers + * are declared here. + */ +interface DirectMessagesApi { + + /** Lists a DM folder (default inbox), cursor-paginated by `nextCursor`. */ + @GET("api/dm") + suspend fun getInbox( + @Query("folder") folder: String? = null, + @Query("cursor") cursor: String? = null, + @Query("take") take: Int? = null, + ): InboxResponse + + /** Sends a direct message. */ + @POST("api/dm") + suspend fun send(@Body body: SendMessageRequest): SendMessageResponse + + /** Fetches a single message the current user participates in. */ + @GET("api/dm/{id}") + suspend fun getMessage(@Path("id") id: String): MessageDto + + /** Marks a received message read (recipient-scoped). */ + @POST("api/dm/{id}/read") + suspend fun markRead(@Path("id") id: String) + + /** Soft-deletes the caller's own side of a message. */ + @POST("api/dm/{id}/trash") + suspend fun trash(@Path("id") id: String) + + /** Clears the caller's own side soft-delete. */ + @POST("api/dm/{id}/restore") + suspend fun restore(@Path("id") id: String) + + /** The people the current user can DM. */ + @GET("api/dm/recipients") + suspend fun getRecipients(): RecipientsResponse + + /** The conversation thread with `username`; marks received-unread messages read. */ + @GET("api/dm/thread/{username}") + suspend fun getThread( + @Path("username") username: String, + @Query("cursor") cursor: String? = null, + @Query("take") take: Int? = null, + ): ThreadResponse + + /** Incremental fetch of messages after [after], for polling an open thread. */ + @GET("api/dm/thread/{username}/updates") + suspend fun getThreadUpdates( + @Path("username") username: String, + @Query("after") after: String? = null, + @Query("since") since: String? = null, + ): ThreadUpdatesResponse + + /** The number of unread received DMs. */ + @GET("api/dm/unread-count") + suspend fun getUnreadCount(): UnreadCountResponse + + /** Uploads an image attachment for a direct message. */ + @Multipart + @POST("api/dm/images/upload") + suspend fun uploadImage(@Part image: MultipartBody.Part): ImageUploadResponse +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt new file mode 100644 index 0000000..9d29bc5 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/DmResponses.kt @@ -0,0 +1,104 @@ +package com.interlinedlist.android.feature.directmessages.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Response for `GET /api/dm` (inbox/sent folders). + * + * Confirmed live shape: `{ "items": [...], "nextCursor": null }`. Each item is a + * message; the last message of a conversation represents that conversation in + * the inbox folder. + */ +@Serializable +data class InboxResponse( + val items: List = emptyList(), + val nextCursor: String? = null, +) + +/** + * Response for `GET /api/dm/thread/{username}`. + * + * Confirmed live shape: `{ items, olderCursor, isMutual, isBlocked, otherUser }`. + */ +@Serializable +data class ThreadResponse( + val items: List = emptyList(), + val olderCursor: String? = null, + val isMutual: Boolean = false, + val isBlocked: Boolean = false, + val otherUser: RecipientDto? = null, +) + +/** + * Response for `GET /api/dm/thread/{username}/updates`. + * + * Confirmed live shape: `{ "items": [...] }` — the messages newer than the + * caller's `after` cursor. + */ +@Serializable +data class ThreadUpdatesResponse( + val items: List = emptyList(), +) + +/** Response for `GET /api/dm/unread-count`: `{ "count": 0 }`. */ +@Serializable +data class UnreadCountResponse( + val count: Int = 0, +) + +/** + * Request for `POST /api/dm`. + * + * The recipient may be addressed by id or username; both are sent when known so + * the server can resolve either way. + */ +@Serializable +data class SendMessageRequest( + val recipientId: String? = null, + val recipientUsername: String? = null, + val body: String, + val imageUrls: List = emptyList(), +) + +/** + * Response for `POST /api/dm`. Some create endpoints wrap the created object + * under `data`; accept both the bare message and the wrapped form. + */ +@Serializable +data class SendMessageResponse( + val id: String? = null, + val senderId: String? = null, + val recipientId: String? = null, + val body: String? = null, + val imageUrls: List = emptyList(), + val createdAt: String? = null, + val data: MessageDto? = null, +) { + /** The created message, unwrapping the `data` envelope when present. */ + fun message(): MessageDto? = data ?: id?.let { + MessageDto( + id = it, + senderId = senderId ?: "", + recipientId = recipientId ?: "", + body = body ?: "", + imageUrls = imageUrls, + createdAt = createdAt ?: "", + ) + } +} + +/** Response for `POST /api/dm/images/upload`: the hosted URL of the attachment. */ +@Serializable +data class ImageUploadResponse( + val url: String? = null, + val imageUrl: String? = null, + val data: ImageUploadData? = null, +) { + /** The uploaded image URL under whichever key the endpoint used. */ + fun resolvedUrl(): String? = url ?: imageUrl ?: data?.url +} + +@Serializable +data class ImageUploadData( + val url: String? = null, +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt new file mode 100644 index 0000000..5118036 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/MessageDto.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.feature.directmessages.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire model for a single direct message. + * + * Mirrors the `DirectMessage` component schema in the OpenAPI spec: + * `{ id, pairKey, senderId, recipientId, body, imageUrls, createdAt, readAt, + * senderDeletedAt, recipientDeletedAt }`. Timestamps are ISO-8601 strings. + * + * Envelope quirks honoured: the author sub-object appears as `sender`, `author`, + * or `user` depending on the endpoint, and optional fields may arrive as + * explicit `null` — every field therefore has a default. `imageUrls` is a raw + * list because the schema leaves its item type unspecified. + */ +@Serializable +data class MessageDto( + val id: String = "", + val pairKey: String? = null, + val senderId: String = "", + val recipientId: String = "", + val body: String = "", + val imageUrls: List = emptyList(), + val createdAt: String = "", + val readAt: String? = null, + val senderDeletedAt: String? = null, + val recipientDeletedAt: String? = null, + // The author sometimes rides along embedded; accept any of the observed keys. + val sender: RecipientDto? = null, + val author: RecipientDto? = null, + val user: RecipientDto? = null, +) { + /** The embedded author under whichever key the endpoint used, if present. */ + val embeddedAuthor: RecipientDto? get() = sender ?: author ?: user +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/RecipientDto.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/RecipientDto.kt new file mode 100644 index 0000000..8121dc7 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/data/remote/dto/RecipientDto.kt @@ -0,0 +1,23 @@ +package com.interlinedlist.android.feature.directmessages.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * A person the current user can DM. + * + * Confirmed live shape from `GET /api/dm/recipients` and the `otherUser`/`user` + * sub-objects on threads: `{ id, username, displayName, avatar }`. + */ +@Serializable +data class RecipientDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, + val avatar: String? = null, +) + +/** Response for `GET /api/dm/recipients`: `{ "recipients": [...] }`. */ +@Serializable +data class RecipientsResponse( + val recipients: List = emptyList(), +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/di/DirectMessagesModule.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/di/DirectMessagesModule.kt new file mode 100644 index 0000000..5d06007 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/di/DirectMessagesModule.kt @@ -0,0 +1,75 @@ +package com.interlinedlist.android.feature.directmessages.di + +import android.content.Context +import androidx.room.Room +import com.interlinedlist.android.core.datastore.SessionStore +import com.interlinedlist.android.feature.directmessages.data.CurrentUserIdProvider +import com.interlinedlist.android.feature.directmessages.data.DefaultDirectMessagesRepository +import com.interlinedlist.android.feature.directmessages.data.DirectMessagesRepository +import com.interlinedlist.android.feature.directmessages.data.local.ConversationDao +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageDao +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessagesDatabase +import com.interlinedlist.android.feature.directmessages.data.remote.DirectMessagesApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** + * Wires the Direct Messages feature: its Retrofit API built from the shared + * authed [Retrofit] of `:core:network`, its own Room database + * (`interlinedlist-dm.db`) and DAOs, and the repository binding. + */ +@Module +@InstallIn(SingletonComponent::class) +object DirectMessagesModule { + + /** Builds the DM API from the shared, already-authenticated Retrofit. */ + @Provides + @Singleton + fun provideDirectMessagesApi(retrofit: Retrofit): DirectMessagesApi = + retrofit.create(DirectMessagesApi::class.java) + + @Provides + @Singleton + fun provideDirectMessagesDatabase( + @ApplicationContext context: Context, + ): DirectMessagesDatabase = + Room.databaseBuilder( + context, + DirectMessagesDatabase::class.java, + "interlinedlist-dm.db", + ) + // Disposable cache during early development; real migrations come once + // the schema carries irreplaceable local data. + .fallbackToDestructiveMigration() + .build() + + @Provides + fun provideMessageDao(db: DirectMessagesDatabase): DirectMessageDao = db.messageDao() + + @Provides + fun provideConversationDao(db: DirectMessagesDatabase): ConversationDao = db.conversationDao() + + /** Adapts the Android-backed [SessionStore] to the module's id contract. */ + @Provides + @Singleton + fun provideCurrentUserIdProvider(sessionStore: SessionStore): CurrentUserIdProvider = + CurrentUserIdProvider { sessionStore.userId } +} + +/** Repository binding kept separate so the object module above stays pure `@Provides`. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class DirectMessagesBindsModule { + + @Binds + @Singleton + abstract fun bindDirectMessagesRepository( + impl: DefaultDirectMessagesRepository, + ): DirectMessagesRepository +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/navigation/DirectMessagesNavigation.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/navigation/DirectMessagesNavigation.kt new file mode 100644 index 0000000..9cba4b3 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/navigation/DirectMessagesNavigation.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.directmessages.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.interlinedlist.android.feature.directmessages.ui.inbox.InboxRoute +import com.interlinedlist.android.feature.directmessages.ui.newmessage.NewMessageRoute +import com.interlinedlist.android.feature.directmessages.ui.thread.ThreadRoute + +/** Route keys and argument names for the Direct Messages graph. */ +object DirectMessagesDestinations { + /** Conversations / inbox list — the graph's entry route. */ + const val INBOX = "dm/inbox" + + /** Recipient picker to start a new conversation. */ + const val NEW_MESSAGE = "dm/new" + + const val ARG_USERNAME = "username" + + /** Thread with a specific user; navigate via [threadRoute]. */ + const val THREAD = "dm/thread/{$ARG_USERNAME}" + + /** Builds a concrete thread route for [username]. */ + fun threadRoute(username: String): String = "dm/thread/$username" +} + +/** Convenience navigation helpers so callers don't hand-build route strings. */ +fun NavController.navigateToDmInbox() = navigate(DirectMessagesDestinations.INBOX) +fun NavController.navigateToDmThread(username: String) = + navigate(DirectMessagesDestinations.threadRoute(username)) +fun NavController.navigateToNewDm() = navigate(DirectMessagesDestinations.NEW_MESSAGE) + +/** + * Registers the Direct Messages destinations into the host graph. + * + * The app wires this into its top-level NavHost (see the module's report for the + * exact snippet). [onBack] pops the current destination; [onOpenThread] lets the + * host decide how threads are pushed. + */ +fun NavGraphBuilder.directMessagesGraph( + onBack: () -> Unit, + onOpenThread: (username: String) -> Unit, + onComposeNew: () -> Unit, +) { + composable(DirectMessagesDestinations.INBOX) { + InboxRoute( + onOpenThread = onOpenThread, + onComposeNew = onComposeNew, + ) + } + + composable( + route = DirectMessagesDestinations.THREAD, + arguments = listOf( + navArgument(DirectMessagesDestinations.ARG_USERNAME) { type = NavType.StringType }, + ), + ) { + ThreadRoute(onBack = onBack) + } + + composable(DirectMessagesDestinations.NEW_MESSAGE) { + NewMessageRoute( + onBack = onBack, + onRecipientChosen = onOpenThread, + ) + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmAvatar.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmAvatar.kt new file mode 100644 index 0000000..09a0169 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmAvatar.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.directmessages.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage + +/** + * A circular avatar for a DM participant: their image when available, otherwise + * a monogram on the primary container. Uses only colorScheme role colours. + */ +@Composable +fun DmAvatar( + avatarUrl: String?, + fallbackText: String, + modifier: Modifier = Modifier, + size: Int = 44, +) { + val shape = CircleShape + if (!avatarUrl.isNullOrBlank()) { + AsyncImage( + model = avatarUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = modifier + .size(size.dp) + .clip(shape), + ) + } else { + Box( + modifier = modifier + .size(size.dp) + .clip(shape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = fallbackText.take(1).uppercase(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmErrorMessages.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmErrorMessages.kt new file mode 100644 index 0000000..9cbe9e8 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/DmErrorMessages.kt @@ -0,0 +1,15 @@ +package com.interlinedlist.android.feature.directmessages.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the DM UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> "Your session expired. Please sign in again." + is AppError.Forbidden -> message ?: "You can't message this person." + is AppError.NotFound -> message ?: "That conversation is no longer available." + is AppError.RateLimited -> "You're sending messages too quickly. Try again shortly." + is AppError.SubscriptionRequired -> message ?: "This feature requires an active subscription." + is AppError.Server -> "InterlinedList is having trouble right now. Try again shortly." + else -> message ?: "Something went wrong. Please try again." +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxScreen.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxScreen.kt new file mode 100644 index 0000000..2463804 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxScreen.kt @@ -0,0 +1,215 @@ +package com.interlinedlist.android.feature.directmessages.ui.inbox + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Message +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.feature.directmessages.data.Conversation +import com.interlinedlist.android.feature.directmessages.ui.DmAvatar + +/** Stable test tags for the inbox screen. */ +object InboxTestTags { + const val LIST = "dmInboxList" + const val EMPTY = "dmInboxEmpty" + const val COMPOSE_FAB = "dmInboxComposeFab" + const val UNREAD_DOT = "dmInboxUnreadDot" + fun row(username: String) = "dmInboxRow_$username" +} + +/** Hilt-wired entry point for the inbox. */ +@Composable +fun InboxRoute( + onOpenThread: (username: String) -> Unit, + onComposeNew: () -> Unit, + modifier: Modifier = Modifier, + viewModel: InboxViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + InboxScreen( + state = state, + onRefresh = viewModel::refresh, + onLoadMore = viewModel::loadMore, + onOpenThread = onOpenThread, + onComposeNew = onComposeNew, + modifier = modifier, + ) +} + +/** Stateless inbox UI, driveable directly from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun InboxScreen( + state: InboxUiState, + onRefresh: () -> Unit, + onOpenThread: (username: String) -> Unit, + onComposeNew: () -> Unit, + modifier: Modifier = Modifier, + onLoadMore: () -> Unit = {}, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { TopAppBar(title = { Text("Messages") }) }, + floatingActionButton = { + FloatingActionButton( + onClick = onComposeNew, + modifier = Modifier.testTag(InboxTestTags.COMPOSE_FAB), + ) { + Icon(Icons.Filled.Edit, contentDescription = "New message") + } + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = onRefresh, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (state.isEmpty) { + EmptyInbox(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(InboxTestTags.LIST), + ) { + items(state.conversations, key = { it.username }) { conversation -> + ConversationRow( + conversation = conversation, + onClick = { onOpenThread(conversation.username) }, + ) + HorizontalDivider() + } + if (state.canLoadMore) { + item(key = "dmInboxLoadMore") { + LoadingRow() + LaunchedEffect(state.nextCursor) { onLoadMore() } + } + } + } + } + } + } +} + +@Composable +private fun ConversationRow( + conversation: Conversation, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(InboxTestTags.row(conversation.username)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DmAvatar( + avatarUrl = conversation.avatarUrl, + fallbackText = conversation.displayName ?: conversation.username, + ) + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = conversation.displayName ?: conversation.username, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (conversation.hasUnread) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = conversation.lastMessageBody, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (conversation.hasUnread) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (conversation.hasUnread) { + Spacer(Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary) + .testTag(InboxTestTags.UNREAD_DOT), + ) + } + } +} + +@Composable +private fun EmptyInbox(modifier: Modifier = Modifier) { + Column( + modifier = modifier.testTag(InboxTestTags.EMPTY), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Message, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.size(12.dp)) + Text( + text = "No conversations yet", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Start a conversation with the compose button.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun LoadingRow() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp)) + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModel.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModel.kt new file mode 100644 index 0000000..50ca46c --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModel.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.feature.directmessages.ui.inbox + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.directmessages.data.Conversation +import com.interlinedlist.android.feature.directmessages.data.DirectMessagesRepository +import com.interlinedlist.android.feature.directmessages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the Conversations / Inbox list. */ +data class InboxUiState( + val conversations: List = emptyList(), + val isRefreshing: Boolean = false, + val isLoadingMore: Boolean = false, + val nextCursor: String? = null, + val errorMessage: String? = null, +) { + /** Number of conversations with an unread received message, for the badge. */ + val unreadConversationCount: Int get() = conversations.count { it.hasUnread } + val isEmpty: Boolean get() = conversations.isEmpty() + val canLoadMore: Boolean get() = nextCursor != null && !isLoadingMore && !isRefreshing +} + +@HiltViewModel +class InboxViewModel @Inject constructor( + private val repository: DirectMessagesRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(InboxUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + // Room is the source of truth: mirror cached conversations into the state. + viewModelScope.launch { + repository.observeConversations().collect { conversations -> + _uiState.update { it.copy(conversations = conversations) } + } + } + refresh() + } + + /** Pull-to-refresh: reloads the first inbox page. */ + fun refresh() { + _uiState.update { it.copy(isRefreshing = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshInbox(cursor = null)) { + is ApiResult.Success -> _uiState.update { + it.copy(isRefreshing = false, nextCursor = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isRefreshing = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Loads the next inbox page using the stored cursor, if any. */ + fun loadMore() { + val cursor = _uiState.value.nextCursor ?: return + if (_uiState.value.isLoadingMore) return + _uiState.update { it.copy(isLoadingMore = true) } + viewModelScope.launch { + when (val result = repository.refreshInbox(cursor = cursor)) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingMore = false, nextCursor = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingMore = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/model/MessageBubble.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/model/MessageBubble.kt new file mode 100644 index 0000000..a279ba2 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/model/MessageBubble.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.directmessages.ui.model + +import com.interlinedlist.android.feature.directmessages.data.DirectMessage + +/** + * A single message prepared for rendering in a thread: whether it is the current + * user's own message, whether it has been read, and whether it is still sending. + */ +data class MessageBubble( + val id: String, + val body: String, + val imageUrls: List, + val isMine: Boolean, + val isRead: Boolean, + val isPending: Boolean, + val createdAtMillis: Long, +) + +/** Projects a domain [DirectMessage] into a [MessageBubble] for [currentUserId]. */ +fun DirectMessage.toBubble(currentUserId: String?): MessageBubble = MessageBubble( + id = id, + body = body, + imageUrls = imageUrls, + isMine = currentUserId != null && senderId == currentUserId, + isRead = readAt != null, + isPending = pending, + createdAtMillis = createdAtMillis, +) diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageScreen.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageScreen.kt new file mode 100644 index 0000000..d98dfb7 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageScreen.kt @@ -0,0 +1,178 @@ +package com.interlinedlist.android.feature.directmessages.ui.newmessage + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.feature.directmessages.data.Recipient +import com.interlinedlist.android.feature.directmessages.ui.DmAvatar + +/** Stable test tags for the recipient picker. */ +object NewMessageTestTags { + const val SEARCH = "dmNewSearch" + const val LIST = "dmNewList" + const val EMPTY = "dmNewEmpty" + const val BACK = "dmNewBack" + fun row(username: String) = "dmNewRow_$username" +} + +/** Hilt-wired entry point for the recipient picker. */ +@Composable +fun NewMessageRoute( + onBack: () -> Unit, + onRecipientChosen: (username: String) -> Unit, + modifier: Modifier = Modifier, + viewModel: NewMessageViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + NewMessageScreen( + state = state, + onQueryChange = viewModel::onQueryChange, + onBack = onBack, + onRecipientChosen = { onRecipientChosen(it.username) }, + modifier = modifier, + ) +} + +/** Stateless recipient-picker UI, driveable directly from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewMessageScreen( + state: NewMessageUiState, + onQueryChange: (String) -> Unit, + onBack: () -> Unit, + onRecipientChosen: (Recipient) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("New message") }, + navigationIcon = { + IconButton( + onClick = onBack, + modifier = Modifier.testTag(NewMessageTestTags.BACK), + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + OutlinedTextField( + value = state.query, + onValueChange = onQueryChange, + singleLine = true, + placeholder = { Text("Search people") }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(NewMessageTestTags.SEARCH), + ) + if (state.isEmpty) { + EmptyRecipients(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(NewMessageTestTags.LIST), + ) { + items(state.filtered, key = { it.username }) { recipient -> + RecipientRow( + recipient = recipient, + onClick = { onRecipientChosen(recipient) }, + ) + HorizontalDivider() + } + } + } + } + } +} + +@Composable +private fun RecipientRow( + recipient: Recipient, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(NewMessageTestTags.row(recipient.username)) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DmAvatar( + avatarUrl = recipient.avatarUrl, + fallbackText = recipient.displayName ?: recipient.username, + size = 40, + ) + Spacer(Modifier.width(12.dp)) + Column { + Text( + text = recipient.displayName ?: recipient.username, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${recipient.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun EmptyRecipients(modifier: Modifier = Modifier) { + Column( + modifier = modifier.testTag(NewMessageTestTags.EMPTY), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "No one to message yet", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Follow people to start conversations.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModel.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModel.kt new file mode 100644 index 0000000..4982558 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModel.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.directmessages.ui.newmessage + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.directmessages.data.DirectMessagesRepository +import com.interlinedlist.android.feature.directmessages.data.Recipient +import com.interlinedlist.android.feature.directmessages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the recipient picker used to start a new conversation. */ +data class NewMessageUiState( + val query: String = "", + val recipients: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, +) { + /** Recipients matching [query] against username and display name. */ + val filtered: List + get() = if (query.isBlank()) { + recipients + } else { + val q = query.trim().lowercase() + recipients.filter { r -> + r.username.lowercase().contains(q) || + r.displayName?.lowercase()?.contains(q) == true + } + } + val isEmpty: Boolean get() = recipients.isEmpty() && !isLoading +} + +@HiltViewModel +class NewMessageViewModel @Inject constructor( + private val repository: DirectMessagesRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(NewMessageUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.recipients()) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoading = false, recipients = result.data) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun onQueryChange(value: String) = _uiState.update { it.copy(query = value) } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadScreen.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadScreen.kt new file mode 100644 index 0000000..604478c --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadScreen.kt @@ -0,0 +1,277 @@ +package com.interlinedlist.android.feature.directmessages.ui.thread + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import com.interlinedlist.android.feature.directmessages.ui.model.MessageBubble + +/** Stable test tags for the thread screen. */ +object ThreadTestTags { + const val LIST = "dmThreadList" + const val COMPOSER = "dmThreadComposer" + const val SEND = "dmThreadSend" + const val ATTACH = "dmThreadAttach" + const val BACK = "dmThreadBack" + const val EMPTY = "dmThreadEmpty" + const val READ_RECEIPT = "dmThreadReadReceipt" + fun bubble(id: String) = "dmThreadBubble_$id" +} + +/** Hilt-wired entry point for a thread. */ +@Composable +fun ThreadRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ThreadViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + // Poll only while the thread is on screen. + DisposableEffect(viewModel) { + viewModel.startPolling() + onDispose { viewModel.stopPolling() } + } + ThreadScreen( + state = state, + onBack = onBack, + onDraftChange = viewModel::onDraftChange, + onSend = viewModel::send, + onAttachImage = { /* Host supplies a picker; wired at app level. */ }, + modifier = modifier, + ) +} + +/** Stateless thread UI, driveable directly from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThreadScreen( + state: ThreadUiState, + onBack: () -> Unit, + onDraftChange: (String) -> Unit, + onSend: () -> Unit, + onAttachImage: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(state.username) }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(ThreadTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + bottomBar = { + Composer( + draft = state.draft, + canSend = state.canSend, + onDraftChange = onDraftChange, + onSend = onSend, + onAttachImage = onAttachImage, + ) + }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (state.isEmpty) { + Text( + text = "No messages yet. Say hello!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .testTag(ThreadTestTags.EMPTY), + ) + } else { + val listState = rememberLazyListState() + LaunchedEffect(state.messages.size) { + if (state.messages.isNotEmpty()) { + listState.animateScrollToItem(state.messages.lastIndex) + } + } + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .testTag(ThreadTestTags.LIST), + contentPadding = PaddingValues(vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(state.messages, key = { it.id }) { bubble -> + MessageBubbleRow(bubble) + } + } + } + } + } +} + +@Composable +private fun MessageBubbleRow(bubble: MessageBubble) { + val alignment = if (bubble.isMine) Alignment.End else Alignment.Start + val bubbleColor = if (bubble.isMine) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + val textColor = if (bubble.isMine) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .testTag(ThreadTestTags.bubble(bubble.id)), + horizontalAlignment = alignment, + ) { + Surface( + color = bubbleColor, + shape = RoundedCornerShape(16.dp), + modifier = Modifier.widthIn(max = 280.dp), + ) { + Column(modifier = Modifier.padding(10.dp)) { + bubble.imageUrls.forEach { url -> + AsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)), + ) + Spacer(Modifier.size(6.dp)) + } + if (bubble.body.isNotBlank()) { + Text( + text = bubble.body, + color = textColor, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + // Read receipt / send status, only meaningful on the user's own messages. + if (bubble.isMine) { + val status = when { + bubble.isPending -> "Sending…" + bubble.isRead -> "Read" + else -> "Sent" + } + Text( + text = status, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .padding(top = 2.dp, end = 4.dp) + .testTag(ThreadTestTags.READ_RECEIPT), + ) + } + } +} + +@Composable +private fun Composer( + draft: String, + canSend: Boolean, + onDraftChange: (String) -> Unit, + onSend: () -> Unit, + onAttachImage: () -> Unit, +) { + Surface(tonalElevation = 2.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onAttachImage, + modifier = Modifier.testTag(ThreadTestTags.ATTACH), + ) { + Icon(Icons.Filled.Image, contentDescription = "Attach image") + } + OutlinedTextField( + value = draft, + onValueChange = onDraftChange, + placeholder = { Text("Message") }, + modifier = Modifier + .weight(1f) + .testTag(ThreadTestTags.COMPOSER), + maxLines = 4, + ) + Spacer(Modifier.width(4.dp)) + IconButton( + onClick = onSend, + enabled = canSend, + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .background( + if (canSend) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceVariant, + ) + .testTag(ThreadTestTags.SEND), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = if (canSend) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } +} diff --git a/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModel.kt b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModel.kt new file mode 100644 index 0000000..9319e66 --- /dev/null +++ b/feature/directmessages/src/main/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModel.kt @@ -0,0 +1,148 @@ +package com.interlinedlist.android.feature.directmessages.ui.thread + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.directmessages.data.DirectMessagesRepository +import com.interlinedlist.android.feature.directmessages.navigation.DirectMessagesDestinations +import com.interlinedlist.android.feature.directmessages.ui.model.MessageBubble +import com.interlinedlist.android.feature.directmessages.ui.model.toBubble +import com.interlinedlist.android.feature.directmessages.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for a single conversation thread. */ +data class ThreadUiState( + val username: String, + val messages: List = emptyList(), + val draft: String = "", + val pendingImageUrls: List = emptyList(), + val isSending: Boolean = false, + val isLoading: Boolean = false, + val errorMessage: String? = null, +) { + val canSend: Boolean + get() = (draft.isNotBlank() || pendingImageUrls.isNotEmpty()) && !isSending + val isEmpty: Boolean get() = messages.isEmpty() && !isLoading +} + +@HiltViewModel +class ThreadViewModel( + private val repository: DirectMessagesRepository, + private val username: String, + private val pollIntervalMillis: Long = DEFAULT_POLL_INTERVAL_MILLIS, +) : ViewModel() { + + /** Hilt entry point: pulls the username from the nav arguments. */ + @Inject + constructor( + repository: DirectMessagesRepository, + savedStateHandle: SavedStateHandle, + ) : this( + repository = repository, + username = requireNotNull( + savedStateHandle.get(DirectMessagesDestinations.ARG_USERNAME), + ) { "Thread route requires a '${DirectMessagesDestinations.ARG_USERNAME}' argument" }, + pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS, + ) + + private val currentUserId = repository.currentUserId + + private val _uiState = MutableStateFlow(ThreadUiState(username = username, isLoading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + private var pollJob: Job? = null + + init { + // Room is the source of truth: mirror cached messages into the state. + viewModelScope.launch { + repository.observeThread(username).collect { messages -> + _uiState.update { state -> + state.copy(messages = messages.map { it.toBubble(currentUserId) }) + } + } + } + refresh() + } + + /** Full reload of the thread (also marks received-unread messages read). */ + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.refreshThread(username)) { + is ApiResult.Success -> _uiState.update { it.copy(isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** + * Starts near-real-time polling of the open thread, mirroring the + * notifications poller. Call from the screen's lifecycle (started) and pair + * with [stopPolling] (stopped) so we don't poll a backgrounded thread. + * Idempotent: repeated calls do not stack pollers. + */ + fun startPolling() { + if (pollJob?.isActive == true) return + pollJob = viewModelScope.launch { + while (isActive) { + delay(pollIntervalMillis) + repository.pollThreadUpdates(username) + } + } + } + + /** Stops the poll loop started by [startPolling]. */ + fun stopPolling() { + pollJob?.cancel() + pollJob = null + } + + fun onDraftChange(value: String) = _uiState.update { it.copy(draft = value, errorMessage = null) } + + fun attachImage(url: String) = + _uiState.update { it.copy(pendingImageUrls = it.pendingImageUrls + url) } + + fun removeImage(url: String) = + _uiState.update { it.copy(pendingImageUrls = it.pendingImageUrls - url) } + + /** Optimistic send: the repository echoes the message locally before the network call. */ + fun send() { + val current = _uiState.value + val body = current.draft.trim() + if (body.isBlank() && current.pendingImageUrls.isEmpty()) return + val images = current.pendingImageUrls + // Clear the composer immediately for a snappy feel; restore on failure. + _uiState.update { it.copy(draft = "", pendingImageUrls = emptyList(), isSending = true) } + viewModelScope.launch { + when (val result = repository.send(username, body, images)) { + is ApiResult.Success -> _uiState.update { it.copy(isSending = false) } + is ApiResult.Failure -> _uiState.update { + it.copy( + isSending = false, + draft = body, + pendingImageUrls = images, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + companion object { + const val DEFAULT_POLL_INTERVAL_MILLIS = 5_000L + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt new file mode 100644 index 0000000..bd42abf --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/DefaultDirectMessagesRepositoryTest.kt @@ -0,0 +1,298 @@ +package com.interlinedlist.android.feature.directmessages.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.directmessages.data.remote.DirectMessagesApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDirectMessagesRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: DirectMessagesApi + private lateinit var messageDao: FakeMessageDao + private lateinit var conversationDao: FakeConversationDao + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + + private val testDispatchers = object : DispatcherProvider { + val d: CoroutineDispatcher = UnconfinedTestDispatcher() + override val io = d + override val default = d + override val main = d + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val client = OkHttpClient.Builder().build() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(client) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DirectMessagesApi::class.java) + messageDao = FakeMessageDao() + conversationDao = FakeConversationDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repo(currentUserId: String? = "me") = DefaultDirectMessagesRepository( + api = api, + messageDao = messageDao, + conversationDao = conversationDao, + currentUserIdProvider = { currentUserId }, + json = json, + dispatchers = testDispatchers, + ) + + @Test + fun `refreshInbox caches conversations and returns next cursor`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + { + "items": [ + {"id":"m1","senderId":"other","recipientId":"me","body":"hi there", + "createdAt":"2026-07-31T10:00:00Z","readAt":null, + "user":{"id":"other","username":"adron","displayName":"Adron","avatar":null}} + ], + "nextCursor": "cursor-2" + } + """.trimIndent(), + ), + ) + + val result = repo().refreshInbox(cursor = null) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEqualTo("cursor-2") + + val conversations = repo().observeConversations().first() + assertThat(conversations).hasSize(1) + assertThat(conversations.first().username).isEqualTo("adron") + assertThat(conversations.first().lastMessageBody).isEqualTo("hi there") + assertThat(conversations.first().hasUnread).isTrue() + } + + @Test + fun `refreshInbox forwards the cursor query param`() = runTest { + server.enqueue(MockResponse().setBody("""{"items":[],"nextCursor":null}""")) + + repo().refreshInbox(cursor = "page-2") + + val recorded = server.takeRequest() + assertThat(recorded.path).contains("cursor=page-2") + } + + @Test + fun `refreshThread caches messages oldest-first`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + { + "items": [ + {"id":"m2","senderId":"me","recipientId":"other","body":"second", + "createdAt":"2026-07-31T10:05:00Z"}, + {"id":"m1","senderId":"other","recipientId":"me","body":"first", + "createdAt":"2026-07-31T10:00:00Z"} + ], + "olderCursor": null, + "isMutual": true, + "isBlocked": false, + "otherUser": {"id":"other","username":"adron","displayName":"Adron","avatar":null} + } + """.trimIndent(), + ), + ) + + val result = repo().refreshThread("adron") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val thread = repo().observeThread("adron").first() + assertThat(thread.map { it.id }).containsExactly("m1", "m2").inOrder() + } + + @Test + fun `send posts the message and caches the server copy`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + {"id":"srv1","senderId":"me","recipientId":"other","body":"hello", + "createdAt":"2026-07-31T11:00:00Z"} + """.trimIndent(), + ), + ) + + val result = repo().send(username = "adron", body = "hello") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/dm") + assertThat(recorded.body.readUtf8()).contains("\"body\":\"hello\"") + + val thread = repo().observeThread("adron").first() + assertThat(thread.map { it.id }).contains("srv1") + assertThat(thread.first { it.id == "srv1" }.pending).isFalse() + } + + @Test + fun `send caches an optimistic message even when the network fails`() = runTest { + server.enqueue(MockResponse().setResponseCode(500).setBody("""{"error":"boom"}""")) + + val r = repo() + val result = r.send(username = "adron", body = "will fail") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + // The optimistic copy stays visible so the UI can show a retry affordance. + val thread = r.observeThread("adron").first() + assertThat(thread).hasSize(1) + assertThat(thread.first().pending).isTrue() + assertThat(thread.first().body).isEqualTo("will fail") + } + + @Test + fun `markRead updates the server and the local read timestamp`() = runTest { + messageDao.upsert( + DirectMessage( + id = "m1", conversationUsername = "adron", senderId = "other", + recipientId = "me", body = "hi", imageUrls = emptyList(), + createdAt = "2026-07-31T10:00:00Z", createdAtMillis = 1L, + readAt = null, pending = false, + ).let { + com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity( + id = it.id, conversationUsername = it.conversationUsername, + senderId = it.senderId, recipientId = it.recipientId, body = it.body, + imageUrls = it.imageUrls, createdAt = it.createdAt, + createdAtMillis = it.createdAtMillis, readAt = it.readAt, trashed = false, + ) + }, + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repo().markRead("m1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.path).isEqualTo("/api/dm/m1/read") + assertThat(messageDao.all.first { it.id == "m1" }.readAt).isNotNull() + } + + @Test + fun `trash marks the local message trashed and restore reverses it`() = runTest { + messageDao.upsert( + com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity( + id = "m1", conversationUsername = "adron", senderId = "me", + recipientId = "other", body = "hi", imageUrls = emptyList(), + createdAt = "2026-07-31T10:00:00Z", createdAtMillis = 1L, readAt = null, + trashed = false, + ), + ) + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) // trash + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) // restore + + val r = repo() + + assertThat(r.trash("m1")).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.takeRequest().path).isEqualTo("/api/dm/m1/trash") + assertThat(messageDao.all.first { it.id == "m1" }.trashed).isTrue() + + assertThat(r.restore("m1", "adron")).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.takeRequest().path).isEqualTo("/api/dm/m1/restore") + assertThat(messageDao.all.first { it.id == "m1" }.trashed).isFalse() + } + + @Test + fun `pollThreadUpdates merges only genuinely new messages`() = runTest { + // Seed one cached message. + messageDao.upsert( + com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity( + id = "m1", conversationUsername = "adron", senderId = "other", + recipientId = "me", body = "first", imageUrls = emptyList(), + createdAt = "2026-07-31T10:00:00Z", createdAtMillis = + parseIsoMillis("2026-07-31T10:00:00Z"), readAt = null, trashed = false, + ), + ) + server.enqueue( + MockResponse().setBody( + """ + {"items":[ + {"id":"m1","senderId":"other","recipientId":"me","body":"first", + "createdAt":"2026-07-31T10:00:00Z"}, + {"id":"m2","senderId":"other","recipientId":"me","body":"second", + "createdAt":"2026-07-31T10:10:00Z"} + ]} + """.trimIndent(), + ), + ) + + val r = repo() + val merged = r.pollThreadUpdates("adron") + + assertThat(merged).isInstanceOf(ApiResult.Success::class.java) + assertThat((merged as ApiResult.Success).data).isEqualTo(1) + + val recorded = server.takeRequest() + assertThat(recorded.path).contains("/api/dm/thread/adron/updates") + assertThat(recorded.path).contains("after=") + + val thread = r.observeThread("adron").first() + assertThat(thread.map { it.id }).containsExactly("m1", "m2").inOrder() + } + + @Test + fun `unreadCount reads the count field`() = runTest { + server.enqueue(MockResponse().setBody("""{"count":7}""")) + + val result = repo().unreadCount() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEqualTo(7) + } + + @Test + fun `recipients maps the wire list to domain`() = runTest { + server.enqueue( + MockResponse().setBody( + """ + {"recipients":[ + {"id":"u1","username":"adron","displayName":"Adron Hall","avatar":"http://a"} + ]} + """.trimIndent(), + ), + ) + + val result = repo().recipients() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val list = (result as ApiResult.Success).data + assertThat(list).hasSize(1) + assertThat(list.first().username).isEqualTo("adron") + assertThat(list.first().avatarUrl).isEqualTo("http://a") + } + + companion object { + // Silence unused import warnings on Dispatchers in some Kotlin versions. + private val unused = Dispatchers.Default + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt new file mode 100644 index 0000000..18fa027 --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/data/FakeDaos.kt @@ -0,0 +1,81 @@ +package com.interlinedlist.android.feature.directmessages.data + +import com.interlinedlist.android.feature.directmessages.data.local.ConversationDao +import com.interlinedlist.android.feature.directmessages.data.local.ConversationEntity +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageDao +import com.interlinedlist.android.feature.directmessages.data.local.DirectMessageEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map + +/** + * In-memory [DirectMessageDao] so repository tests run on the plain JVM without + * the Room/Android runtime. Mirrors the real DAO's query semantics. + */ +class FakeMessageDao : DirectMessageDao { + private val state = MutableStateFlow>(emptyList()) + + val all: List get() = state.value + + override fun observeThread(username: String): Flow> = + state.map { list -> + list.filter { it.conversationUsername == username && !it.trashed } + .sortedBy { it.createdAtMillis } + } + + override suspend fun upsertAll(messages: List) { + messages.forEach { upsert(it) } + } + + override suspend fun upsert(message: DirectMessageEntity) { + state.value = state.value.filterNot { it.id == message.id } + message + } + + override suspend fun setReadAt(id: String, readAt: String) { + state.value = state.value.map { if (it.id == id) it.copy(readAt = readAt) else it } + } + + override suspend fun setTrashed(id: String, trashed: Boolean) { + state.value = state.value.map { if (it.id == id) it.copy(trashed = trashed) else it } + } + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun latestCreatedAt(username: String): String? = + state.value.filter { it.conversationUsername == username } + .maxByOrNull { it.createdAtMillis } + ?.createdAt + + override suspend fun existingIds(username: String): List = + state.value.filter { it.conversationUsername == username }.map { it.id } + + override suspend fun clear() { + state.value = emptyList() + } +} + +/** In-memory [ConversationDao] counterpart for repository tests. */ +class FakeConversationDao : ConversationDao { + private val state = MutableStateFlow>(emptyList()) + + override fun observeConversations(): Flow> = + state.map { list -> list.sortedByDescending { it.lastMessageAtMillis } } + + override suspend fun upsertAll(conversations: List) { + val byKey = state.value.associateBy { it.username }.toMutableMap() + conversations.forEach { byKey[it.username] = it } + state.value = byKey.values.toList() + } + + override suspend fun clearUnread(username: String) { + state.value = state.value.map { + if (it.username == username) it.copy(hasUnread = false) else it + } + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/FakeDirectMessagesRepository.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/FakeDirectMessagesRepository.kt new file mode 100644 index 0000000..9376022 --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/FakeDirectMessagesRepository.kt @@ -0,0 +1,84 @@ +package com.interlinedlist.android.feature.directmessages.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.directmessages.data.Conversation +import com.interlinedlist.android.feature.directmessages.data.DirectMessage +import com.interlinedlist.android.feature.directmessages.data.DirectMessagesRepository +import com.interlinedlist.android.feature.directmessages.data.Recipient +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Configurable in-memory [DirectMessagesRepository] for ViewModel tests. Flows + * are backed by [MutableStateFlow]s tests can push to; suspend calls return the + * result the test primes and record that they ran. + */ +class FakeDirectMessagesRepository( + override val currentUserId: String? = "me", +) : DirectMessagesRepository { + + val conversationsFlow = MutableStateFlow>(emptyList()) + val threadFlow = MutableStateFlow>(emptyList()) + + var refreshInboxResult: ApiResult = ApiResult.Success(null) + var refreshThreadResult: ApiResult = ApiResult.Success(Unit) + var pollResult: ApiResult = ApiResult.Success(0) + var sendResult: ApiResult? = null + var markReadResult: ApiResult = ApiResult.Success(Unit) + var trashResult: ApiResult = ApiResult.Success(Unit) + var restoreResult: ApiResult = ApiResult.Success(Unit) + var recipientsResult: ApiResult> = ApiResult.Success(emptyList()) + var unreadResult: ApiResult = ApiResult.Success(0) + + var refreshInboxCount = 0 + var refreshThreadCount = 0 + var pollCount = 0 + val sentBodies = mutableListOf() + + override fun observeConversations(): Flow> = conversationsFlow.asStateFlow() + override fun observeThread(username: String): Flow> = threadFlow.asStateFlow() + + override suspend fun refreshInbox(cursor: String?): ApiResult { + refreshInboxCount++ + return refreshInboxResult + } + + override suspend fun refreshThread(username: String): ApiResult { + refreshThreadCount++ + return refreshThreadResult + } + + override suspend fun pollThreadUpdates(username: String): ApiResult { + pollCount++ + return pollResult + } + + override suspend fun send( + username: String, + body: String, + imageUrls: List, + ): ApiResult { + sentBodies += body + return sendResult ?: ApiResult.Success( + DirectMessage( + id = "srv-$body", conversationUsername = username, senderId = currentUserId ?: "me", + recipientId = "other", body = body, imageUrls = imageUrls, + createdAt = "2026-07-31T12:00:00Z", createdAtMillis = 1L, readAt = null, + pending = false, + ), + ) + } + + override suspend fun markRead(id: String): ApiResult = markReadResult + override suspend fun trash(id: String): ApiResult = trashResult + override suspend fun restore(id: String, username: String): ApiResult = restoreResult + override suspend fun recipients(): ApiResult> = recipientsResult + override suspend fun unreadCount(): ApiResult = unreadResult + + fun failEverythingWith(error: AppError) { + refreshInboxResult = ApiResult.Failure(error) + refreshThreadResult = ApiResult.Failure(error) + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt new file mode 100644 index 0000000..5a44734 --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/inbox/InboxViewModelTest.kt @@ -0,0 +1,117 @@ +package com.interlinedlist.android.feature.directmessages.ui.inbox + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.directmessages.data.Conversation +import com.interlinedlist.android.feature.directmessages.ui.FakeDirectMessagesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InboxViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun conversation(username: String, unread: Boolean) = Conversation( + username = username, displayName = username, avatarUrl = null, + lastMessageBody = "hey", lastMessageAtMillis = 1L, hasUnread = unread, + ) + + @Test + fun `starts empty then reflects cached conversations`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + val vm = InboxViewModel(repo) + + vm.uiState.test { + assertThat(awaitItem().conversations).isEmpty() + + repo.conversationsFlow.value = listOf(conversation("adron", unread = true)) + advanceUntilIdle() + + val loaded = awaitItem() + assertThat(loaded.conversations).hasSize(1) + assertThat(loaded.conversations.first().username).isEqualTo("adron") + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `unread badge counts only conversations with unread`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + val vm = InboxViewModel(repo) + + vm.uiState.test { + awaitItem() + repo.conversationsFlow.value = listOf( + conversation("adron", unread = true), + conversation("blake", unread = false), + conversation("casey", unread = true), + ) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.unreadConversationCount).isEqualTo(2) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `refresh loads the first page and stores the next cursor`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.refreshInboxResult = ApiResult.Success("next") + val vm = InboxViewModel(repo) + + // The ViewModel refreshes once on init; refresh again explicitly. + vm.refresh() + advanceUntilIdle() + + assertThat(repo.refreshInboxCount).isEqualTo(2) + assertThat(vm.uiState.value.isRefreshing).isFalse() + assertThat(vm.uiState.value.nextCursor).isEqualTo("next") + } + + @Test + fun `refresh failure surfaces an error message`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.refreshInboxResult = ApiResult.Failure(AppError.Network("offline")) + val vm = InboxViewModel(repo) + + vm.refresh() + advanceUntilIdle() + + assertThat(vm.uiState.value.isRefreshing).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `loadMore pages using the stored cursor`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.refreshInboxResult = ApiResult.Success("cursor-2") + val vm = InboxViewModel(repo) + + // The ViewModel refreshes once on init, storing the first cursor. + advanceUntilIdle() + assertThat(vm.uiState.value.nextCursor).isEqualTo("cursor-2") + val countAfterInit = repo.refreshInboxCount + + repo.refreshInboxResult = ApiResult.Success(null) + vm.loadMore() + advanceUntilIdle() + + assertThat(repo.refreshInboxCount).isEqualTo(countAfterInit + 1) + assertThat(vm.uiState.value.nextCursor).isNull() + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModelTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModelTest.kt new file mode 100644 index 0000000..8fedf1c --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/newmessage/NewMessageViewModelTest.kt @@ -0,0 +1,70 @@ +package com.interlinedlist.android.feature.directmessages.ui.newmessage + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.directmessages.data.Recipient +import com.interlinedlist.android.feature.directmessages.ui.FakeDirectMessagesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class NewMessageViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private val adron = Recipient("u1", "adron", "Adron Hall", null) + private val blake = Recipient("u2", "blake", "Blake", null) + + @Test + fun `loads recipients on init`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.recipientsResult = ApiResult.Success(listOf(adron, blake)) + + val vm = NewMessageViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.recipients).hasSize(2) + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `query filters recipients by username and display name`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.recipientsResult = ApiResult.Success(listOf(adron, blake)) + val vm = NewMessageViewModel(repo) + advanceUntilIdle() + + vm.onQueryChange("adr") + assertThat(vm.uiState.value.filtered.map { it.username }).containsExactly("adron") + + vm.onQueryChange("blake") + assertThat(vm.uiState.value.filtered.map { it.username }).containsExactly("blake") + + vm.onQueryChange("") + assertThat(vm.uiState.value.filtered).hasSize(2) + } + + @Test + fun `failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.recipientsResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = NewMessageViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} diff --git a/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModelTest.kt b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModelTest.kt new file mode 100644 index 0000000..8e35564 --- /dev/null +++ b/feature/directmessages/src/test/kotlin/com/interlinedlist/android/feature/directmessages/ui/thread/ThreadViewModelTest.kt @@ -0,0 +1,138 @@ +package com.interlinedlist.android.feature.directmessages.ui.thread + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.directmessages.data.DirectMessage +import com.interlinedlist.android.feature.directmessages.ui.FakeDirectMessagesRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ThreadViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun message(id: String, mine: Boolean, body: String = "hi") = DirectMessage( + id = id, conversationUsername = "adron", + senderId = if (mine) "me" else "other", + recipientId = if (mine) "other" else "me", + body = body, imageUrls = emptyList(), createdAt = "2026-07-31T10:00:00Z", + createdAtMillis = id.hashCode().toLong(), readAt = null, pending = false, + ) + + private fun vm(repo: FakeDirectMessagesRepository, pollMillis: Long = 3_000L) = + ThreadViewModel(repo, "adron", pollIntervalMillis = pollMillis) + + @Test + fun `marks mine vs theirs from the current user id`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository(currentUserId = "me") + val vm = vm(repo) + + vm.uiState.test { + awaitItem() + repo.threadFlow.value = listOf(message("m1", mine = false), message("m2", mine = true)) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.messages).hasSize(2) + assertThat(state.messages.first { it.id == "m1" }.isMine).isFalse() + assertThat(state.messages.first { it.id == "m2" }.isMine).isTrue() + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `loads the thread on init`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + vm(repo) + advanceUntilIdle() + assertThat(repo.refreshThreadCount).isEqualTo(1) + } + + @Test + fun `send clears the draft and delegates to the repository`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + val vm = vm(repo) + advanceUntilIdle() + + vm.onDraftChange("hello there") + vm.send() + advanceUntilIdle() + + assertThat(repo.sentBodies).containsExactly("hello there") + assertThat(vm.uiState.value.draft).isEmpty() + } + + @Test + fun `send does nothing for a blank draft`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + val vm = vm(repo) + advanceUntilIdle() + + vm.onDraftChange(" ") + vm.send() + advanceUntilIdle() + + assertThat(repo.sentBodies).isEmpty() + } + + @Test + fun `failed send restores the draft and shows an error`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.sendResult = ApiResult.Failure(AppError.Network("offline")) + val vm = vm(repo) + advanceUntilIdle() + + vm.onDraftChange("retry me") + vm.send() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.draft).isEqualTo("retry me") + } + + @Test + fun `does not poll until polling is started`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + val vm = vm(repo, pollMillis = 3_000L) + advanceUntilIdle() // initial refreshThread only + + assertThat(repo.pollCount).isEqualTo(0) + } + + @Test + fun `polls for updates on the configured interval while started`() = runTest(dispatcher) { + val repo = FakeDirectMessagesRepository() + repo.pollResult = ApiResult.Success(1) + val vm = vm(repo, pollMillis = 3_000L) + advanceUntilIdle() // initial refreshThread + + vm.startPolling() + + // One interval → one poll; two intervals → two polls. Stop before draining + // so the infinite poll loop doesn't hang advanceUntilIdle(). + advanceTimeBy(3_100L) + assertThat(repo.pollCount).isEqualTo(1) + + advanceTimeBy(3_000L) + assertThat(repo.pollCount).isEqualTo(2) + + vm.stopPolling() + advanceTimeBy(6_000L) + assertThat(repo.pollCount).isEqualTo(2) // no further polls after stop + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 354d17f..e3a23ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,3 +42,4 @@ include(":feature:profile") include(":feature:notifications") include(":feature:organizations") include(":feature:integrations") +include(":feature:directmessages") From 09f2adab092c175fdf8a417139a3dd56ddf277c5 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 15:21:09 -0700 Subject: [PATCH 13/25] feat(profile): browse others' public content (Milestone L) Adds Posts/Lists/Documents tabs to the other-user profile plus a mutual- connections indicator, and read-only public list/document viewers. Endpoints: /api/user/{username}/messages, /api/users/{username}/lists(+/{id}(+/data)), /api/users/{username}/documents, /api/documents/{id}, /api/users/lookup, /api/follow/{userId}/mutual (counts-only). 123 profile unit tests green. Stacks on Milestone K. Nav wiring deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../feature/profile/ui/UserProfileTabsTest.kt | 145 ++++++++ .../profile/data/DefaultProfileRepository.kt | 81 +++++ .../feature/profile/data/ProfileRepository.kt | 30 ++ .../data/mapper/PublicContentMappers.kt | 74 ++++ .../feature/profile/data/remote/ProfileApi.kt | 62 ++++ .../data/remote/dto/PublicContentResponses.kt | 188 ++++++++++ .../feature/profile/domain/PublicContent.kt | 83 +++++ .../profile/ui/profile/ProfileViewModel.kt | 5 + .../profile/ui/profile/PublicContentState.kt | 22 ++ .../ui/profile/PublicDocumentScreen.kt | 138 ++++++++ .../ui/profile/PublicDocumentViewModel.kt | 62 ++++ .../profile/ui/profile/PublicListScreen.kt | 184 ++++++++++ .../profile/ui/profile/PublicListViewModel.kt | 67 ++++ .../profile/ui/profile/UserProfileScreen.kt | 323 +++++++++++++++++- .../ui/profile/UserProfileViewModel.kt | 71 ++++ .../data/DefaultProfileRepositoryTest.kt | 200 +++++++++++ .../profile/ui/FakeProfileRepository.kt | 60 ++++ .../profile/ui/UserProfileContentTest.kt | 180 ++++++++++ 18 files changed, 1959 insertions(+), 16 deletions(-) create mode 100644 feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileTabsTest.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/PublicContentMappers.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/PublicContentResponses.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/PublicContent.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicContentState.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentViewModel.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListViewModel.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileContentTest.kt diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileTabsTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileTabsTest.kt new file mode 100644 index 0000000..4e53dea --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileTabsTest.kt @@ -0,0 +1,145 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.FollowCounts +import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.MutualConnections +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost +import com.interlinedlist.android.feature.profile.ui.profile.ProfileContentTab +import com.interlinedlist.android.feature.profile.ui.profile.ProfileContentTestTags +import com.interlinedlist.android.feature.profile.ui.profile.ProfileUiState +import com.interlinedlist.android.feature.profile.ui.profile.PublicContentState +import com.interlinedlist.android.feature.profile.ui.profile.UserProfileScreen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UserProfileTabsTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun ada() = ProfileUser( + id = "u2", + username = "ada", + displayName = "Ada Lovelace", + avatarUrl = null, + bio = "First programmer.", + customerStatus = CustomerStatus.FREE, + isCurrentUser = false, + ) + + @Test + fun tabs_renderAndPostsShowByDefault() { + composeRule.setContent { + InterlinedListTheme { + UserProfileScreen( + state = ProfileUiState( + user = ada(), + isLoading = false, + followStatus = FollowStatus.NOT_FOLLOWING, + followCounts = FollowCounts(followers = 10, following = 5), + selectedTab = ProfileContentTab.POSTS, + content = PublicContentState( + posts = listOf(PublicPost("m1", "Hello", null)), + loadedTabs = setOf(ProfileContentTab.POSTS), + ), + ), + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(ProfileContentTestTags.TABS).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileContentTestTags.postRow("m1")).assertIsDisplayed() + } + + @Test + fun tapping_listsTab_invokesSelectAndRendersLists() { + var selected: ProfileContentTab? = null + composeRule.setContent { + InterlinedListTheme { + UserProfileScreen( + state = ProfileUiState( + user = ada(), + isLoading = false, + followStatus = FollowStatus.NOT_FOLLOWING, + selectedTab = ProfileContentTab.LISTS, + content = PublicContentState( + lists = listOf(PublicListSummary("l1", "Todos", "desc")), + loadedTabs = setOf(ProfileContentTab.LISTS), + ), + ), + onBack = {}, + onRetry = {}, + onSelectTab = { selected = it }, + ) + } + } + + composeRule.onNodeWithTag(ProfileContentTestTags.tab(ProfileContentTab.LISTS)).performClick() + assert(selected == ProfileContentTab.LISTS) + composeRule.onNodeWithTag(ProfileContentTestTags.listRow("l1")).assertIsDisplayed() + } + + @Test + fun documentsTab_rendersDocumentRowsAndOpensOnTap() { + var openedDoc: String? = null + composeRule.setContent { + InterlinedListTheme { + UserProfileScreen( + state = ProfileUiState( + user = ada(), + isLoading = false, + followStatus = FollowStatus.NOT_FOLLOWING, + selectedTab = ProfileContentTab.DOCUMENTS, + content = PublicContentState( + documents = listOf(PublicDocumentSummary("d1", "Notes")), + loadedTabs = setOf(ProfileContentTab.DOCUMENTS), + ), + ), + onBack = {}, + onRetry = {}, + onOpenDocument = { openedDoc = it }, + ) + } + } + + composeRule.onNodeWithTag(ProfileContentTestTags.documentRow("d1")).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileContentTestTags.documentRow("d1")).performClick() + assert(openedDoc == "d1") + } + + @Test + fun mutualConnections_indicatorShowsWhenPresent() { + composeRule.setContent { + InterlinedListTheme { + UserProfileScreen( + state = ProfileUiState( + user = ada(), + isLoading = false, + followStatus = FollowStatus.NOT_FOLLOWING, + selectedTab = ProfileContentTab.POSTS, + mutualConnections = MutualConnections(mutualFollowers = 3, mutualFollowing = 1), + content = PublicContentState(loadedTabs = setOf(ProfileContentTab.POSTS)), + ), + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(ProfileContentTestTags.MUTUAL).assertIsDisplayed() + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt index 555072d..b180e8c 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt @@ -12,7 +12,13 @@ import com.interlinedlist.android.feature.profile.data.mapper.toFollowCounts import com.interlinedlist.android.feature.profile.data.mapper.toFollowStatus import com.interlinedlist.android.feature.profile.data.mapper.toFollowUser import com.interlinedlist.android.feature.profile.data.mapper.toFollowUserOrNull +import com.interlinedlist.android.feature.profile.data.mapper.toMutualConnections import com.interlinedlist.android.feature.profile.data.mapper.toProfileUser +import com.interlinedlist.android.feature.profile.data.mapper.toPublicDocumentDetail +import com.interlinedlist.android.feature.profile.data.mapper.toPublicDocumentSummary +import com.interlinedlist.android.feature.profile.data.mapper.toPublicListRow +import com.interlinedlist.android.feature.profile.data.mapper.toPublicListSummary +import com.interlinedlist.android.feature.profile.data.mapper.toPublicPost import com.interlinedlist.android.feature.profile.data.mapper.toSearchResult import com.interlinedlist.android.feature.profile.data.remote.ProfileApi import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest @@ -25,7 +31,13 @@ import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.LinkedIdentity import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListDetail +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -190,6 +202,74 @@ class DefaultProfileRepository @Inject constructor( override suspend fun removeFollower(userId: String): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.removeFollower(userId) } } + // --- Public content (read-only, nothing cached) --- + + override suspend fun getUserPosts(username: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getUserMessages(username, limit = CONTENT_LIMIT).posts.map { it.toPublicPost() } + } + } + + override suspend fun getUserLists(username: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getUserLists(username, limit = CONTENT_LIMIT).items.map { it.toPublicListSummary() } + } + } + + override suspend fun getUserList( + username: String, + listId: String, + ): ApiResult = withContext(dispatchers.io) { + // Metadata and rows come from two endpoints; fetch the list first so a missing + // or private list surfaces its error before we attempt the rows. + when (val meta = safeApiCall(json) { api.getUserList(username, listId).listOrSelf }) { + is ApiResult.Success -> { + val list = meta.data + ?: return@withContext ApiResult.Failure(AppError.NotFound("List not found")) + when (val data = safeApiCall(json) { + api.getUserListData(username, listId, limit = CONTENT_LIMIT).items + }) { + is ApiResult.Success -> ApiResult.Success( + PublicListDetail( + id = list.id, + title = list.title, + description = list.description, + rows = data.data.map { it.toPublicListRow() }, + ), + ) + is ApiResult.Failure -> data + } + } + is ApiResult.Failure -> meta + } + } + + override suspend fun getUserDocuments(username: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getUserDocuments(username).items.map { it.toPublicDocumentSummary() } + } + } + + override suspend fun getDocument(documentId: String): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { api.getDocument(documentId).documentOrSelf }) { + is ApiResult.Success -> { + val doc = result.data + ?: return@withContext ApiResult.Failure(AppError.NotFound("Document not found")) + ApiResult.Success(doc.toPublicDocumentDetail()) + } + is ApiResult.Failure -> result + } + } + + override suspend fun getMutualConnections(userId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getMutualConnections(userId).toMutualConnections() } + } + // --- Account & Security (always fresh, nothing cached) --- override suspend fun getSessions(): ApiResult> = @@ -248,5 +328,6 @@ class DefaultProfileRepository @Inject constructor( private companion object { const val SEARCH_LIMIT = 20 const val LIST_LIMIT = 50 + const val CONTENT_LIMIT = 50 } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt index 90f0f3b..6dc8aa5 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt @@ -6,7 +6,13 @@ import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.LinkedIdentity import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListDetail +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.Flow @@ -93,6 +99,30 @@ interface ProfileRepository { /** Removes [userId] as a follower via `DELETE /api/follow/{userId}/remove`. */ suspend fun removeFollower(userId: String): ApiResult + // --- Public content (another user's posts / lists / documents) --- + // Read-only surfaces on the other-user profile; nothing is cached (YAGNI). + + /** A user's public posts via `GET /api/user/{username}/messages` (singular `user`). */ + suspend fun getUserPosts(username: String): ApiResult> + + /** A user's public lists via `GET /api/users/{username}/lists`. */ + suspend fun getUserLists(username: String): ApiResult> + + /** + * A single public list (metadata + rows) via `GET /api/users/{username}/lists/{id}` + * and `.../data`, combined into a read-only [PublicListDetail]. + */ + suspend fun getUserList(username: String, listId: String): ApiResult + + /** A user's public documents via `GET /api/users/{username}/documents`. */ + suspend fun getUserDocuments(username: String): ApiResult> + + /** A single public document (title + content) via `GET /api/documents/{id}`. */ + suspend fun getDocument(documentId: String): ApiResult + + /** Mutual-connection counts with [userId] via `GET /api/follow/{userId}/mutual`. */ + suspend fun getMutualConnections(userId: String): ApiResult + // --- Account & Security --- // These are always-fresh settings surfaces, so nothing is cached (YAGNI). diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/PublicContentMappers.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/PublicContentMappers.kt new file mode 100644 index 0000000..4fb44d3 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/PublicContentMappers.kt @@ -0,0 +1,74 @@ +package com.interlinedlist.android.feature.profile.data.mapper + +import com.interlinedlist.android.feature.profile.data.remote.dto.MutualConnectionsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicDocumentDetailDto +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicDocumentDto +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicListDto +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicListRowDto +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicPostDto +import com.interlinedlist.android.feature.profile.domain.MutualConnections +import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListCell +import com.interlinedlist.android.feature.profile.domain.PublicListRow +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Maps a public post DTO into the read-only [PublicPost]. */ +fun PublicPostDto.toPublicPost(): PublicPost = PublicPost( + id = id, + content = content, + createdAt = createdAt, +) + +/** Maps a public list summary DTO. */ +fun PublicListDto.toPublicListSummary(): PublicListSummary = PublicListSummary( + id = id, + title = title, + description = description, +) + +/** + * Maps a public list row's dynamic `rowData` map into an ordered list of display + * cells. Value types are never assumed — strings, numbers, booleans, nulls, and + * nested arrays/objects are all coerced to a readable string so any user-defined + * schema renders. Blank/null cells are dropped so the read-only view stays tidy. + */ +fun PublicListRowDto.toPublicListRow(): PublicListRow = PublicListRow( + id = id, + cells = fields.entries + .map { (key, value) -> PublicListCell(label = key, value = displayString(value)) } + .filter { it.value.isNotBlank() }, +) + +/** Maps a public document summary DTO. */ +fun PublicDocumentDto.toPublicDocumentSummary(): PublicDocumentSummary = PublicDocumentSummary( + id = id, + title = title, +) + +/** Maps a public document detail DTO (title + content). */ +fun PublicDocumentDetailDto.toPublicDocumentDetail(): PublicDocumentDetail = PublicDocumentDetail( + id = id, + title = title, + content = content, +) + +/** Maps the mutual-connections counts response. */ +fun MutualConnectionsResponse.toMutualConnections(): MutualConnections = MutualConnections( + mutualFollowers = mutualFollowersOrZero, + mutualFollowing = mutualFollowingOrZero, +) + +/** Coerces any JSON value to a human-readable string (nulls become empty). */ +internal fun displayString(value: JsonElement): String = when (value) { + is JsonNull -> "" + is JsonPrimitive -> value.content + is JsonArray -> value.joinToString(", ") { displayString(it) } + is JsonObject -> value.entries.joinToString(", ") { (k, v) -> "$k: ${displayString(v)}" } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt index 2b6bea0..7c77b21 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt @@ -9,8 +9,16 @@ import com.interlinedlist.android.feature.profile.data.remote.dto.FollowListResp import com.interlinedlist.android.feature.profile.data.remote.dto.FollowRequestsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.FollowStatusResponse import com.interlinedlist.android.feature.profile.data.remote.dto.IdentitiesResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.MutualConnectionsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicDocumentDetailResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicDocumentsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicListDataResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicListDetailResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicListsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.PublicPostsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.SessionsResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.UserLookupResponse import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest import com.interlinedlist.android.feature.profile.data.remote.dto.UserSearchResponse import okhttp3.MultipartBody @@ -60,6 +68,60 @@ interface ProfileApi { @Query("limit") limit: Int? = null, ): UserSearchResponse + /** Looks up a single user by handle (e.g. `@username`). */ + @GET("api/users/lookup") + suspend fun lookupUser(@Query("handle") handle: String): UserLookupResponse + + // --- Public content (another user's posts / lists / documents) --- + + /** + * A user's public posts. Note the SINGULAR `user` path segment — confirmed + * against the OpenAPI spec and live calls (`/api/user/{username}/messages`), + * unlike the plural `users` used by the lists/documents endpoints below. + */ + @GET("api/user/{username}/messages") + suspend fun getUserMessages( + @Path("username") username: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): PublicPostsResponse + + /** A user's public lists. */ + @GET("api/users/{username}/lists") + suspend fun getUserLists( + @Path("username") username: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): PublicListsResponse + + /** A single public list's metadata. */ + @GET("api/users/{username}/lists/{id}") + suspend fun getUserList( + @Path("username") username: String, + @Path("id") listId: String, + ): PublicListDetailResponse + + /** A single public list's rows. */ + @GET("api/users/{username}/lists/{id}/data") + suspend fun getUserListData( + @Path("username") username: String, + @Path("id") listId: String, + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): PublicListDataResponse + + /** A user's public documents. */ + @GET("api/users/{username}/documents") + suspend fun getUserDocuments(@Path("username") username: String): PublicDocumentsResponse + + /** A single public document, including its content. */ + @GET("api/documents/{id}") + suspend fun getDocument(@Path("id") documentId: String): PublicDocumentDetailResponse + + /** Mutual-connection counts between the current user and [userId]. */ + @GET("api/follow/{userId}/mutual") + suspend fun getMutualConnections(@Path("userId") userId: String): MutualConnectionsResponse + // --- Following --- /** Follows a user. */ diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/PublicContentResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/PublicContentResponses.kt new file mode 100644 index 0000000..8240629 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/PublicContentResponses.kt @@ -0,0 +1,188 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** + * Wire models for another user's public content — posts, lists, documents — plus the + * user lookup and mutual-connections endpoints (Milestone L). Field names and + * envelopes were confirmed against live GET calls; the shared [kotlinx.serialization.json.Json] + * ignores unknown keys, so only the fields the read-only surfaces render are declared. + */ + +/** A public post (message). Confirmed live: `id`, `content`, `createdAt` (+ nested author). */ +@Serializable +data class PublicPostDto( + val id: String, + val content: String = "", + val createdAt: String? = null, +) + +/** + * `GET /api/user/{username}/messages` (note the singular `user` segment — confirmed + * against the OpenAPI spec and live). Posts arrive under `messages`, with `data` as a + * defensive fallback. + */ +@Serializable +data class PublicPostsResponse( + val messages: List? = null, + val data: List? = null, +) { + val posts: List get() = messages ?: data ?: emptyList() +} + +/** A public list summary. Confirmed live: `id`, `title`, `description`. */ +@Serializable +data class PublicListDto( + val id: String, + val title: String = "", + val description: String? = null, +) + +/** + * `GET /api/users/{username}/lists` (plural `users`). Confirmed live envelope: + * `{ lists: [...], pagination }`; `data` is a defensive fallback. + */ +@Serializable +data class PublicListsResponse( + val lists: List? = null, + val data: List? = null, +) { + val items: List get() = lists ?: data ?: emptyList() +} + +/** + * `GET /api/users/{username}/lists/{id}`. Confirmed live: the list is wrapped under + * `list` (alongside `ancestors`); a bare/`data` shape is tolerated too. + */ +@Serializable +data class PublicListDetailResponse( + val list: PublicListDto? = null, + val data: PublicListDto? = null, + val id: String? = null, + val title: String? = null, + val description: String? = null, +) { + /** The list payload, whether wrapped under `list`/`data` or inlined at the top level. */ + val listOrSelf: PublicListDto? + get() = list ?: data ?: id?.let { + PublicListDto(id = it, title = title ?: "", description = description) + } +} + +/** + * A single public list row. Confirmed live: the dynamic field map arrives under + * `rowData` (not `data`, which the owner's own Lists module uses). Kept as a + * [JsonObject] and projected to display strings by the mapper so any schema renders. + */ +@Serializable +data class PublicListRowDto( + val id: String, + val rowData: JsonObject = JsonObject(emptyMap()), + val data: JsonObject? = null, +) { + /** The field map from whichever key the server populated. */ + val fields: JsonObject get() = if (rowData.isNotEmpty()) rowData else (data ?: rowData) +} + +/** + * `GET /api/users/{username}/lists/{id}/data`. Confirmed live envelope: + * `{ rows: [...], pagination }`; `data` is a defensive fallback. + */ +@Serializable +data class PublicListDataResponse( + val rows: List? = null, + val data: List? = null, +) { + val items: List get() = rows ?: data ?: emptyList() +} + +/** A public document summary. Confirmed live: `id`, `title`. */ +@Serializable +data class PublicDocumentDto( + val id: String, + val title: String = "", +) + +/** + * `GET /api/users/{username}/documents`. Confirmed live envelope: + * `{ documents: [...], folders }`; `data` is a defensive fallback. + */ +@Serializable +data class PublicDocumentsResponse( + val documents: List? = null, + val data: List? = null, +) { + val items: List get() = documents ?: data ?: emptyList() +} + +/** + * `GET /api/documents/{id}`. Confirmed live: the document (incl. `content`) is wrapped + * under `document`; a bare/`data` shape is tolerated too. + */ +@Serializable +data class PublicDocumentDetailDto( + val id: String, + val title: String = "", + val content: String = "", +) + +@Serializable +data class PublicDocumentDetailResponse( + val document: PublicDocumentDetailDto? = null, + val data: PublicDocumentDetailDto? = null, + val id: String? = null, + val title: String? = null, + val content: String? = null, +) { + val documentOrSelf: PublicDocumentDetailDto? + get() = document ?: data ?: id?.let { + PublicDocumentDetailDto(id = it, title = title ?: "", content = content ?: "") + } +} + +/** + * `GET /api/users/lookup?handle=...`. Confirmed live: a bare user object + * (`{ id, username, displayName, avatar, isPrivate }`), so [UserLookupResponse] reuses + * the top-level fields; some builds may wrap it under `user`. + */ +@Serializable +data class UserLookupResponse( + val user: ProfileUserDto? = null, + val id: String? = null, + val username: String? = null, + val displayName: String? = null, + val avatar: String? = null, + val avatarUrl: String? = null, +) { + val userOrSelf: ProfileUserDto? + get() = user ?: id?.let { + ProfileUserDto( + id = it, + username = username ?: "", + displayName = displayName, + avatarUrl = avatarUrl, + avatar = avatar, + ) + } +} + +/** + * `GET /api/follow/{userId}/mutual`. Confirmed live: counts only — + * `{ mutualFollowers, mutualFollowing }` (no user list). A couple of alias keys are + * tolerated defensively. + */ +@Serializable +data class MutualConnectionsResponse( + val mutualFollowers: Int? = null, + val mutualFollowing: Int? = null, + val followers: Int? = null, + val following: Int? = null, +) { + val mutualFollowersOrZero: Int get() = mutualFollowers ?: followers ?: 0 + val mutualFollowingOrZero: Int get() = mutualFollowing ?: following ?: 0 +} + +/** True when a JSON primitive holds a renderable, non-blank value. */ +internal fun JsonPrimitive.isRenderable(): Boolean = content.isNotBlank() diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/PublicContent.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/PublicContent.kt new file mode 100644 index 0000000..9d8499a --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/PublicContent.kt @@ -0,0 +1,83 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * Read-only domain models for another user's public content — their posts, lists, and + * documents — surfaced on the other-user profile screen (Milestone L). These are + * view-only projections; the module does not cache or mutate them (YAGNI), so they + * carry only the fields the read-only surfaces render. + */ + +/** A public post (message) authored by the viewed user. */ +data class PublicPost( + val id: String, + val content: String, + val createdAt: String?, +) + +/** A summary of a public list in the viewed user's Lists tab. */ +data class PublicListSummary( + val id: String, + val title: String, + val description: String?, +) { + /** A non-blank label to show, falling back to a placeholder for untitled lists. */ + val displayTitle: String get() = title.takeIf { it.isNotBlank() } ?: "Untitled list" +} + +/** + * A fully-loaded public list: its metadata plus its rows. Each row is a flat map of + * schema-field label → display value, projected from the dynamic `rowData` object so + * the read-only view can render arbitrary user-defined schemas without a fixed shape. + */ +data class PublicListDetail( + val id: String, + val title: String, + val description: String?, + val rows: List, +) { + val displayTitle: String get() = title.takeIf { it.isNotBlank() } ?: "Untitled list" +} + +/** A single read-only row: an ordered list of field label → value pairs. */ +data class PublicListRow( + val id: String, + val cells: List, +) + +/** One field within a [PublicListRow]. */ +data class PublicListCell( + val label: String, + val value: String, +) + +/** A summary of a public document in the viewed user's Documents tab. */ +data class PublicDocumentSummary( + val id: String, + val title: String, +) { + val displayTitle: String get() = title.takeIf { it.isNotBlank() } ?: "Untitled document" +} + +/** A fully-loaded public document: its title and markdown/plain-text content. */ +data class PublicDocumentDetail( + val id: String, + val title: String, + val content: String, +) { + val displayTitle: String get() = title.takeIf { it.isNotBlank() } ?: "Untitled document" +} + +/** + * Mutual-connection tallies with the viewed user, from `GET /api/follow/{userId}/mutual`. + * The endpoint returns counts only (no user list), so the indicator shows a count. + * + * @property mutualFollowers people who follow you that also follow the viewed user. + * @property mutualFollowing people you follow that the viewed user also follows. + */ +data class MutualConnections( + val mutualFollowers: Int = 0, + val mutualFollowing: Int = 0, +) { + /** The headline mutual-connection count shown on the profile. */ + val total: Int get() = mutualFollowers +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt index 9b56882..151178f 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.data.ProfileRepository import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,6 +32,10 @@ data class ProfileUiState( val followStatus: FollowStatus = FollowStatus.SELF, val followCounts: FollowCounts = FollowCounts(), val isFollowActionInProgress: Boolean = false, + // Public-content tabs — populated only on another user's profile (Milestone L). + val selectedTab: ProfileContentTab = ProfileContentTab.POSTS, + val content: PublicContentState = PublicContentState(), + val mutualConnections: MutualConnections? = null, ) { /** No cached user and not loading — nothing to render yet. */ val isEmpty: Boolean get() = user == null && !isLoading diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicContentState.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicContentState.kt new file mode 100644 index 0000000..da86052 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicContentState.kt @@ -0,0 +1,22 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost + +/** The content tabs shown on another user's public profile. */ +enum class ProfileContentTab { POSTS, LISTS, DOCUMENTS } + +/** + * The content shown under the other-user profile's tabs. Each tab is loaded lazily + * the first time it is selected ([loadedTabs]); [isLoading]/[errorMessage] track the + * currently-selected tab's fetch. + */ +data class PublicContentState( + val posts: List = emptyList(), + val lists: List = emptyList(), + val documents: List = emptyList(), + val isLoading: Boolean = false, + val errorMessage: String? = null, + val loadedTabs: Set = emptySet(), +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentScreen.kt new file mode 100644 index 0000000..b87b63e --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentScreen.kt @@ -0,0 +1,138 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail + +/** Stable test tags for the read-only public document view. */ +object PublicDocumentTestTags { + const val TITLE = "publicDocumentTitle" + const val CONTENT = "publicDocumentContent" + const val PROGRESS = "publicDocumentProgress" + const val ERROR = "publicDocumentError" + const val BACK = "publicDocumentBack" +} + +/** + * A read-only view of a public document (route `publicDocument/{documentId}`). Renders + * the document's raw content as plain text (no editing). + * + * @param onBack pop back to the profile. + */ +@Composable +fun PublicDocumentRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PublicDocumentViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + PublicDocumentScreen(state = state, onBack = onBack, onRetry = viewModel::refresh, modifier = modifier) +} + +/** Stateless read-only document UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun PublicDocumentScreen( + state: PublicDocumentUiState, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = state.document?.displayTitle ?: "Document", + modifier = Modifier.testTag(PublicDocumentTestTags.TITLE), + ) + }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(PublicDocumentTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading && state.document == null -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(PublicDocumentTestTags.PROGRESS), + ) + + state.document == null -> Column( + Modifier.align(Alignment.Center).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.errorMessage ?: "Couldn't load this document.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(PublicDocumentTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + + else -> Text( + text = state.document.content.ifBlank { "This document is empty." }, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + .testTag(PublicDocumentTestTags.CONTENT), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PublicDocumentScreenPreview() { + InterlinedListTheme { + PublicDocumentScreen( + state = PublicDocumentUiState( + document = PublicDocumentDetail( + id = "d1", + title = "Design notes", + content = "# Heading\n\nSome body text goes here.", + ), + isLoading = false, + ), + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentViewModel.kt new file mode 100644 index 0000000..f8d39f1 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicDocumentViewModel.kt @@ -0,0 +1,62 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav arg key the public-document route reads its document id from. */ +const val PUBLIC_DOCUMENT_ID_ARG = "documentId" + +/** UI state for the read-only public document view. */ +data class PublicDocumentUiState( + val document: PublicDocumentDetail? = null, + val isLoading: Boolean = true, + val errorMessage: String? = null, +) + +/** + * Drives a read-only view of a public document (route `publicDocument/{documentId}`). + * Loads the document (title + content) from `GET /api/documents/{id}`. No caching (YAGNI). + */ +@HiltViewModel +class PublicDocumentViewModel @Inject constructor( + private val repository: ProfileRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val documentId: String = checkNotNull(savedStateHandle[PUBLIC_DOCUMENT_ID_ARG]) { + "PublicDocumentViewModel requires a '$PUBLIC_DOCUMENT_ID_ARG' nav arg" + } + + private val _uiState = MutableStateFlow(PublicDocumentUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getDocument(documentId)) { + is ApiResult.Success -> _uiState.update { + it.copy(document = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListScreen.kt new file mode 100644 index 0000000..b2b98b8 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListScreen.kt @@ -0,0 +1,184 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.PublicListCell +import com.interlinedlist.android.feature.profile.domain.PublicListDetail +import com.interlinedlist.android.feature.profile.domain.PublicListRow + +/** Stable test tags for the read-only public list view. */ +object PublicListTestTags { + const val TITLE = "publicListTitle" + const val LIST = "publicListRows" + const val EMPTY = "publicListEmpty" + const val PROGRESS = "publicListProgress" + const val ERROR = "publicListError" + const val BACK = "publicListBack" + fun row(id: String) = "publicListRow_$id" +} + +/** + * A read-only view of another user's public list (route `publicList/{username}/{listId}`). + * + * @param onBack pop back to the profile. + */ +@Composable +fun PublicListRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: PublicListViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + PublicListScreen(state = state, onBack = onBack, onRetry = viewModel::refresh, modifier = modifier) +} + +/** Stateless read-only list UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun PublicListScreen( + state: PublicListUiState, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = state.list?.displayTitle ?: "List", + modifier = Modifier.testTag(PublicListTestTags.TITLE), + ) + }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(PublicListTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading && state.list == null -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(PublicListTestTags.PROGRESS), + ) + + state.list == null -> Column( + Modifier.align(Alignment.Center).padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = state.errorMessage ?: "Couldn't load this list.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(PublicListTestTags.ERROR), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + + state.list.rows.isEmpty() -> Text( + text = "This list has no rows.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.Center).testTag(PublicListTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(PublicListTestTags.LIST), + contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), + ) { + state.list.description?.takeIf { it.isNotBlank() }?.let { description -> + item { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + ) + } + } + items(state.list.rows, key = { it.id }) { row -> + PublicListRowCard(row) + Spacer(Modifier.height(8.dp)) + } + } + } + } + } +} + +@Composable +private fun PublicListRowCard(row: PublicListRow) { + Card( + modifier = Modifier.fillMaxWidth().testTag(PublicListTestTags.row(row.id)), + ) { + Column(Modifier.padding(12.dp)) { + row.cells.forEach { cell -> + Text( + text = "${cell.label}: ${cell.value}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PublicListScreenPreview() { + InterlinedListTheme { + PublicListScreen( + state = PublicListUiState( + list = PublicListDetail( + id = "l1", + title = "Todos", + description = "My todo list", + rows = listOf( + PublicListRow( + id = "r1", + cells = listOf( + PublicListCell("task", "Ship it"), + PublicListCell("done", "false"), + ), + ), + ), + ), + isLoading = false, + ), + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListViewModel.kt new file mode 100644 index 0000000..bcae6e8 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/PublicListViewModel.kt @@ -0,0 +1,67 @@ +package com.interlinedlist.android.feature.profile.ui.profile + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.PublicListDetail +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav arg keys the public-list route reads its owner username and list id from. */ +const val PUBLIC_LIST_USERNAME_ARG = "username" +const val PUBLIC_LIST_ID_ARG = "listId" + +/** UI state for the read-only public list view. */ +data class PublicListUiState( + val list: PublicListDetail? = null, + val isLoading: Boolean = true, + val errorMessage: String? = null, +) + +/** + * Drives a read-only view of another user's public list (route + * `publicList/{username}/{listId}`). Loads the list metadata and its rows from + * `GET /api/users/{username}/lists/{id}` (+ `/data`). No caching (YAGNI). + */ +@HiltViewModel +class PublicListViewModel @Inject constructor( + private val repository: ProfileRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val username: String = checkNotNull(savedStateHandle[PUBLIC_LIST_USERNAME_ARG]) { + "PublicListViewModel requires a '$PUBLIC_LIST_USERNAME_ARG' nav arg" + } + private val listId: String = checkNotNull(savedStateHandle[PUBLIC_LIST_ID_ARG]) { + "PublicListViewModel requires a '$PUBLIC_LIST_ID_ARG' nav arg" + } + + private val _uiState = MutableStateFlow(PublicListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getUserList(username, listId)) { + is ApiResult.Success -> _uiState.update { + it.copy(list = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt index 91df18f..be0e1fc 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt @@ -1,21 +1,31 @@ package com.interlinedlist.android.feature.profile.ui.profile +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.filled.Group import androidx.compose.material3.Button +import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -23,6 +33,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -31,23 +42,48 @@ import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.core.model.CustomerStatus import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost +import com.interlinedlist.android.feature.profile.ui.account.relativeTime + +/** Stable test tags for the other-user profile's content tabs. */ +object ProfileContentTestTags { + const val TABS = "profileContentTabs" + const val MUTUAL = "profileMutualConnections" + const val CONTENT_PROGRESS = "profileContentProgress" + const val CONTENT_ERROR = "profileContentError" + const val CONTENT_EMPTY = "profileContentEmpty" + const val CONTENT_LIST = "profileContentList" + fun tab(tab: ProfileContentTab) = "profileTab_${tab.name}" + fun postRow(id: String) = "profilePostRow_$id" + fun listRow(id: String) = "profileListRow_$id" + fun documentRow(id: String) = "profileDocumentRow_$id" +} /** * Another user's public profile, reached by drilling down from search (route - * `profile/{username}`). Includes a back affordance to ascend, mirroring the app's - * drill-down navigation pattern, plus a follow/unfollow button and tappable - * follower/following counts. + * `profile/{username}`). Shows the profile header, a mutual-connections indicator, + * and content tabs (Posts / Lists / Documents), each backed by its own endpoint. * * @param onBack pop back to the previous screen (search). * @param onOpenFollowers open this user's followers list (`followers/{username}`). * @param onOpenFollowing open this user's following list (`following/{username}`). + * @param onOpenList open a public list read-only (`publicList/{username}/{listId}`). + * @param onOpenDocument open a public document read-only (`publicDocument/{documentId}`). */ @Composable fun UserProfileRoute( onBack: () -> Unit, onOpenFollowers: (String) -> Unit, onOpenFollowing: (String) -> Unit, + // Read-only content drill-downs (Milestone L). Defaulted to no-ops so existing app + // wiring compiles unchanged; wire these to the `publicList`/`publicDocument` routes + // to enable opening public content (see the module's nav-wiring snippet). + onOpenList: (String, String) -> Unit = { _, _ -> }, + onOpenDocument: (String) -> Unit = {}, modifier: Modifier = Modifier, viewModel: UserProfileViewModel = hiltViewModel(), ) { @@ -57,13 +93,16 @@ fun UserProfileRoute( onBack = onBack, onRetry = viewModel::refresh, onToggleFollow = viewModel::toggleFollow, + onSelectTab = viewModel::selectTab, onOpenFollowers = { state.user?.username?.let(onOpenFollowers) }, onOpenFollowing = { state.user?.username?.let(onOpenFollowing) }, + onOpenList = { listId -> state.user?.username?.let { onOpenList(it, listId) } }, + onOpenDocument = onOpenDocument, modifier = modifier, ) } -/** Stateless other-user profile UI. */ +/** Stateless other-user profile UI with content tabs. */ @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) @Composable fun UserProfileScreen( @@ -71,8 +110,11 @@ fun UserProfileScreen( onBack: () -> Unit, onRetry: () -> Unit, onToggleFollow: () -> Unit = {}, + onSelectTab: (ProfileContentTab) -> Unit = {}, onOpenFollowers: () -> Unit = {}, onOpenFollowing: () -> Unit = {}, + onOpenList: (String) -> Unit = {}, + onOpenDocument: (String) -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -89,19 +131,39 @@ fun UserProfileScreen( }, ) { padding -> when { - state.user != null -> ProfileContent( - user = state.user, + state.user != null -> LazyColumn( modifier = Modifier .fillMaxSize() .padding(padding) - .verticalScroll(rememberScrollState()), - counts = state.followCounts, - onOpenFollowers = onOpenFollowers, - onOpenFollowing = onOpenFollowing, - followStatus = state.followStatus, - isFollowActionInProgress = state.isFollowActionInProgress, - onToggleFollow = onToggleFollow, - ) + .testTag(ProfileContentTestTags.CONTENT_LIST), + ) { + item(key = "header") { + ProfileContent( + user = state.user, + counts = state.followCounts, + onOpenFollowers = onOpenFollowers, + onOpenFollowing = onOpenFollowing, + followStatus = state.followStatus, + isFollowActionInProgress = state.isFollowActionInProgress, + onToggleFollow = onToggleFollow, + ) + } + + state.mutualConnections?.takeIf { it.total > 0 }?.let { mutual -> + item(key = "mutual") { MutualConnectionsRow(mutual) } + } + + item(key = "tabs") { + ContentTabRow(selected = state.selectedTab, onSelectTab = onSelectTab) + } + + contentTabItems( + state = state, + onOpenList = onOpenList, + onOpenDocument = onOpenDocument, + onRetry = onRetry, + ) + } state.isLoading -> Box( Modifier.fillMaxSize().padding(padding), @@ -129,6 +191,229 @@ fun UserProfileScreen( } } +/** Renders the selected tab's rows as LazyColumn items (loading / error / empty / content). */ +private fun androidx.compose.foundation.lazy.LazyListScope.contentTabItems( + state: ProfileUiState, + onOpenList: (String) -> Unit, + onOpenDocument: (String) -> Unit, + onRetry: () -> Unit, +) { + val content = state.content + when { + content.isLoading -> item(key = "content-loading") { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator(Modifier.testTag(ProfileContentTestTags.CONTENT_PROGRESS)) + } + } + + content.errorMessage != null -> item(key = "content-error") { + Column( + Modifier.fillMaxWidth().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = content.errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ProfileContentTestTags.CONTENT_ERROR), + ) + Spacer(Modifier.height(12.dp)) + Button(onClick = onRetry) { Text("Retry") } + } + } + + else -> when (state.selectedTab) { + ProfileContentTab.POSTS -> postItems(content.posts) + ProfileContentTab.LISTS -> listItems(content.lists, onOpenList) + ProfileContentTab.DOCUMENTS -> documentItems(content.documents, onOpenDocument) + } + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.postItems(posts: List) { + if (posts.isEmpty()) { + emptyItem("No posts yet.") + return + } + items(posts, key = { it.id }) { post -> PostCard(post) } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.listItems( + lists: List, + onOpenList: (String) -> Unit, +) { + if (lists.isEmpty()) { + emptyItem("No public lists yet.") + return + } + items(lists, key = { it.id }) { list -> + SummaryRow( + title = list.displayTitle, + subtitle = list.description, + tag = ProfileContentTestTags.listRow(list.id), + onClick = { onOpenList(list.id) }, + ) + HorizontalDivider() + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.documentItems( + documents: List, + onOpenDocument: (String) -> Unit, +) { + if (documents.isEmpty()) { + emptyItem("No public documents yet.") + return + } + items(documents, key = { it.id }) { document -> + SummaryRow( + title = document.displayTitle, + subtitle = null, + tag = ProfileContentTestTags.documentRow(document.id), + onClick = { onOpenDocument(document.id) }, + ) + HorizontalDivider() + } +} + +private fun androidx.compose.foundation.lazy.LazyListScope.emptyItem(message: String) { + item(key = "content-empty") { + Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ProfileContentTestTags.CONTENT_EMPTY), + ) + } + } +} + +@Composable +private fun MutualConnectionsRow(mutual: MutualConnections) { + val label = if (mutual.total == 1) { + "1 mutual connection" + } else { + "${mutual.total} mutual connections" + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + .testTag(ProfileContentTestTags.MUTUAL), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Default.Group, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) +} + +@Composable +private fun ContentTabRow( + selected: ProfileContentTab, + onSelectTab: (ProfileContentTab) -> Unit, +) { + val tabs = ProfileContentTab.entries + TabRow( + selectedTabIndex = tabs.indexOf(selected), + modifier = Modifier.fillMaxWidth().testTag(ProfileContentTestTags.TABS), + ) { + tabs.forEach { tab -> + Tab( + selected = tab == selected, + onClick = { onSelectTab(tab) }, + modifier = Modifier.testTag(ProfileContentTestTags.tab(tab)), + text = { Text(tab.label) }, + ) + } + } +} + +@Composable +private fun PostCard(post: PublicPost) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp) + .testTag(ProfileContentTestTags.postRow(post.id)), + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = post.content, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + post.createdAt?.let { + Spacer(Modifier.height(8.dp)) + Text( + text = relativeTime(it), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun SummaryRow( + title: String, + subtitle: String?, + tag: String, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(tag) + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** Human-readable label for a content tab. */ +private val ProfileContentTab.label: String + get() = when (this) { + ProfileContentTab.POSTS -> "Posts" + ProfileContentTab.LISTS -> "Lists" + ProfileContentTab.DOCUMENTS -> "Documents" + } + @Preview(showBackground = true) @Composable private fun UserProfileScreenPreview() { @@ -147,6 +432,12 @@ private fun UserProfileScreenPreview() { isLoading = false, followStatus = FollowStatus.NOT_FOLLOWING, followCounts = FollowCounts(followers = 128, following = 87), + mutualConnections = MutualConnections(mutualFollowers = 3, mutualFollowing = 1), + content = PublicContentState( + posts = listOf(PublicPost("m1", "Hello from Ada!", null)), + isLoading = false, + loadedTabs = setOf(ProfileContentTab.POSTS), + ), ), onBack = {}, onRetry = {}, diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt index f98bdcf..718d1fd 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt @@ -59,6 +59,9 @@ class UserProfileViewModel @Inject constructor( is ApiResult.Success -> { _uiState.update { it.copy(user = result.data, isLoading = false) } loadFollow(result.data.id, result.data.isCurrentUser) + loadMutual(result.data.id, result.data.isCurrentUser) + // Load the initially-selected tab now that the username is confirmed. + loadTab(_uiState.value.selectedTab) } is ApiResult.Failure -> _uiState.update { it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) @@ -67,6 +70,74 @@ class UserProfileViewModel @Inject constructor( } } + /** + * Switches the visible content tab, loading its data the first time it is shown. + * Already-loaded tabs are not refetched; a tap on the current tab is a no-op. + */ + fun selectTab(tab: ProfileContentTab) { + if (_uiState.value.selectedTab == tab && _uiState.value.content.loadedTabs.contains(tab)) { + return + } + _uiState.update { it.copy(selectedTab = tab) } + loadTab(tab) + } + + /** Loads a content tab once (unless already loaded), tracking loading/error per tab. */ + private fun loadTab(tab: ProfileContentTab) { + if (_uiState.value.content.loadedTabs.contains(tab)) { + // Show cached content immediately; nothing to fetch. + _uiState.update { it.copy(content = it.content.copy(isLoading = false, errorMessage = null)) } + return + } + _uiState.update { it.copy(content = it.content.copy(isLoading = true, errorMessage = null)) } + viewModelScope.launch { + when (tab) { + ProfileContentTab.POSTS -> handleTabResult(tab, repository.getUserPosts(username)) { content, data -> + content.copy(posts = data) + } + ProfileContentTab.LISTS -> handleTabResult(tab, repository.getUserLists(username)) { content, data -> + content.copy(lists = data) + } + ProfileContentTab.DOCUMENTS -> handleTabResult(tab, repository.getUserDocuments(username)) { content, data -> + content.copy(documents = data) + } + } + } + } + + /** Folds a tab fetch result into the content state, tracking loaded/error per tab. */ + private fun handleTabResult( + tab: ProfileContentTab, + result: ApiResult, + apply: (PublicContentState, T) -> PublicContentState, + ) { + _uiState.update { state -> + val content = when (result) { + is ApiResult.Success -> apply(state.content, result.data).copy( + isLoading = false, + errorMessage = null, + loadedTabs = state.content.loadedTabs + tab, + ) + is ApiResult.Failure -> state.content.copy( + isLoading = false, + errorMessage = result.error.toUserMessage(), + ) + } + state.copy(content = content) + } + } + + /** Loads mutual-connection counts for another user (not fetched for your own profile). */ + private fun loadMutual(userId: String, isCurrentUser: Boolean) { + if (isCurrentUser) return + viewModelScope.launch { + val result = repository.getMutualConnections(userId) + if (result is ApiResult.Success) { + _uiState.update { it.copy(mutualConnections = result.data) } + } + } + } + /** Loads the follow status and counts for the viewed user once its id is known. */ private fun loadFollow(userId: String, isCurrentUser: Boolean) { viewModelScope.launch { diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt index 17ba115..918b4e2 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt @@ -469,6 +469,206 @@ class DefaultProfileRepositoryTest { assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) } + // --- Public content --- + + @Test + fun `getUserPosts reads the singular user messages endpoint and maps posts`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "messages": [ + { "id": "m1", "content": "Hello world", "createdAt": "2026-07-29T20:56:30.392Z" }, + { "id": "m2", "content": "Second post", "createdAt": "2026-07-28T10:00:00.000Z" } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getUserPosts("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val posts = (result as ApiResult.Success).data + assertThat(posts.map { it.id }).containsExactly("m1", "m2").inOrder() + assertThat(posts.first().content).isEqualTo("Hello world") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("GET") + // The posts endpoint uses the SINGULAR `user` segment (confirmed live). + assertThat(recorded.path).startsWith("/api/user/ada/messages") + } + + @Test + fun `getUserLists reads the lists envelope and maps summaries`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "lists": [ + { "id": "l1", "title": "Todos", "description": "My todo list", "isPublic": true }, + { "id": "l2", "title": "Reading", "description": null, "isPublic": true } + ], + "pagination": { "total": 2, "limit": 50, "offset": 0, "hasMore": false } + } + """.trimIndent(), + ), + ) + + val result = repository.getUserLists("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val lists = (result as ApiResult.Success).data + assertThat(lists.map { it.id }).containsExactly("l1", "l2").inOrder() + assertThat(lists.first().title).isEqualTo("Todos") + assertThat(lists[1].description).isNull() + + assertThat(server.takeRequest().path).startsWith("/api/users/ada/lists") + } + + @Test + fun `getUserList combines the wrapped list metadata with its rows`() = runTest(testDispatcher) { + // First: list metadata, wrapped under `list` (alongside `ancestors`). + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "list": { "id": "l1", "title": "Todos", "description": "My todo list" }, + "ancestors": [ { "id": "p1", "title": "Parent" } ] + } + """.trimIndent(), + ), + ) + // Second: rows, with the dynamic field map under `rowData`. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "rows": [ + { "id": "r1", "rowData": { "task": "Ship it", "done": false } }, + { "id": "r2", "rowData": { "task": "Test it", "done": true } } + ], + "pagination": { "total": 2, "limit": 50, "offset": 0, "hasMore": false } + } + """.trimIndent(), + ), + ) + + val result = repository.getUserList("ada", "l1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val detail = (result as ApiResult.Success).data + assertThat(detail.id).isEqualTo("l1") + assertThat(detail.title).isEqualTo("Todos") + assertThat(detail.rows.map { it.id }).containsExactly("r1", "r2").inOrder() + // The dynamic rowData map is projected to display cells. + val firstCells = detail.rows.first().cells.associate { it.label to it.value } + assertThat(firstCells["task"]).isEqualTo("Ship it") + assertThat(firstCells["done"]).isEqualTo("false") + + assertThat(server.takeRequest().path).isEqualTo("/api/users/ada/lists/l1") + assertThat(server.takeRequest().path).startsWith("/api/users/ada/lists/l1/data") + } + + @Test + fun `getUserList maps a 404 on the metadata to NotFound`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "No such list" }""")) + + val result = repository.getUserList("ada", "ghost") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `getUserDocuments reads the documents envelope and maps summaries`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "documents": [ + { "id": "d1", "title": "Design notes" }, + { "id": "d2", "title": "Roadmap" } + ], + "folders": [] + } + """.trimIndent(), + ), + ) + + val result = repository.getUserDocuments("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val docs = (result as ApiResult.Success).data + assertThat(docs.map { it.id }).containsExactly("d1", "d2").inOrder() + assertThat(docs.first().title).isEqualTo("Design notes") + + assertThat(server.takeRequest().path).isEqualTo("/api/users/ada/documents") + } + + @Test + fun `getDocument reads the wrapped document with its content`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "document": { + "id": "d1", + "title": "Design notes", + "content": "# Heading\n\nSome body text." + } + } + """.trimIndent(), + ), + ) + + val result = repository.getDocument("d1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val doc = (result as ApiResult.Success).data + assertThat(doc.title).isEqualTo("Design notes") + assertThat(doc.content).contains("Some body text.") + + assertThat(server.takeRequest().path).isEqualTo("/api/documents/d1") + } + + @Test + fun `getMutualConnections parses the follower and following counts`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "mutualFollowers": 3, "mutualFollowing": 1 }""", + ), + ) + + val result = repository.getMutualConnections("u2") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val mutual = (result as ApiResult.Success).data + assertThat(mutual.mutualFollowers).isEqualTo(3) + assertThat(mutual.mutualFollowing).isEqualTo(1) + assertThat(mutual.total).isEqualTo(3) + + assertThat(server.takeRequest().path).isEqualTo("/api/follow/u2/mutual") + } + + @Test + fun `lookupUser resolves a bare user object by handle`() = runTest(testDispatcher) { + // Confirmed live: lookup returns a bare user object, not an envelope. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "id": "u2", "username": "ada", "displayName": "Ada Lovelace", "avatar": "https://cdn/a.png", "isPrivate": false }""", + ), + ) + + val response = api.lookupUser("ada") + + assertThat(response.userOrSelf?.id).isEqualTo("u2") + assertThat(response.userOrSelf?.username).isEqualTo("ada") + assertThat(response.userOrSelf?.avatarOrNull).isEqualTo("https://cdn/a.png") + + assertThat(server.takeRequest().path).isEqualTo("/api/users/lookup?handle=ada") + } + // --- Account & Security --- @Test diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt index 0b17f1b..80e05c7 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt @@ -9,7 +9,13 @@ import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.LinkedIdentity import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListDetail +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost import com.interlinedlist.android.feature.profile.domain.UserSearchResult import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -149,6 +155,60 @@ class FakeProfileRepository : ProfileRepository { return removeFollowerResult } + // --- Public content --- + + var postsResult: ApiResult> = ApiResult.Success(emptyList()) + var listsResult: ApiResult> = ApiResult.Success(emptyList()) + var listDetailResult: ApiResult = + ApiResult.Failure(AppError.NotFound("not set")) + var documentsResult: ApiResult> = ApiResult.Success(emptyList()) + var documentDetailResult: ApiResult = + ApiResult.Failure(AppError.NotFound("not set")) + var mutualResult: ApiResult = ApiResult.Success(MutualConnections()) + + var postsUsername: String? = null + var listsUsername: String? = null + var listDetailArgs: Pair? = null + var documentsUsername: String? = null + var documentDetailId: String? = null + var mutualUserId: String? = null + var postsCount = 0 + var listsCount = 0 + var documentsCount = 0 + + override suspend fun getUserPosts(username: String): ApiResult> { + postsUsername = username + postsCount++ + return postsResult + } + + override suspend fun getUserLists(username: String): ApiResult> { + listsUsername = username + listsCount++ + return listsResult + } + + override suspend fun getUserList(username: String, listId: String): ApiResult { + listDetailArgs = username to listId + return listDetailResult + } + + override suspend fun getUserDocuments(username: String): ApiResult> { + documentsUsername = username + documentsCount++ + return documentsResult + } + + override suspend fun getDocument(documentId: String): ApiResult { + documentDetailId = documentId + return documentDetailResult + } + + override suspend fun getMutualConnections(userId: String): ApiResult { + mutualUserId = userId + return mutualResult + } + // --- Account & Security --- var sessionsResult: ApiResult> = ApiResult.Success(emptyList()) diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileContentTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileContentTest.kt new file mode 100644 index 0000000..5b117d0 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileContentTest.kt @@ -0,0 +1,180 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.domain.MutualConnections +import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary +import com.interlinedlist.android.feature.profile.domain.PublicListSummary +import com.interlinedlist.android.feature.profile.domain.PublicPost +import com.interlinedlist.android.feature.profile.ui.profile.PROFILE_USERNAME_ARG +import com.interlinedlist.android.feature.profile.ui.profile.ProfileContentTab +import com.interlinedlist.android.feature.profile.ui.profile.UserProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * Covers the other-user profile's content tabs (Posts / Lists / Documents) and the + * mutual-connections indicator added in Milestone L. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class UserProfileContentTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(username: String) = + UserProfileViewModel(repo, SavedStateHandle(mapOf(PROFILE_USERNAME_ARG to username))) + + @Test + fun `loads posts for the default tab once the user resolves`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.postsResult = ApiResult.Success( + listOf(PublicPost(id = "m1", content = "Hello", createdAt = null)), + ) + + val vm = viewModel("ada") + advanceUntilIdle() + + assertThat(vm.uiState.value.selectedTab).isEqualTo(ProfileContentTab.POSTS) + assertThat(repo.postsUsername).isEqualTo("ada") + assertThat(vm.uiState.value.content.posts.map { it.id }).containsExactly("m1") + assertThat(vm.uiState.value.content.isLoading).isFalse() + } + + @Test + fun `loads mutual connections for another user`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.mutualResult = ApiResult.Success(MutualConnections(mutualFollowers = 4, mutualFollowing = 1)) + + val vm = viewModel("ada") + advanceUntilIdle() + + assertThat(repo.mutualUserId).isEqualTo("u2") + assertThat(vm.uiState.value.mutualConnections?.total).isEqualTo(4) + } + + @Test + fun `does not fetch mutual connections for your own profile`() = runTest(dispatcher) { + val me = testUser(id = "me", username = "adron", isCurrentUser = true) + repo.refreshUserResult = ApiResult.Success(me) + + val vm = viewModel("adron") + advanceUntilIdle() + + assertThat(repo.mutualUserId).isNull() + assertThat(vm.uiState.value.mutualConnections).isNull() + } + + @Test + fun `switching to the Lists tab loads lists lazily`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.postsResult = ApiResult.Success(emptyList()) + repo.listsResult = ApiResult.Success( + listOf(PublicListSummary(id = "l1", title = "Todos", description = null)), + ) + + val vm = viewModel("ada") + advanceUntilIdle() + // Lists tab has not been visited yet, so no lists call has happened. + assertThat(repo.listsCount).isEqualTo(0) + + vm.selectTab(ProfileContentTab.LISTS) + advanceUntilIdle() + + assertThat(vm.uiState.value.selectedTab).isEqualTo(ProfileContentTab.LISTS) + assertThat(repo.listsUsername).isEqualTo("ada") + assertThat(vm.uiState.value.content.lists.map { it.id }).containsExactly("l1") + } + + @Test + fun `switching to the Documents tab loads documents lazily`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.postsResult = ApiResult.Success(emptyList()) + repo.documentsResult = ApiResult.Success( + listOf(PublicDocumentSummary(id = "d1", title = "Notes")), + ) + + val vm = viewModel("ada") + advanceUntilIdle() + + vm.selectTab(ProfileContentTab.DOCUMENTS) + advanceUntilIdle() + + assertThat(vm.uiState.value.selectedTab).isEqualTo(ProfileContentTab.DOCUMENTS) + assertThat(repo.documentsUsername).isEqualTo("ada") + assertThat(vm.uiState.value.content.documents.map { it.id }).containsExactly("d1") + } + + @Test + fun `re-selecting an already-loaded tab does not refetch`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.postsResult = ApiResult.Success(emptyList()) + + val vm = viewModel("ada") + advanceUntilIdle() + assertThat(repo.postsCount).isEqualTo(1) + + vm.selectTab(ProfileContentTab.POSTS) + advanceUntilIdle() + + // Posts were already loaded; re-selecting the same tab is a no-op. + assertThat(repo.postsCount).isEqualTo(1) + } + + @Test + fun `a content load failure surfaces an error on the tab`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.postsResult = ApiResult.Failure(AppError.Server("boom")) + + val vm = viewModel("ada") + advanceUntilIdle() + + assertThat(vm.uiState.value.content.errorMessage).isNotNull() + assertThat(vm.uiState.value.content.isLoading).isFalse() + } + + @Test + fun `content state transitions through loading with Turbine`() = runTest(dispatcher) { + val ada = testUser(id = "u2", username = "ada", isCurrentUser = false) + repo.refreshUserResult = ApiResult.Success(ada) + repo.postsResult = ApiResult.Success( + listOf(PublicPost(id = "m1", content = "Hi", createdAt = null)), + ) + + val vm = viewModel("ada") + vm.uiState.test { + // Initial emission before the user resolves. + assertThat(awaitItem().content.posts).isEmpty() + advanceUntilIdle() + val settled = expectMostRecentItem() + assertThat(settled.content.posts.map { it.id }).containsExactly("m1") + assertThat(settled.content.isLoading).isFalse() + } + } +} From e1685c3f773995c4a98ad8427743d17e371af7cb Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 15:28:22 -0700 Subject: [PATCH 14/25] feat(lists,documents): sharing & share links (Milestone F) Create/list/revoke tokenized share links (view/edit/admin) on lists and documents; resolve + claim shared links; 'Shared with me' backed by /api/lists/watching (with role). Endpoints: {lists,documents}/{id}/share-links (+/{token} DELETE), {lists,documents}/shared/{token} (GET/POST), lists/watching, lists/shared/{token}/data. lists 92 + documents 72 unit tests green. Deep-link intent-filters + nav wiring deferred (snippets in report). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/share/DocumentShareScreenTest.kt | 82 ++++++ .../data/DefaultDocumentsRepository.kt | 43 +++ .../documents/data/DocumentsRepository.kt | 20 ++ .../documents/data/mapper/ShareMappers.kt | 31 +++ .../documents/data/remote/DocumentsApi.kt | 32 +++ .../documents/data/remote/dto/ShareDtos.kt | 96 +++++++ .../feature/documents/domain/ShareLink.kt | 65 +++++ .../ui/editor/DocumentEditorScreen.kt | 11 + .../documents/ui/share/DocumentShareScreen.kt | 243 +++++++++++++++++ .../ui/share/DocumentShareViewModel.kt | 100 +++++++ .../ui/share/SharedDocumentScreen.kt | 208 +++++++++++++++ .../ui/share/SharedDocumentViewModel.kt | 80 ++++++ .../DefaultDocumentsRepositoryShareTest.kt | 186 +++++++++++++ .../documents/ui/FakeDocumentsRepository.kt | 46 ++++ .../ui/share/DocumentShareViewModelTest.kt | 115 ++++++++ .../ui/share/SharedDocumentViewModelTest.kt | 108 ++++++++ .../lists/ui/detail/ListDetailScreenTest.kt | 22 +- .../feature/lists/ui/share/ShareScreenTest.kt | 88 ++++++ .../lists/ui/share/SharedWithMeScreenTest.kt | 69 +++++ .../lists/data/DefaultListsRepository.kt | 51 ++++ .../feature/lists/data/ListsRepository.kt | 24 ++ .../android/feature/lists/data/ShareMapper.kt | 48 ++++ .../feature/lists/data/remote/ListsApi.kt | 37 +++ .../lists/data/remote/dto/ShareDtos.kt | 137 ++++++++++ .../android/feature/lists/domain/ShareLink.kt | 48 ++++ .../feature/lists/domain/SharedList.kt | 36 +++ .../lists/ui/detail/ListDetailScreen.kt | 9 + .../feature/lists/ui/list/ListsScreen.kt | 11 + .../feature/lists/ui/share/ShareScreen.kt | 250 ++++++++++++++++++ .../feature/lists/ui/share/ShareViewModel.kt | 101 +++++++ .../lists/ui/share/SharedListScreen.kt | 237 +++++++++++++++++ .../lists/ui/share/SharedListViewModel.kt | 81 ++++++ .../lists/ui/share/SharedWithMeScreen.kt | 194 ++++++++++++++ .../lists/ui/share/SharedWithMeViewModel.kt | 50 ++++ .../feature/lists/FakeListsRepository.kt | 50 ++++ .../data/DefaultListsRepositoryShareTest.kt | 236 +++++++++++++++++ .../lists/ui/share/ShareViewModelTest.kt | 134 ++++++++++ .../lists/ui/share/SharedListViewModelTest.kt | 127 +++++++++ 38 files changed, 3505 insertions(+), 1 deletion(-) create mode 100644 feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreenTest.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/ShareMappers.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ShareDtos.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ShareLink.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModel.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModel.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModelTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModelTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreenTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreenTest.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ShareMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ShareDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ShareLink.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/SharedList.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeViewModel.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModelTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModelTest.kt diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreenTest.kt new file mode 100644 index 0000000..03ba31b --- /dev/null +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreenTest.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Verifies the document share sheet renders existing links, role chips, and a create control. */ +@RunWith(AndroidJUnit4::class) +class DocumentShareScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent( + state: DocumentShareUiState, + onSelectRole: (ShareRole) -> Unit = {}, + onCreate: () -> Unit = {}, + onRevoke: (ShareLink) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + DocumentShareSheetContent( + state = state, + onSelectRole = onSelectRole, + onCreate = onCreate, + onRevoke = onRevoke, + ) + } + } + } + + @Test + fun rendersExistingLinks_andCreateControl() { + setContent( + DocumentShareUiState( + links = listOf( + ShareLink("1", "abc123", ShareRole.VIEW, null, null, null), + ShareLink("2", "def456", ShareRole.EDIT, null, null, null), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(DocumentShareTestTags.CREATE).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentShareTestTags.link("abc123")).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentShareTestTags.link("def456")).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentShareTestTags.copy("abc123")).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentShareTestTags.revoke("abc123")).assertIsDisplayed() + } + + @Test + fun createButton_invokesCallback() { + var created = false + setContent(DocumentShareUiState(isLoading = false), onCreate = { created = true }) + + composeRule.onNodeWithTag(DocumentShareTestTags.CREATE).performClick() + assert(created) + } + + @Test + fun roleChip_selectsRole() { + var selected: ShareRole? = null + setContent(DocumentShareUiState(isLoading = false), onSelectRole = { selected = it }) + + composeRule.onNodeWithTag(DocumentShareTestTags.role(ShareRole.ADMIN)).performClick() + assert(selected == ShareRole.ADMIN) + } + + @Test + fun emptyState_isShown_whenNoLinks() { + setContent(DocumentShareUiState(links = emptyList(), isLoading = false)) + composeRule.onNodeWithTag(DocumentShareTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt index ed9a93e..eb8bb84 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt @@ -10,10 +10,12 @@ import com.interlinedlist.android.feature.documents.data.local.FolderDao import com.interlinedlist.android.feature.documents.data.local.toDomain import com.interlinedlist.android.feature.documents.data.local.toEntity import com.interlinedlist.android.feature.documents.data.mapper.toDomain +import com.interlinedlist.android.feature.documents.data.mapper.toSharedDocument import com.interlinedlist.android.feature.documents.data.mapper.toTemplate import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateFolderRequest @@ -24,6 +26,9 @@ import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.domain.FolderTree +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.domain.SharedDocument import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first @@ -318,6 +323,44 @@ class DefaultDocumentsRepository @Inject constructor( .map { response -> response.documentsOrEmpty.map { it.toDomain() } } } + override suspend fun getShareLinks(documentId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getShareLinks(documentId) } + .map { response -> response.items.map { it.toDomain() } } + } + + override suspend fun createShareLink(documentId: String, role: ShareRole): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { + api.createShareLink(documentId, CreateShareLinkRequest(role = role.apiValue)) + }) { + is ApiResult.Success -> { + val dto = result.data.linkOrSelf + ?: return@withContext ApiResult.Failure( + AppError.Unknown("Share link create returned no token"), + ) + ApiResult.Success(dto.toDomain()) + } + is ApiResult.Failure -> result + } + } + + override suspend fun revokeShareLink(documentId: String, token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.revokeShareLink(documentId, token) }.map { } + } + + override suspend fun resolveSharedDocument(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.resolveSharedDocument(token) } + .map { response -> response.toSharedDocument(token) } + } + + override suspend fun claimSharedDocument(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.claimSharedDocument(token) }.map { } + } + // --- Helpers ----------------------------------------------------------- private fun buildTree(folders: List, documents: List): FolderNode { diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt index c917b1f..28522cc 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt @@ -6,6 +6,9 @@ import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.domain.SharedDocument import kotlinx.coroutines.flow.Flow /** @@ -88,4 +91,21 @@ interface DocumentsRepository { /** One-shot search against the API (not cached). */ suspend fun searchDocuments(query: String): ApiResult> + + // --- Sharing ----------------------------------------------------------- + + /** Existing public share links for a document. */ + suspend fun getShareLinks(documentId: String): ApiResult> + + /** Creates a share link granting [role]; returns the created link. */ + suspend fun createShareLink(documentId: String, role: ShareRole): ApiResult + + /** Revokes a share link by its token. */ + suspend fun revokeShareLink(documentId: String, token: String): ApiResult + + /** Resolves a public `documents/shared/{token}` link to a read-only preview. */ + suspend fun resolveSharedDocument(token: String): ApiResult + + /** Claims edit/admin access to a shared document via its token. */ + suspend fun claimSharedDocument(token: String): ApiResult } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/ShareMappers.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/ShareMappers.kt new file mode 100644 index 0000000..078aee7 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/ShareMappers.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.documents.data.mapper + +import com.interlinedlist.android.feature.documents.data.remote.dto.ShareLinkDto +import com.interlinedlist.android.feature.documents.data.remote.dto.SharedDocumentResponse +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.domain.SharedDocument + +/** Maps a share-link wire model into the domain [ShareLink]. */ +fun ShareLinkDto.toDomain(): ShareLink = ShareLink( + id = id, + token = token, + role = ShareRole.fromApi(role), + expiresAt = expiresAt, + revokedAt = revokedAt, + createdAt = createdAt, +) + +/** Maps a resolved-link response into the domain [SharedDocument] preview. */ +fun SharedDocumentResponse.toSharedDocument(token: String): SharedDocument { + val doc = documentOrSelf + val owner = user?.let { it.displayName?.takeIf(String::isNotBlank) ?: it.username.takeIf(String::isNotBlank) } + return SharedDocument( + token = token, + documentId = doc?.id.orEmpty(), + title = doc?.title?.takeIf(String::isNotBlank) ?: "Untitled", + content = doc?.content, + ownerName = owner, + role = ShareRole.fromApi(role), + ) +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt index 281a7f3..c4619e4 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt @@ -2,11 +2,15 @@ package com.interlinedlist.android.feature.documents.data.remote import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FolderListResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FolderResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.ShareLinkEnvelope +import com.interlinedlist.android.feature.documents.data.remote.dto.ShareLinksResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.SharedDocumentResponse import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateFolderRequest import okhttp3.MultipartBody @@ -97,4 +101,32 @@ interface DocumentsApi { @POST("api/documents/from-template") suspend fun createFromTemplate(@Body body: FromTemplateRequest): DocumentResponse + + // --- Sharing ----------------------------------------------------------- + + /** Existing public share links for a document. */ + @GET("api/documents/{id}/share-links") + suspend fun getShareLinks(@Path("id") id: String): ShareLinksResponse + + /** Creates a share link granting the requested role (optionally expiring). */ + @POST("api/documents/{id}/share-links") + suspend fun createShareLink( + @Path("id") id: String, + @Body body: CreateShareLinkRequest, + ): ShareLinkEnvelope + + /** Revokes (deletes) a share link by its token. */ + @DELETE("api/documents/{id}/share-links/{token}") + suspend fun revokeShareLink( + @Path("id") id: String, + @Path("token") token: String, + ) + + /** Resolves a public share link to a read-only preview of the shared document. */ + @GET("api/documents/shared/{token}") + suspend fun resolveSharedDocument(@Path("token") token: String): SharedDocumentResponse + + /** Claims edit/admin access to a shared document as the logged-in user. */ + @POST("api/documents/shared/{token}") + suspend fun claimSharedDocument(@Path("token") token: String) } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ShareDtos.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ShareDtos.kt new file mode 100644 index 0000000..3257b90 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/ShareDtos.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the document sharing endpoints (see the `DocumentShareLink` + * schema). The shared Json ignores unknown keys and coerces invalid values to + * defaults, so all optionals are defaulted and only rendered fields are declared. + */ +@Serializable +data class ShareLinkDto( + val id: String = "", + val documentId: String? = null, + val token: String = "", + val role: String? = null, + val expiresAt: String? = null, + val revokedAt: String? = null, + val createdAt: String? = null, +) + +/** Envelope for `GET /api/documents/{id}/share-links` (verified live: `shareLinks`). */ +@Serializable +data class ShareLinksResponse( + val shareLinks: List? = null, + val data: List? = null, +) { + val items: List get() = shareLinks ?: data ?: emptyList() +} + +/** + * Envelope for `POST /api/documents/{id}/share-links`; the created link may be + * wrapped under `shareLink`/`data` or returned bare. + */ +@Serializable +data class ShareLinkEnvelope( + val shareLink: ShareLinkDto? = null, + val data: ShareLinkDto? = null, + val id: String? = null, + val token: String? = null, + val role: String? = null, + val expiresAt: String? = null, + val revokedAt: String? = null, + val createdAt: String? = null, +) { + val linkOrSelf: ShareLinkDto? + get() = shareLink ?: data ?: token?.let { + ShareLinkDto( + id = id.orEmpty(), + token = it, + role = role, + expiresAt = expiresAt, + revokedAt = revokedAt, + createdAt = createdAt, + ) + } +} + +/** + * Body for `POST /api/documents/{id}/share-links`. The spec models only `expiresAt`, + * but the link entity carries a `role`; we send both so the chosen access level is + * honoured. Nulls are dropped by the shared Json. + */ +@Serializable +data class CreateShareLinkRequest( + val role: String? = null, + val expiresAt: String? = null, +) + +/** A nested owner reference on a shared document response. */ +@Serializable +data class ShareUserDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, +) + +/** + * Response for `GET /api/documents/shared/{token}` — resolves a public link to a + * read-only preview. The document may be inlined or wrapped under `document`; the + * `role` is the access the link grants. + */ +@Serializable +data class SharedDocumentResponse( + val document: DocumentDto? = null, + val id: String? = null, + val title: String? = null, + val content: String? = null, + val role: String? = null, + val user: ShareUserDto? = null, +) { + /** The resolved document metadata, whether wrapped under `document` or inlined. */ + val documentOrSelf: DocumentDto? + get() = document ?: id?.let { + DocumentDto(id = it, title = title, content = content) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ShareLink.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ShareLink.kt new file mode 100644 index 0000000..e77448d --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/ShareLink.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * A public share link for a document. [token] is the opaque secret embedded in the + * shareable URL; [role] is the access it grants. A link with a non-null [revokedAt] + * is dead and should not be surfaced as active. + */ +data class ShareLink( + val id: String, + val token: String, + val role: ShareRole, + val expiresAt: String?, + val revokedAt: String?, + val createdAt: String?, +) { + /** True when the link is still usable (not revoked). */ + val isActive: Boolean get() = revokedAt == null + + /** The public URL a user copies/shares to grant access via this link. */ + fun url(baseUrl: String = INTERLINEDLIST_BASE_URL): String = + "${baseUrl.trimEnd('/')}/documents/shared/$token" + + companion object { + const val INTERLINEDLIST_BASE_URL = "https://interlinedlist.com" + } +} + +/** + * Access level a document share link grants. The web app offers view / edit / admin; + * unknown or absent server values map to [VIEW] so a link is never over-privileged. + */ +enum class ShareRole(val apiValue: String, val label: String) { + VIEW("view", "View"), + EDIT("edit", "Edit"), + ADMIN("admin", "Admin"); + + /** True when following this link lets the visitor claim edit/admin access. */ + val grantsClaim: Boolean get() = this != VIEW + + companion object { + /** Maps an API role string (case-insensitive) to a [ShareRole], defaulting to [VIEW]. */ + fun fromApi(raw: String?): ShareRole = when (raw?.trim()?.lowercase()) { + "edit", "editor", "collaborator" -> EDIT + "admin", "owner" -> ADMIN + else -> VIEW + } + } +} + +/** + * The outcome of resolving a `documents/shared/{token}` link: the target document's + * read-only preview plus the access the link grants. When [canClaim] is true the + * visitor can POST to the link to claim edit/admin access under their own account. + */ +data class SharedDocument( + val token: String, + val documentId: String, + val title: String, + val content: String?, + val ownerName: String?, + val role: ShareRole, +) { + /** True when following the link can upgrade the visitor to edit/admin access. */ + val canClaim: Boolean get() = role.grantsClaim +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt index 66ba6e1..a3c8664 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -48,6 +49,7 @@ object DocumentEditorTestTags { const val SAVE = "editorSave" const val DELETE = "editorDelete" const val UPLOAD_IMAGE = "editorUploadImage" + const val SHARE = "editorShare" const val TOGGLE_PREVIEW = "editorTogglePreview" const val PROGRESS = "editorProgress" const val ERROR = "editorError" @@ -63,6 +65,7 @@ fun DocumentEditorRoute( onBack: () -> Unit, onDeleted: () -> Unit, modifier: Modifier = Modifier, + onOpenShare: () -> Unit = {}, viewModel: DocumentEditorViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -98,6 +101,7 @@ fun DocumentEditorRoute( ) }, onBack = onBack, + onOpenShare = onOpenShare, modifier = modifier, ) } @@ -115,6 +119,7 @@ fun DocumentEditorScreen( onBack: () -> Unit, modifier: Modifier = Modifier, onPickImage: () -> Unit = {}, + onOpenShare: () -> Unit = {}, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -144,6 +149,12 @@ fun DocumentEditorScreen( Icon(Icons.Default.Image, contentDescription = "Insert image") } } + IconButton( + onClick = onOpenShare, + modifier = Modifier.testTag(DocumentEditorTestTags.SHARE), + ) { + Icon(Icons.Outlined.Share, contentDescription = "Share") + } IconButton( onClick = onTogglePreview, modifier = Modifier.testTag(DocumentEditorTestTags.TOGGLE_PREVIEW), diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreen.kt new file mode 100644 index 0000000..2ccdcac --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareScreen.kt @@ -0,0 +1,243 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole + +/** Stable test tags for the document share sheet. */ +object DocumentShareTestTags { + const val SHEET = "docShareSheet" + const val LINKS = "docShareLinks" + const val CREATE = "docShareCreate" + const val EMPTY = "docShareEmpty" + const val PROGRESS = "docShareProgress" + const val ERROR = "docShareError" + fun link(token: String) = "docShareLink_$token" + fun copy(token: String) = "docShareCopy_$token" + fun revoke(token: String) = "docShareRevoke_$token" + fun role(role: ShareRole) = "docShareRole_${role.apiValue}" +} + +/** + * Hilt-wired share sheet for a document, shown as a modal bottom sheet over the + * editor; [onDismiss] closes it. Reads its `documentId` from the nav SavedStateHandle + * (see [SHARE_DOCUMENT_ID_ARG]). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentShareRoute( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + viewModel: DocumentShareViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag(DocumentShareTestTags.SHEET), + ) { + DocumentShareSheetContent( + state = state, + onSelectRole = viewModel::selectRole, + onCreate = viewModel::createLink, + onRevoke = viewModel::revokeLink, + ) + } +} + +/** Stateless share sheet body — role picker + create control + existing links. */ +@Composable +fun DocumentShareSheetContent( + state: DocumentShareUiState, + onSelectRole: (ShareRole) -> Unit, + onCreate: () -> Unit, + onRevoke: (ShareLink) -> Unit, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboardManager.current + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Share, contentDescription = null) + Text("Share this document", style = MaterialTheme.typography.titleLarge) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Create a link and choose what people who open it can do.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShareRole.entries.forEach { role -> + FilterChip( + selected = state.selectedRole == role, + onClick = { onSelectRole(role) }, + label = { Text(role.label) }, + modifier = Modifier.testTag(DocumentShareTestTags.role(role)), + ) + } + } + + Spacer(Modifier.height(12.dp)) + Button( + onClick = onCreate, + enabled = !state.isCreating, + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentShareTestTags.CREATE), + ) { + Icon(Icons.Default.Add, contentDescription = null) + Text(" Create ${state.selectedRole.label.lowercase()} link") + } + + if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag(DocumentShareTestTags.ERROR), + ) + } + + Spacer(Modifier.height(16.dp)) + Text("Existing links", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + when { + state.isLoading -> Box( + Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(DocumentShareTestTags.PROGRESS)) } + + state.isEmpty -> Text( + text = "No share links yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(DocumentShareTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .testTag(DocumentShareTestTags.LINKS), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.activeLinks, key = { it.token }) { link -> + DocumentShareLinkRow( + link = link, + onCopy = { clipboard.setText(AnnotatedString(link.url())) }, + onRevoke = { onRevoke(link) }, + ) + } + } + } + } +} + +@Composable +private fun DocumentShareLinkRow( + link: ShareLink, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentShareTestTags.link(link.token)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(link.role.label, style = MaterialTheme.typography.titleSmall) + Text( + text = link.url(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton( + onClick = onCopy, + modifier = Modifier.testTag(DocumentShareTestTags.copy(link.token)), + ) { Icon(Icons.Outlined.Share, contentDescription = "Copy link") } + IconButton( + onClick = onRevoke, + modifier = Modifier.testTag(DocumentShareTestTags.revoke(link.token)), + ) { Icon(Icons.Default.Close, contentDescription = "Revoke link") } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentShareSheetPreview() { + InterlinedListTheme { + DocumentShareSheetContent( + state = DocumentShareUiState( + links = listOf( + ShareLink("1", "abc123", ShareRole.VIEW, null, null, null), + ShareLink("2", "def456", ShareRole.EDIT, null, null, null), + ), + isLoading = false, + ), + onSelectRole = {}, + onCreate = {}, + onRevoke = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModel.kt new file mode 100644 index 0000000..3a95e81 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModel.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the document share route reads its document id from. */ +const val SHARE_DOCUMENT_ID_ARG = "documentId" + +/** UI state for the document share sheet. */ +data class DocumentShareUiState( + val links: List = emptyList(), + val selectedRole: ShareRole = ShareRole.VIEW, + val isLoading: Boolean = true, + val isCreating: Boolean = false, + val errorMessage: String? = null, +) { + val activeLinks: List get() = links.filter { it.isActive } + val isEmpty: Boolean get() = activeLinks.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the document share sheet: load existing links, create a link for a chosen + * role (optimistically appended, rolled back on failure), and revoke a link + * (optimistically removed, rolled back on failure). + */ +@HiltViewModel +class DocumentShareViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val documentId: String = requireNotNull(savedStateHandle[SHARE_DOCUMENT_ID_ARG]) { + "DocumentShareViewModel requires a '$SHARE_DOCUMENT_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(DocumentShareUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getShareLinks(documentId)) { + is ApiResult.Success -> _uiState.update { it.copy(links = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun selectRole(role: ShareRole) = _uiState.update { it.copy(selectedRole = role) } + + fun createLink() { + val role = _uiState.value.selectedRole + _uiState.update { it.copy(isCreating = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.createShareLink(documentId, role)) { + is ApiResult.Success -> _uiState.update { + it.copy(links = it.links + result.data, isCreating = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isCreating = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Optimistically removes the link; restores it (and shows an error) on failure. */ + fun revokeLink(link: ShareLink) { + val previous = _uiState.value.links + _uiState.update { it.copy(links = it.links.filterNot { existing -> existing.token == link.token }) } + viewModelScope.launch { + when (val result = repository.revokeShareLink(documentId, link.token)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> _uiState.update { + it.copy(links = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentScreen.kt new file mode 100644 index 0000000..c8c0d47 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentScreen.kt @@ -0,0 +1,208 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.domain.SharedDocument +import com.interlinedlist.android.feature.documents.ui.common.MarkdownText + +/** Stable test tags for the resolve/claim shared-document screen. */ +object SharedDocumentTestTags { + const val PROGRESS = "sharedDocProgress" + const val ERROR = "sharedDocError" + const val TITLE = "sharedDocTitle" + const val PREVIEW = "sharedDocPreview" + const val CLAIM = "sharedDocClaim" + const val CLAIMED = "sharedDocClaimed" +} + +/** + * Hilt-wired resolve/claim screen for a `documents/shared/{token}` link. Reads the + * token from the nav SavedStateHandle (see [SHARED_DOCUMENT_TOKEN_ARG]); [onBack] + * pops navigation. + */ +@Composable +fun SharedDocumentRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SharedDocumentViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + SharedDocumentScreen( + state = state, + onBack = onBack, + onClaim = viewModel::claim, + modifier = modifier, + ) +} + +/** Stateless read-only preview of a shared document, with an optional "Claim access" action. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharedDocumentScreen( + state: SharedDocumentUiState, + onBack: () -> Unit, + onClaim: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Shared document") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(SharedDocumentTestTags.PROGRESS)) } + + state.document == null -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.errorMessage ?: "This link could not be opened.", + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(24.dp) + .testTag(SharedDocumentTestTags.ERROR), + ) + } + + else -> SharedDocumentBody( + state = state, + document = state.document, + onClaim = onClaim, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } + } +} + +@Composable +private fun SharedDocumentBody( + state: SharedDocumentUiState, + document: SharedDocument, + onClaim: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .padding(16.dp) + .testTag(SharedDocumentTestTags.PREVIEW), + ) { + Text( + text = document.title.ifBlank { "Untitled document" }, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.testTag(SharedDocumentTestTags.TITLE), + ) + if (document.ownerName != null) { + Text( + text = "Shared by ${document.ownerName}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = "Access: ${document.role.label}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + + Spacer(Modifier.height(12.dp)) + when { + state.claimed -> androidx.compose.foundation.layout.Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag(SharedDocumentTestTags.CLAIMED), + ) { + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text("Access claimed. This document is now in your account.") + } + + state.canClaim -> Button( + onClick = onClaim, + enabled = !state.isClaiming, + modifier = Modifier + .fillMaxWidth() + .testTag(SharedDocumentTestTags.CLAIM), + ) { Text("Claim ${document.role.label.lowercase()} access") } + } + if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + Spacer(Modifier.height(16.dp)) + // MarkdownText brings its own vertical scroll, so it owns the remaining space. + MarkdownText( + markdown = document.content.orEmpty().ifBlank { "_This document has no content._" }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun SharedDocumentPreview() { + InterlinedListTheme { + SharedDocumentScreen( + state = SharedDocumentUiState( + document = SharedDocument( + token = "tok", + documentId = "D5", + title = "Public Notes", + content = "# Heading\n\nSome shared **markdown** content.", + ownerName = "Grace H", + role = ShareRole.EDIT, + ), + isLoading = false, + ), + onBack = {}, + onClaim = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModel.kt new file mode 100644 index 0000000..cba386d --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModel.kt @@ -0,0 +1,80 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.SharedDocument +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the resolve route reads its share token from. */ +const val SHARED_DOCUMENT_TOKEN_ARG = "token" + +/** UI state for the resolve/claim screen of a shared document link. */ +data class SharedDocumentUiState( + val document: SharedDocument? = null, + val isLoading: Boolean = true, + val isClaiming: Boolean = false, + val claimed: Boolean = false, + val errorMessage: String? = null, +) { + /** True when the resolved link grants edit/admin and hasn't been claimed yet. */ + val canClaim: Boolean get() = document?.canClaim == true && !claimed +} + +/** + * Resolves a `documents/shared/{token}` link to a read-only preview and, when the + * link grants edit/admin, lets the visitor claim access under their own account. + */ +@HiltViewModel +class SharedDocumentViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val token: String = requireNotNull(savedStateHandle[SHARED_DOCUMENT_TOKEN_ARG]) { + "SharedDocumentViewModel requires a '$SHARED_DOCUMENT_TOKEN_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(SharedDocumentUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + resolve() + } + + fun resolve() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.resolveSharedDocument(token)) { + is ApiResult.Success -> _uiState.update { it.copy(document = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun claim() { + if (_uiState.value.document?.canClaim != true) return + _uiState.update { it.copy(isClaiming = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.claimSharedDocument(token)) { + is ApiResult.Success -> _uiState.update { it.copy(isClaiming = false, claimed = true) } + is ApiResult.Failure -> _uiState.update { + it.copy(isClaiming = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt new file mode 100644 index 0000000..f5b377e --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt @@ -0,0 +1,186 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * MockWebServer coverage for the document sharing endpoints: list/create/revoke a + * share link, resolve a token to a read-only preview, and claim access. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDocumentsRepositoryShareTest { + + private lateinit var server: MockWebServer + private lateinit var api: DocumentsApi + private lateinit var repository: DefaultDocumentsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DocumentsApi::class.java) + repository = DefaultDocumentsRepository(api, FakeDocumentDao(), FakeFolderDao(), json, dispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `getShareLinks parses the shareLinks envelope and maps roles`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "shareLinks": [ + { "id": "s1", "documentId": "D1", "token": "tok-view", "role": "view", "createdAt": "2026-01-01" }, + { "id": "s2", "documentId": "D1", "token": "tok-edit", "role": "edit", "revokedAt": null } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getShareLinks("D1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val links = (result as ApiResult.Success).data + assertThat(links.map { it.token }).containsExactly("tok-view", "tok-edit").inOrder() + assertThat(links[0].role).isEqualTo(ShareRole.VIEW) + assertThat(links[1].role).isEqualTo(ShareRole.EDIT) + assertThat(links[0].url()).isEqualTo("https://interlinedlist.com/documents/shared/tok-view") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/api/documents/D1/share-links") + } + + @Test + fun `createShareLink posts the chosen role and returns the created link`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "shareLink": { "id": "s9", "token": "new-tok", "role": "admin" } }""", + ), + ) + + val result = repository.createShareLink("D1", ShareRole.ADMIN) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val link = (result as ApiResult.Success).data + assertThat(link.token).isEqualTo("new-tok") + assertThat(link.role).isEqualTo(ShareRole.ADMIN) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/documents/D1/share-links") + assertThat(request.body.readUtf8()).contains("\"role\":\"admin\"") + } + + @Test + fun `createShareLink tolerates a bare wrapped link body`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "id": "s7", "token": "bare-tok", "role": "edit" }"""), + ) + + val result = repository.createShareLink("D1", ShareRole.EDIT) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.token).isEqualTo("bare-tok") + } + + @Test + fun `revokeShareLink deletes by token`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.revokeShareLink("D1", "tok-gone") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/documents/D1/share-links/tok-gone") + } + + @Test + fun `resolveSharedDocument maps preview metadata and role`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "id": "D5", "title": "Public Notes", "content": "# Hello", + "role": "edit", + "user": { "id": "u2", "username": "grace", "displayName": "Grace H" } + } + """.trimIndent(), + ), + ) + + val result = repository.resolveSharedDocument("shared-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val res = (result as ApiResult.Success).data + assertThat(res.token).isEqualTo("shared-tok") + assertThat(res.documentId).isEqualTo("D5") + assertThat(res.title).isEqualTo("Public Notes") + assertThat(res.content).isEqualTo("# Hello") + assertThat(res.ownerName).isEqualTo("Grace H") + assertThat(res.role).isEqualTo(ShareRole.EDIT) + assertThat(res.canClaim).isTrue() + + assertThat(server.takeRequest().path).isEqualTo("/api/documents/shared/shared-tok") + } + + @Test + fun `resolveSharedDocument maps a 404 to NotFound`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Share link not found, expired, or revoked", "code": "not_found" }"""), + ) + + val result = repository.resolveSharedDocument("dead-tok") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `claimSharedDocument posts to the shared token`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + + val result = repository.claimSharedDocument("claim-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/documents/shared/claim-tok") + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt index 1910b80..1bae091 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt @@ -10,6 +10,9 @@ import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.domain.FolderTree +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.domain.SharedDocument import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -39,6 +42,13 @@ class FakeDocumentsRepository : DocumentsRepository { var fromTemplateResult: ApiResult? = null var searchResult: ApiResult> = ApiResult.Success(emptyList()) + // Sharing. + var shareLinksResult: ApiResult> = ApiResult.Success(emptyList()) + var createShareLinkResult: ApiResult? = null + var revokeShareLinkResult: ApiResult = ApiResult.Success(Unit) + var resolveSharedResult: ApiResult? = null + var claimSharedResult: ApiResult = ApiResult.Success(Unit) + var refreshTreeCount = 0 var lastCreate: Create? = null var lastUpdate: Update? = null @@ -48,6 +58,13 @@ class FakeDocumentsRepository : DocumentsRepository { var lastFolderRename: FolderRename? = null var lastDeletedFolderId: String? = null var lastSearchQuery: String? = null + var createShareLinkCount = 0 + var revokeShareLinkCount = 0 + var claimSharedCount = 0 + var lastCreatedShareRole: ShareRole? = null + var lastRevokedToken: String? = null + var lastResolvedToken: String? = null + var lastClaimedToken: String? = null data class Create(val title: String, val content: String, val isPublic: Boolean, val folderId: String?) data class Update(val id: String, val title: String, val content: String, val isPublic: Boolean, val folderId: String?) @@ -143,6 +160,35 @@ class FakeDocumentsRepository : DocumentsRepository { return searchResult } + override suspend fun getShareLinks(documentId: String): ApiResult> = shareLinksResult + + override suspend fun createShareLink(documentId: String, role: ShareRole): ApiResult { + createShareLinkCount++ + lastCreatedShareRole = role + return createShareLinkResult ?: ApiResult.Success( + ShareLink("link-new", "token-new", role, null, null, null), + ) + } + + override suspend fun revokeShareLink(documentId: String, token: String): ApiResult { + revokeShareLinkCount++ + lastRevokedToken = token + return revokeShareLinkResult + } + + override suspend fun resolveSharedDocument(token: String): ApiResult { + lastResolvedToken = token + return resolveSharedResult ?: ApiResult.Success( + SharedDocument(token, "D", "Untitled", null, null, ShareRole.VIEW), + ) + } + + override suspend fun claimSharedDocument(token: String): ApiResult { + claimSharedCount++ + lastClaimedToken = token + return claimSharedResult + } + private fun flatten(node: FolderNode): List = buildList { node.children.forEach { child -> add(FolderSummary(child.id, child.name, child.documents.size, child.children.size)) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModelTest.kt new file mode 100644 index 0000000..206fe23 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/DocumentShareViewModelTest.kt @@ -0,0 +1,115 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.ShareLink +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentShareViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun link(token: String, role: ShareRole = ShareRole.VIEW) = + ShareLink("id-$token", token, role, null, null, null) + + private fun viewModel(repo: FakeDocumentsRepository) = + DocumentShareViewModel(repo, SavedStateHandle(mapOf(SHARE_DOCUMENT_ID_ARG to "D1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads existing share links on init`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + shareLinksResult = ApiResult.Success(listOf(link("a"), link("b", ShareRole.EDIT))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("a", "b").inOrder() + } + + @Test + fun `createLink optimistically appends the created link with the chosen role`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + shareLinksResult = ApiResult.Success(emptyList()) + createShareLinkResult = ApiResult.Success(link("fresh", ShareRole.ADMIN)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.selectRole(ShareRole.ADMIN) + vm.createLink() + advanceUntilIdle() + + assertThat(repo.createShareLinkCount).isEqualTo(1) + assertThat(repo.lastCreatedShareRole).isEqualTo(ShareRole.ADMIN) + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("fresh") + } + + @Test + fun `createLink failure surfaces an error and adds nothing`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + shareLinksResult = ApiResult.Success(emptyList()) + createShareLinkResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.createLink() + advanceUntilIdle() + + assertThat(vm.uiState.value.activeLinks).isEmpty() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `revokeLink optimistically removes the link on success`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + shareLinksResult = ApiResult.Success(listOf(link("keep"), link("drop"))) + revokeShareLinkResult = ApiResult.Success(Unit) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revokeLink(link("drop")) + // Removed immediately, before the network resolves. + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("keep") + + advanceUntilIdle() + assertThat(repo.lastRevokedToken).isEqualTo("drop") + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("keep") + } + + @Test + fun `revokeLink rolls back and shows an error on failure`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + shareLinksResult = ApiResult.Success(listOf(link("keep"), link("drop"))) + revokeShareLinkResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revokeLink(link("drop")) + advanceUntilIdle() + + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("keep", "drop").inOrder() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModelTest.kt new file mode 100644 index 0000000..83b35aa --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/share/SharedDocumentViewModelTest.kt @@ -0,0 +1,108 @@ +package com.interlinedlist.android.feature.documents.ui.share + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.ShareRole +import com.interlinedlist.android.feature.documents.domain.SharedDocument +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SharedDocumentViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun shared(role: ShareRole) = + SharedDocument("tok", "D5", "Notes", "# Body", "Grace", role) + + private fun viewModel(repo: FakeDocumentsRepository) = + SharedDocumentViewModel(repo, SavedStateHandle(mapOf(SHARED_DOCUMENT_TOKEN_ARG to "tok"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `resolves the token into a preview on init`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + resolveSharedResult = ApiResult.Success(shared(ShareRole.EDIT)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(repo.lastResolvedToken).isEqualTo("tok") + assertThat(vm.uiState.value.document?.documentId).isEqualTo("D5") + assertThat(vm.uiState.value.canClaim).isTrue() + } + + @Test + fun `a view-only link cannot be claimed`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + resolveSharedResult = ApiResult.Success(shared(ShareRole.VIEW)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.canClaim).isFalse() + vm.claim() + advanceUntilIdle() + assertThat(repo.claimSharedCount).isEqualTo(0) + } + + @Test + fun `claim success flips claimed`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + resolveSharedResult = ApiResult.Success(shared(ShareRole.ADMIN)) + claimSharedResult = ApiResult.Success(Unit) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.claim() + advanceUntilIdle() + + assertThat(repo.claimSharedCount).isEqualTo(1) + assertThat(repo.lastClaimedToken).isEqualTo("tok") + assertThat(vm.uiState.value.claimed).isTrue() + assertThat(vm.uiState.value.canClaim).isFalse() + } + + @Test + fun `claim failure surfaces an error and stays unclaimed`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + resolveSharedResult = ApiResult.Success(shared(ShareRole.EDIT)) + claimSharedResult = ApiResult.Failure(AppError.Forbidden("nope")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.claim() + advanceUntilIdle() + + assertThat(vm.uiState.value.claimed).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `resolve failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + resolveSharedResult = ApiResult.Failure(AppError.NotFound("gone")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.document).isNull() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt index 2abcb4f..922ebe9 100644 --- a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreenTest.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.lists.domain.FieldType @@ -33,7 +34,7 @@ class ListDetailScreenTest { ), ) - private fun setScreen(state: ListDetailUiState) { + private fun setScreen(state: ListDetailUiState, onOpenShare: () -> Unit = {}) { composeRule.setContent { InterlinedListTheme { ListDetailScreen( @@ -43,6 +44,7 @@ class ListDetailScreenTest { onEditRow = {}, onDeleteRow = {}, onDeleteList = {}, + onOpenShare = onOpenShare, ) } } @@ -81,4 +83,22 @@ class ListDetailScreenTest { composeRule.onNodeWithTag(ListDetailTestTags.EMPTY).assertIsDisplayed() } + + @Test + fun overflowMenu_exposesShare_andInvokesCallback() { + var shared = false + setScreen( + ListDetailUiState( + summary = ListSummary("L1", "Reading", null, 1, null, false, null), + schema = schema, + rows = listOf(ListRow("r1", mapOf("title" to "Dune"))), + isLoading = false, + ), + onOpenShare = { shared = true }, + ) + + composeRule.onNodeWithTag(ListDetailTestTags.OVERFLOW).performClick() + composeRule.onNodeWithTag(ListDetailTestTags.SHARE).assertIsDisplayed().performClick() + assert(shared) + } } diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreenTest.kt new file mode 100644 index 0000000..0c010cb --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreenTest.kt @@ -0,0 +1,88 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Verifies the share sheet renders existing links, a create control, and role chips. */ +@RunWith(AndroidJUnit4::class) +class ShareScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent( + state: ShareUiState, + onSelectRole: (ShareRole) -> Unit = {}, + onCreate: () -> Unit = {}, + onRevoke: (ShareLink) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + ShareSheetContent( + state = state, + onSelectRole = onSelectRole, + onCreate = onCreate, + onRevoke = onRevoke, + ) + } + } + } + + @Test + fun rendersExistingLinks_andCreateControl() { + setContent( + ShareUiState( + links = listOf( + ShareLink("1", "abc123", ShareRole.VIEW, null, null, null), + ShareLink("2", "def456", ShareRole.EDIT, null, null, null), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(ShareTestTags.CREATE).assertIsDisplayed() + composeRule.onNodeWithTag(ShareTestTags.link("abc123")).assertIsDisplayed() + composeRule.onNodeWithTag(ShareTestTags.link("def456")).assertIsDisplayed() + composeRule.onNodeWithTag(ShareTestTags.copy("abc123")).assertIsDisplayed() + composeRule.onNodeWithTag(ShareTestTags.revoke("abc123")).assertIsDisplayed() + } + + @Test + fun createButton_invokesCallback() { + var created = false + setContent( + ShareUiState(isLoading = false), + onCreate = { created = true }, + ) + + composeRule.onNodeWithTag(ShareTestTags.CREATE).performClick() + assert(created) + } + + @Test + fun roleChip_selectsRole() { + var selected: ShareRole? = null + setContent( + ShareUiState(isLoading = false), + onSelectRole = { selected = it }, + ) + + composeRule.onNodeWithTag(ShareTestTags.role(ShareRole.ADMIN)).performClick() + assert(selected == ShareRole.ADMIN) + } + + @Test + fun emptyState_isShown_whenNoLinks() { + setContent(ShareUiState(links = emptyList(), isLoading = false)) + composeRule.onNodeWithTag(ShareTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreenTest.kt new file mode 100644 index 0000000..d704153 --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreenTest.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Verifies the "Shared with me" screen renders each list with its owner and role. */ +@RunWith(AndroidJUnit4::class) +class SharedWithMeScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent(state: SharedWithMeUiState, onOpenList: (String) -> Unit = {}) { + composeRule.setContent { + InterlinedListTheme { + SharedWithMeScreen(state = state, onBack = {}, onOpenList = onOpenList) + } + } + } + + @Test + fun rendersSharedLists_withOwnerAndRole() { + setContent( + SharedWithMeUiState( + lists = listOf( + SharedList("w1", "Shows Upcoming", null, "Adron Hall", ShareRole.EDIT, true), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(SharedWithMeTestTags.row("w1")).assertIsDisplayed() + composeRule.onNodeWithText("Shows Upcoming").assertIsDisplayed() + composeRule.onNodeWithText("Shared by Adron Hall").assertIsDisplayed() + // The role label is rendered as a chip. + composeRule.onNodeWithText(ShareRole.EDIT.label).assertIsDisplayed() + } + + @Test + fun opensList_whenRowTapped() { + var opened: String? = null + setContent( + SharedWithMeUiState( + lists = listOf(SharedList("w9", "Videos", null, "Adron", ShareRole.VIEW, true)), + isLoading = false, + ), + onOpenList = { opened = it }, + ) + + composeRule.onNodeWithTag(SharedWithMeTestTags.row("w9")).performClick() + assert(opened == "w9") + } + + @Test + fun emptyState_isShown_whenNothingShared() { + setContent(SharedWithMeUiState(lists = emptyList(), isLoading = false)) + composeRule.onNodeWithTag(SharedWithMeTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index 1791917..4945300 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -10,6 +10,7 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.AddWatcherReques import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnectionRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest @@ -23,6 +24,10 @@ import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList +import com.interlinedlist.android.feature.lists.domain.SharedListResolution import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole @@ -289,6 +294,52 @@ class DefaultListsRepository @Inject constructor( safeApiCall(json) { api.deleteConnection(id) }.map { } } + override suspend fun getShareLinks(listId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getShareLinks(listId) } + .map { response -> response.items.map(ShareMapper::linkFromDto) } + } + + override suspend fun createShareLink(listId: String, role: ShareRole): ApiResult = + withContext(dispatchers.io) { + when (val result = safeApiCall(json) { + api.createShareLink(listId, CreateShareLinkRequest(role = role.apiValue)) + }) { + is ApiResult.Success -> { + val dto = result.data.linkOrSelf + ?: return@withContext ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.Unknown( + "Share link create returned no token", + ), + ) + ApiResult.Success(ShareMapper.linkFromDto(dto)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun revokeShareLink(listId: String, token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.revokeShareLink(listId, token) }.map { } + } + + override suspend fun getSharedWithMe(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getWatchingLists() } + .map { response -> response.items.map(ShareMapper::sharedFromDto) } + } + + override suspend fun resolveSharedList(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.resolveSharedList(token) } + .map { response -> ShareMapper.resolutionFromResponse(token, response) } + } + + override suspend fun claimSharedList(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.claimSharedList(token) }.map { } + } + /** Blank form fields are dropped so we don't overwrite server values with empty strings. */ private fun Map.toJsonData(): Map = filterValues { it.isNotBlank() } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index 06e3272..29ec578 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -9,6 +9,10 @@ import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList +import com.interlinedlist.android.feature.lists.domain.SharedListResolution import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole @@ -93,6 +97,26 @@ interface ListsRepository { /** Removes a connection between lists. */ suspend fun deleteConnection(id: String): ApiResult + // --- Sharing ----------------------------------------------------------- + + /** Existing public share links for a list. */ + suspend fun getShareLinks(listId: String): ApiResult> + + /** Creates a share link granting [role]; returns the created link. */ + suspend fun createShareLink(listId: String, role: ShareRole): ApiResult + + /** Revokes a share link by its token. */ + suspend fun revokeShareLink(listId: String, token: String): ApiResult + + /** Lists shared with the current user by other owners, with the granted role. */ + suspend fun getSharedWithMe(): ApiResult> + + /** Resolves a public `…/shared/{token}` link to a read-only preview. */ + suspend fun resolveSharedList(token: String): ApiResult + + /** Claims edit/admin access to a shared list via its token. */ + suspend fun claimSharedList(token: String): ApiResult + companion object { const val DEFAULT_PAGE_SIZE = 20 } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ShareMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ShareMapper.kt new file mode 100644 index 0000000..d825db6 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ShareMapper.kt @@ -0,0 +1,48 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.ShareLinkDto +import com.interlinedlist.android.feature.lists.data.remote.dto.SharedListResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.WatchingListDto +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList +import com.interlinedlist.android.feature.lists.domain.SharedListResolution + +/** DTO ↔ domain mapping for the list sharing surface. */ +object ShareMapper { + + fun linkFromDto(dto: ShareLinkDto): ShareLink = ShareLink( + id = dto.id, + token = dto.token, + role = ShareRole.fromApi(dto.role), + expiresAt = dto.expiresAt, + revokedAt = dto.revokedAt, + createdAt = dto.createdAt, + ) + + fun sharedFromDto(dto: WatchingListDto): SharedList = SharedList( + id = dto.id, + title = dto.title, + description = dto.description, + ownerName = dto.user?.let { it.displayName?.takeIf(String::isNotBlank) ?: it.username } + ?.takeIf(String::isNotBlank) ?: "Unknown owner", + role = ShareRole.fromApi(dto.role), + isPublic = dto.isPublic, + ) + + fun resolutionFromResponse(token: String, response: SharedListResponse): SharedListResolution { + val list = response.listOrSelf + val owner = (response.user ?: list?.user)?.let { + it.displayName?.takeIf(String::isNotBlank) ?: it.username.takeIf(String::isNotBlank) + } + return SharedListResolution( + token = token, + listId = list?.id.orEmpty(), + title = list?.title.orEmpty(), + description = list?.description, + ownerName = owner, + role = ShareRole.fromApi(response.role ?: list?.role), + rows = response.rowsOrEmpty.map(RowMapper::fromDto), + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt index 498e407..9656a6a 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt @@ -13,10 +13,15 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.RefreshResultDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.lists.data.remote.dto.RowsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.SchemaEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.ShareLinkEnvelope +import com.interlinedlist.android.feature.lists.data.remote.dto.ShareLinksResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.SharedListResponse import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.WatchingResponse import com.interlinedlist.android.feature.lists.data.remote.dto.WatcherUsersResponse import com.interlinedlist.android.feature.lists.data.remote.dto.WatchersResponse import com.interlinedlist.android.feature.lists.data.remote.dto.WatchingStatusDto @@ -151,4 +156,36 @@ interface ListsApi { @POST("api/folders") suspend fun createFolder(@Body body: CreateFolderRequest): FolderDto + + // --- Sharing ----------------------------------------------------------- + + /** Existing public share links for a list. */ + @GET("api/lists/{id}/share-links") + suspend fun getShareLinks(@Path("id") id: String): ShareLinksResponse + + /** Creates a share link granting the requested role (optionally expiring). */ + @POST("api/lists/{id}/share-links") + suspend fun createShareLink( + @Path("id") id: String, + @Body body: CreateShareLinkRequest, + ): ShareLinkEnvelope + + /** Revokes (deletes) a share link by its token. */ + @DELETE("api/lists/{id}/share-links/{token}") + suspend fun revokeShareLink( + @Path("id") id: String, + @Path("token") token: String, + ) + + /** Lists owned by other users that the current user has access to ("Shared with me"). */ + @GET("api/lists/watching") + suspend fun getWatchingLists(): WatchingResponse + + /** Resolves a public share link to a read-only preview of the shared list. */ + @GET("api/lists/shared/{token}") + suspend fun resolveSharedList(@Path("token") token: String): SharedListResponse + + /** Claims edit/admin access to a shared list as the logged-in user. */ + @POST("api/lists/shared/{token}") + suspend fun claimSharedList(@Path("token") token: String) } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ShareDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ShareDtos.kt new file mode 100644 index 0000000..3d622ca --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ShareDtos.kt @@ -0,0 +1,137 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for the list sharing endpoints. Field names follow the InterlinedList + * REST contract (see the `ListShareLink` schema); the shared Json ignores unknown + * keys, so only the fields the UI renders are declared. All optionals are defaulted + * so the shared `coerceInputValues` Json never fails on explicit nulls. + */ +@Serializable +data class ShareLinkDto( + val id: String = "", + val listId: String? = null, + val token: String = "", + val role: String? = null, + val expiresAt: String? = null, + val revokedAt: String? = null, + val createdAt: String? = null, +) + +/** + * Envelope for `GET /api/lists/{id}/share-links`. Links arrive under `shareLinks` + * (verified live) but `data` is tolerated for forward-compatibility. + */ +@Serializable +data class ShareLinksResponse( + val shareLinks: List? = null, + val data: List? = null, +) { + val items: List get() = shareLinks ?: data ?: emptyList() +} + +/** + * Envelope for `POST /api/lists/{id}/share-links`. The created link may be returned + * bare or wrapped under `shareLink`/`data`; [linkOrSelf] resolves whichever form. + */ +@Serializable +data class ShareLinkEnvelope( + val shareLink: ShareLinkDto? = null, + val data: ShareLinkDto? = null, + val id: String? = null, + val token: String? = null, + val role: String? = null, + val expiresAt: String? = null, + val revokedAt: String? = null, + val createdAt: String? = null, +) { + val linkOrSelf: ShareLinkDto? + get() = shareLink ?: data ?: token?.let { + ShareLinkDto( + id = id.orEmpty(), + token = it, + role = role, + expiresAt = expiresAt, + revokedAt = revokedAt, + createdAt = createdAt, + ) + } +} + +/** + * Body for `POST /api/lists/{id}/share-links`. The spec models only `expiresAt`, + * but the link entity carries a `role`; we send both so the chosen access level is + * honoured (server defaults it when omitted). Nulls are dropped by the shared Json. + */ +@Serializable +data class CreateShareLinkRequest( + val role: String? = null, + val expiresAt: String? = null, +) + +/** A nested owner reference on a watched list row. */ +@Serializable +data class ShareUserDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, +) + +/** + * One row of `GET /api/lists/watching` — a full list plus the owning `user` and the + * `role` the current user holds. Only the fields the "Shared with me" list renders + * are declared; the rest are ignored. + */ +@Serializable +data class WatchingListDto( + val id: String, + val title: String = "", + val description: String? = null, + val isPublic: Boolean = false, + val role: String? = null, + val user: ShareUserDto? = null, +) + +/** Envelope for `GET /api/lists/watching` — verified live shape uses `lists`. */ +@Serializable +data class WatchingResponse( + val lists: List? = null, + val data: List? = null, +) { + val items: List get() = lists ?: data ?: emptyList() +} + +/** + * Response for `GET /api/lists/shared/{token}` — resolves a public link to a + * read-only preview. The payload may inline the list fields or wrap them under + * `list`; the `role` is the access the link grants. Rows may accompany the preview. + */ +@Serializable +data class SharedListResponse( + val list: WatchingListDto? = null, + val id: String? = null, + val title: String? = null, + val description: String? = null, + val isPublic: Boolean = false, + val role: String? = null, + val user: ShareUserDto? = null, + val rows: List? = null, + val data: List? = null, +) { + /** The resolved list metadata, whether wrapped under `list` or inlined. */ + val listOrSelf: WatchingListDto? + get() = list ?: id?.let { + WatchingListDto( + id = it, + title = title.orEmpty(), + description = description, + isPublic = isPublic, + role = role, + user = user, + ) + } + + /** Preview rows, tolerating either `rows` or `data`. */ + val rowsOrEmpty: List get() = rows ?: data ?: emptyList() +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ShareLink.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ShareLink.kt new file mode 100644 index 0000000..924f09e --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/ShareLink.kt @@ -0,0 +1,48 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A public share link for a list. The [token] is the opaque secret embedded in the + * shareable URL; [role] is the access it grants to whoever follows it. A link with + * a non-null [revokedAt] is dead and should not be surfaced as active. + */ +data class ShareLink( + val id: String, + val token: String, + val role: ShareRole, + val expiresAt: String?, + val revokedAt: String?, + val createdAt: String?, +) { + /** True when the link is still usable (not revoked). */ + val isActive: Boolean get() = revokedAt == null + + /** The public URL a user copies/shares to grant access via this link. */ + fun url(baseUrl: String = INTERLINEDLIST_BASE_URL): String = + "${baseUrl.trimEnd('/')}/lists/shared/$token" + + companion object { + const val INTERLINEDLIST_BASE_URL = "https://interlinedlist.com" + } +} + +/** + * Access level a share link grants. The web app offers view / edit / admin; unknown + * or absent server values map to [VIEW] so a link is never over-privileged by accident. + */ +enum class ShareRole(val apiValue: String, val label: String) { + VIEW("view", "View"), + EDIT("edit", "Edit"), + ADMIN("admin", "Admin"); + + /** True when following this link lets the visitor claim edit/admin access. */ + val grantsClaim: Boolean get() = this != VIEW + + companion object { + /** Maps an API role string (case-insensitive) to a [ShareRole], defaulting to [VIEW]. */ + fun fromApi(raw: String?): ShareRole = when (raw?.trim()?.lowercase()) { + "edit", "editor", "collaborator" -> EDIT + "admin", "owner" -> ADMIN + else -> VIEW + } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/SharedList.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/SharedList.kt new file mode 100644 index 0000000..8ee7c2a --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/SharedList.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A list owned by another user that the current user has been granted access to, + * as returned by `GET /api/lists/watching`. Carries the owner and the [role] the + * current user holds on it, so the "Shared with me" section can label each entry. + */ +data class SharedList( + val id: String, + val title: String, + val description: String?, + val ownerName: String, + val role: ShareRole, + val isPublic: Boolean, +) { + /** Best label for the owner line: display name is already resolved by the mapper. */ + val ownerLabel: String get() = ownerName +} + +/** + * The outcome of resolving a `…/shared/{token}` link: the target list's preview + * data plus the access the link grants. When [canClaim] is true the visitor can + * POST to the link to claim edit/admin access under their own account. + */ +data class SharedListResolution( + val token: String, + val listId: String, + val title: String, + val description: String?, + val ownerName: String?, + val role: ShareRole, + val rows: List, +) { + /** True when following the link can upgrade the visitor to edit/admin access. */ + val canClaim: Boolean get() = role.grantsClaim +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt index d2a3951..e3820a9 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -71,6 +71,7 @@ object ListDetailTestTags { const val OVERFLOW = "listDetailOverflow" const val EDIT_SCHEMA = "listDetailEditSchema" const val WATCHERS = "listDetailWatchers" + const val SHARE = "listDetailShare" fun row(id: String) = "listDetailRow_$id" } @@ -88,6 +89,7 @@ fun ListDetailRoute( onEditSchema: () -> Unit, onOpenWatchers: () -> Unit, modifier: Modifier = Modifier, + onOpenShare: () -> Unit = {}, viewModel: ListDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -114,6 +116,7 @@ fun ListDetailRoute( onRefresh = viewModel::refreshFromGithub, onEditSchema = onEditSchema, onOpenWatchers = onOpenWatchers, + onOpenShare = onOpenShare, snackbarHostState = snackbarHostState, modifier = modifier, ) @@ -156,6 +159,7 @@ fun ListDetailScreen( onRefresh: () -> Unit = {}, onEditSchema: () -> Unit = {}, onOpenWatchers: () -> Unit = {}, + onOpenShare: () -> Unit = {}, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { var menuOpen by remember { mutableStateOf(false) } @@ -198,6 +202,11 @@ fun ListDetailScreen( onClick = { menuOpen = false; onOpenWatchers() }, modifier = Modifier.testTag(ListDetailTestTags.WATCHERS), ) + DropdownMenuItem( + text = { Text("Share") }, + onClick = { menuOpen = false; onOpenShare() }, + modifier = Modifier.testTag(ListDetailTestTags.SHARE), + ) DropdownMenuItem( text = { Text("Delete list") }, onClick = { menuOpen = false; onDeleteList() }, diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt index b5923ee..00d6992 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Search import androidx.compose.material3.IconButton import androidx.compose.material3.Card @@ -49,6 +50,7 @@ object ListsTestTags { const val EMPTY = "listsEmpty" const val PROGRESS = "listsProgress" const val SUBSCRIPTION = "listsSubscription" + const val SHARED_WITH_ME = "listsSharedWithMe" fun row(id: String) = "listRow_$id" } @@ -61,6 +63,7 @@ fun ListsRoute( onOpenList: (String) -> Unit, onOpenConnections: () -> Unit, modifier: Modifier = Modifier, + onOpenSharedWithMe: () -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -68,6 +71,7 @@ fun ListsRoute( state = state, onOpenList = onOpenList, onOpenConnections = onOpenConnections, + onOpenSharedWithMe = onOpenSharedWithMe, onSearchQueryChange = viewModel::onSearchQueryChange, onLoadMore = viewModel::loadMore, onCreateList = { title -> viewModel.createList(title, description = null, onCreated = { onOpenList(it.id) }) }, @@ -86,6 +90,7 @@ fun ListsScreen( onLoadMore: () -> Unit, onCreateList: (String) -> Unit, modifier: Modifier = Modifier, + onOpenSharedWithMe: () -> Unit = {}, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -93,6 +98,12 @@ fun ListsScreen( TopAppBar( title = { Text("Lists") }, actions = { + IconButton( + onClick = onOpenSharedWithMe, + modifier = Modifier.testTag(ListsTestTags.SHARED_WITH_ME), + ) { + Icon(Icons.Default.People, contentDescription = "Shared with me") + } IconButton(onClick = onOpenConnections) { Icon(Icons.Default.Hub, contentDescription = "List connections") } diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreen.kt new file mode 100644 index 0000000..880c5fa --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareScreen.kt @@ -0,0 +1,250 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole + +/** Stable test tags for the list share sheet. */ +object ShareTestTags { + const val SHEET = "shareSheet" + const val LINKS = "shareLinks" + const val CREATE = "shareCreate" + const val EMPTY = "shareEmpty" + const val PROGRESS = "shareProgress" + const val ERROR = "shareError" + fun link(token: String) = "shareLink_$token" + fun copy(token: String) = "shareCopy_$token" + fun revoke(token: String) = "shareRevoke_$token" + fun role(role: ShareRole) = "shareRole_${role.apiValue}" +} + +/** + * Hilt-wired share sheet for a list. Shown as a modal bottom sheet over the list + * detail; [onDismiss] closes it. Reads its `listId` from the nav SavedStateHandle + * (see [SHARE_LIST_ID_ARG]). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareRoute( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ShareViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag(ShareTestTags.SHEET), + ) { + ShareSheetContent( + state = state, + onSelectRole = viewModel::selectRole, + onCreate = viewModel::createLink, + onRevoke = viewModel::revokeLink, + ) + } +} + +/** Stateless share sheet body — role picker + create control + existing links. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ShareSheetContent( + state: ShareUiState, + onSelectRole: (ShareRole) -> Unit, + onCreate: () -> Unit, + onRevoke: (ShareLink) -> Unit, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboardManager.current + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Share, contentDescription = null) + Text( + text = "Share this list", + style = MaterialTheme.typography.titleLarge, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Create a link and choose what people who open it can do.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ShareRole.entries.forEach { role -> + FilterChip( + selected = state.selectedRole == role, + onClick = { onSelectRole(role) }, + label = { Text(role.label) }, + modifier = Modifier.testTag(ShareTestTags.role(role)), + ) + } + } + + Spacer(Modifier.height(12.dp)) + Button( + onClick = onCreate, + enabled = !state.isCreating, + modifier = Modifier + .fillMaxWidth() + .testTag(ShareTestTags.CREATE), + ) { + Icon(Icons.Default.Add, contentDescription = null) + Text(" Create ${state.selectedRole.label.lowercase()} link") + } + + if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag(ShareTestTags.ERROR), + ) + } + + Spacer(Modifier.height(16.dp)) + Text("Existing links", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + when { + state.isLoading -> Box( + Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(ShareTestTags.PROGRESS)) } + + state.isEmpty -> Text( + text = "No share links yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(ShareTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .testTag(ShareTestTags.LINKS), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.activeLinks, key = { it.token }) { link -> + ShareLinkRow( + link = link, + onCopy = { clipboard.setText(AnnotatedString(link.url())) }, + onRevoke = { onRevoke(link) }, + ) + } + } + } + } +} + +@Composable +private fun ShareLinkRow( + link: ShareLink, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(ShareTestTags.link(link.token)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = link.role.label, + style = MaterialTheme.typography.titleSmall, + ) + Text( + text = link.url(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton( + onClick = onCopy, + modifier = Modifier.testTag(ShareTestTags.copy(link.token)), + ) { Icon(Icons.Outlined.Share, contentDescription = "Copy link") } + IconButton( + onClick = onRevoke, + modifier = Modifier.testTag(ShareTestTags.revoke(link.token)), + ) { Icon(Icons.Default.Close, contentDescription = "Revoke link") } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ShareSheetPreview() { + InterlinedListTheme { + ShareSheetContent( + state = ShareUiState( + links = listOf( + ShareLink("1", "abc123", ShareRole.VIEW, null, null, null), + ShareLink("2", "def456", ShareRole.EDIT, null, null, null), + ), + isLoading = false, + ), + onSelectRole = {}, + onCreate = {}, + onRevoke = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModel.kt new file mode 100644 index 0000000..dbaaab3 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModel.kt @@ -0,0 +1,101 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the share route reads its list id from. */ +const val SHARE_LIST_ID_ARG = "listId" + +/** UI state for the list share sheet. */ +data class ShareUiState( + val links: List = emptyList(), + val selectedRole: ShareRole = ShareRole.VIEW, + val isLoading: Boolean = true, + val isCreating: Boolean = false, + val errorMessage: String? = null, +) { + /** Only active (non-revoked) links are shown as usable. */ + val activeLinks: List get() = links.filter { it.isActive } + val isEmpty: Boolean get() = activeLinks.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the list share sheet: load existing links, create a link for a chosen role + * (optimistically appended, rolled back on failure), and revoke a link (optimistically + * removed, rolled back on failure). Reads its `listId` from the nav SavedStateHandle. + */ +@HiltViewModel +class ShareViewModel @Inject constructor( + private val repository: ListsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val listId: String = requireNotNull(savedStateHandle[SHARE_LIST_ID_ARG]) { + "ShareViewModel requires a '$SHARE_LIST_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(ShareUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getShareLinks(listId)) { + is ApiResult.Success -> _uiState.update { it.copy(links = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun selectRole(role: ShareRole) = _uiState.update { it.copy(selectedRole = role) } + + fun createLink() { + val role = _uiState.value.selectedRole + _uiState.update { it.copy(isCreating = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.createShareLink(listId, role)) { + is ApiResult.Success -> _uiState.update { + it.copy(links = it.links + result.data, isCreating = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isCreating = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Optimistically removes the link; restores it (and shows an error) on failure. */ + fun revokeLink(link: ShareLink) { + val previous = _uiState.value.links + _uiState.update { it.copy(links = it.links.filterNot { existing -> existing.token == link.token }) } + viewModelScope.launch { + when (val result = repository.revokeShareLink(listId, link.token)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> _uiState.update { + it.copy(links = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListScreen.kt new file mode 100644 index 0000000..5a87da5 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListScreen.kt @@ -0,0 +1,237 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListRow +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedListResolution + +/** Stable test tags for the resolve/claim shared-list screen. */ +object SharedListTestTags { + const val PROGRESS = "sharedListProgress" + const val ERROR = "sharedListError" + const val TITLE = "sharedListTitle" + const val PREVIEW = "sharedListPreview" + const val CLAIM = "sharedListClaim" + const val CLAIMED = "sharedListClaimed" +} + +/** + * Hilt-wired resolve/claim screen for a `…/shared/{token}` link. Reads the token + * from the nav SavedStateHandle (see [SHARED_TOKEN_ARG]); [onBack] pops navigation. + */ +@Composable +fun SharedListRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SharedListViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + SharedListScreen( + state = state, + onBack = onBack, + onClaim = viewModel::claim, + modifier = modifier, + ) +} + +/** Stateless read-only preview of a shared list, with an optional "Claim access" action. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharedListScreen( + state: SharedListUiState, + onBack: () -> Unit, + onClaim: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Shared list") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(SharedListTestTags.PROGRESS)) } + + state.resolution == null -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.errorMessage ?: "This link could not be opened.", + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(24.dp) + .testTag(SharedListTestTags.ERROR), + ) + } + + else -> SharedListBody( + state = state, + resolution = state.resolution, + onClaim = onClaim, + contentPadding = padding, + ) + } + } +} + +@Composable +private fun SharedListBody( + state: SharedListUiState, + resolution: SharedListResolution, + onClaim: () -> Unit, + contentPadding: PaddingValues, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .testTag(SharedListTestTags.PREVIEW), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Column { + Text( + text = resolution.title.ifBlank { "Untitled list" }, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.testTag(SharedListTestTags.TITLE), + ) + if (resolution.ownerName != null) { + Text( + text = "Shared by ${resolution.ownerName}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (!resolution.description.isNullOrBlank()) { + Spacer(Modifier.height(4.dp)) + Text(resolution.description, style = MaterialTheme.typography.bodyMedium) + } + Spacer(Modifier.height(8.dp)) + Text( + text = "Access: ${resolution.role.label}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + item { + when { + state.claimed -> Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.testTag(SharedListTestTags.CLAIMED), + ) { + Icon(Icons.Default.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text("Access claimed. This list is now in your account.") + } + + state.canClaim -> Button( + onClick = onClaim, + enabled = !state.isClaiming, + modifier = Modifier + .fillMaxWidth() + .testTag(SharedListTestTags.CLAIM), + ) { Text("Claim ${resolution.role.label.lowercase()} access") } + } + if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) + } + } + + if (resolution.rows.isNotEmpty()) { + item { Text("Preview", style = MaterialTheme.typography.titleMedium) } + items(resolution.rows, key = { it.id }) { row -> + PreviewRow(row) + } + } + } +} + +@Composable +private fun PreviewRow(row: ListRow) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp)) { + row.values.entries.take(4).forEach { (key, value) -> + Text( + text = "$key: $value", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun SharedListPreview() { + InterlinedListTheme { + SharedListScreen( + state = SharedListUiState( + resolution = SharedListResolution( + token = "tok", + listId = "L5", + title = "Public Reading", + description = "A shared reading list", + ownerName = "Grace H", + role = ShareRole.EDIT, + rows = listOf(ListRow("r1", mapOf("title" to "Dune", "pages" to "412"))), + ), + isLoading = false, + ), + onBack = {}, + onClaim = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModel.kt new file mode 100644 index 0000000..345559c --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModel.kt @@ -0,0 +1,81 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.SharedListResolution +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The nav argument key the resolve route reads its share token from. */ +const val SHARED_TOKEN_ARG = "token" + +/** UI state for the resolve/claim screen of a shared list link. */ +data class SharedListUiState( + val resolution: SharedListResolution? = null, + val isLoading: Boolean = true, + val isClaiming: Boolean = false, + val claimed: Boolean = false, + val errorMessage: String? = null, +) { + /** True when the resolved link grants edit/admin and hasn't been claimed yet. */ + val canClaim: Boolean get() = resolution?.canClaim == true && !claimed +} + +/** + * Resolves a `…/shared/{token}` link to a read-only preview and, when the link + * grants edit/admin, lets the visitor claim access under their own account. Reads + * the token from the nav SavedStateHandle. + */ +@HiltViewModel +class SharedListViewModel @Inject constructor( + private val repository: ListsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val token: String = requireNotNull(savedStateHandle[SHARED_TOKEN_ARG]) { + "SharedListViewModel requires a '$SHARED_TOKEN_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(SharedListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + resolve() + } + + fun resolve() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.resolveSharedList(token)) { + is ApiResult.Success -> _uiState.update { it.copy(resolution = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun claim() { + if (_uiState.value.resolution?.canClaim != true) return + _uiState.update { it.copy(isClaiming = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.claimSharedList(token)) { + is ApiResult.Success -> _uiState.update { it.copy(isClaiming = false, claimed = true) } + is ApiResult.Failure -> _uiState.update { + it.copy(isClaiming = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreen.kt new file mode 100644 index 0000000..0292aa7 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeScreen.kt @@ -0,0 +1,194 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList + +/** Stable test tags for the "Shared with me" screen. */ +object SharedWithMeTestTags { + const val LIST = "sharedWithMeList" + const val EMPTY = "sharedWithMeEmpty" + const val PROGRESS = "sharedWithMeProgress" + const val ERROR = "sharedWithMeError" + fun row(id: String) = "sharedWithMe_$id" +} + +/** + * Hilt-wired "Shared with me" surface: lists other users have granted the current + * user access to. [onBack] pops navigation; [onOpenList] opens a shared list's detail. + */ +@Composable +fun SharedWithMeRoute( + onBack: () -> Unit, + onOpenList: (String) -> Unit, + modifier: Modifier = Modifier, + viewModel: SharedWithMeViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + SharedWithMeScreen( + state = state, + onBack = onBack, + onOpenList = onOpenList, + modifier = modifier, + ) +} + +/** Stateless "Shared with me" screen — owner + role per shared list. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SharedWithMeScreen( + state: SharedWithMeUiState, + onBack: () -> Unit, + onOpenList: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Shared with me") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(SharedWithMeTestTags.PROGRESS)) } + + state.errorMessage != null -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .padding(24.dp) + .testTag(SharedWithMeTestTags.ERROR), + ) + } + + state.isEmpty -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.testTag(SharedWithMeTestTags.EMPTY), + ) { + Text("Nothing shared with you yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Lists others share with you will appear here.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .testTag(SharedWithMeTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.lists, key = { it.id }) { shared -> + SharedListRow(shared = shared, onOpen = { onOpenList(shared.id) }) + } + } + } + } +} + +@Composable +private fun SharedListRow(shared: SharedList, onOpen: () -> Unit) { + Card( + onClick = onOpen, + modifier = Modifier + .fillMaxWidth() + .testTag(SharedWithMeTestTags.row(shared.id)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = shared.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "Shared by ${shared.ownerLabel}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + AssistChip(onClick = onOpen, label = { Text(shared.role.label) }) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun SharedWithMePreview() { + InterlinedListTheme { + SharedWithMeScreen( + state = SharedWithMeUiState( + lists = listOf( + SharedList("w1", "Shows Upcoming & Seen", null, "Adron Hall", ShareRole.EDIT, true), + SharedList("w2", "Videos to Watch", null, "Adron Hall", ShareRole.VIEW, true), + ), + isLoading = false, + ), + onBack = {}, + onOpenList = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeViewModel.kt new file mode 100644 index 0000000..dcab9f7 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedWithMeViewModel.kt @@ -0,0 +1,50 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.SharedList +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the "Shared with me" section. */ +data class SharedWithMeUiState( + val lists: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, +) { + val isEmpty: Boolean get() = lists.isEmpty() && !isLoading && errorMessage == null +} + +/** Loads lists shared with the current user (`GET /api/lists/watching`). */ +@HiltViewModel +class SharedWithMeViewModel @Inject constructor( + private val repository: ListsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SharedWithMeUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getSharedWithMe()) { + is ApiResult.Success -> _uiState.update { it.copy(lists = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index 1b95062..67056c1 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -11,6 +11,10 @@ import com.interlinedlist.android.feature.lists.domain.ListSchema import com.interlinedlist.android.feature.lists.domain.ListSummary import com.interlinedlist.android.feature.lists.domain.Paged import com.interlinedlist.android.feature.lists.domain.RefreshResult +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList +import com.interlinedlist.android.feature.lists.domain.SharedListResolution import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole @@ -51,14 +55,29 @@ class FakeListsRepository : ListsRepository { var createConnectionResult: ApiResult? = null var deleteConnectionResult: ApiResult = ApiResult.Success(Unit) + // Sharing. + var shareLinksResult: ApiResult> = ApiResult.Success(emptyList()) + var createShareLinkResult: ApiResult? = null + var revokeShareLinkResult: ApiResult = ApiResult.Success(Unit) + var sharedWithMeResult: ApiResult> = ApiResult.Success(emptyList()) + var resolveSharedResult: ApiResult? = null + var claimSharedResult: ApiResult = ApiResult.Success(Unit) + var refreshCount = 0 var loadMoreCount = 0 var updateSchemaCount = 0 var refreshGithubCount = 0 var addWatcherCount = 0 var removeWatcherCount = 0 + var createShareLinkCount = 0 + var revokeShareLinkCount = 0 + var claimSharedCount = 0 var lastSchemaUpdate: ListSchema? = null var lastWatcherSearch: String? = null + var lastCreatedShareRole: ShareRole? = null + var lastRevokedToken: String? = null + var lastResolvedToken: String? = null + var lastClaimedToken: String? = null override fun observeLists(): Flow> = cache @@ -159,6 +178,37 @@ class FakeListsRepository : ListsRepository { override suspend fun deleteConnection(id: String): ApiResult = deleteConnectionResult + override suspend fun getShareLinks(listId: String): ApiResult> = shareLinksResult + + override suspend fun createShareLink(listId: String, role: ShareRole): ApiResult { + createShareLinkCount++ + lastCreatedShareRole = role + return createShareLinkResult ?: ApiResult.Success( + ShareLink("link-new", "token-new", role, null, null, null), + ) + } + + override suspend fun revokeShareLink(listId: String, token: String): ApiResult { + revokeShareLinkCount++ + lastRevokedToken = token + return revokeShareLinkResult + } + + override suspend fun getSharedWithMe(): ApiResult> = sharedWithMeResult + + override suspend fun resolveSharedList(token: String): ApiResult { + lastResolvedToken = token + return resolveSharedResult ?: ApiResult.Success( + SharedListResolution(token, "L", "Untitled", null, null, ShareRole.VIEW, emptyList()), + ) + } + + override suspend fun claimSharedList(token: String): ApiResult { + claimSharedCount++ + lastClaimedToken = token + return claimSharedResult + } + companion object { fun subscriptionFailure(): ApiResult.Failure = ApiResult.Failure(AppError.SubscriptionRequired("Lists require an active subscription")) diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt new file mode 100644 index 0000000..1871b30 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryShareTest.kt @@ -0,0 +1,236 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * MockWebServer coverage for the list sharing endpoints: list/create/revoke share + * links, "shared with me" (watching) parse incl. the per-list role, resolve a + * token to a read-only preview, and claim access. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryShareTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var repository: DefaultListsRepository + + // Mirrors the app's shared Json (explicit nulls off, coerce defaults on). + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ListsApi::class.java) + repository = DefaultListsRepository(api, FakeShareDao(), json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `getShareLinks parses the shareLinks envelope and maps roles`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "shareLinks": [ + { "id": "s1", "listId": "L1", "token": "tok-view", "role": "view", "createdAt": "2026-01-01" }, + { "id": "s2", "listId": "L1", "token": "tok-edit", "role": "edit", "revokedAt": null } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getShareLinks("L1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val links = (result as ApiResult.Success).data + assertThat(links.map { it.token }).containsExactly("tok-view", "tok-edit").inOrder() + assertThat(links[0].role).isEqualTo(ShareRole.VIEW) + assertThat(links[1].role).isEqualTo(ShareRole.EDIT) + assertThat(links[1].isActive).isTrue() + assertThat(links[0].url()).isEqualTo("https://interlinedlist.com/lists/shared/tok-view") + + val request: RecordedRequest = server.takeRequest() + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/api/lists/L1/share-links") + } + + @Test + fun `createShareLink posts the chosen role and returns the created link`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "shareLink": { "id": "s9", "token": "new-tok", "role": "admin", "createdAt": "2026-02-02" } }""", + ), + ) + + val result = repository.createShareLink("L1", ShareRole.ADMIN) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val link = (result as ApiResult.Success).data + assertThat(link.token).isEqualTo("new-tok") + assertThat(link.role).isEqualTo(ShareRole.ADMIN) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/L1/share-links") + assertThat(request.body.readUtf8()).contains("\"role\":\"admin\"") + } + + @Test + fun `createShareLink tolerates a bare wrapped link body`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "id": "s7", "token": "bare-tok", "role": "edit" }"""), + ) + + val result = repository.createShareLink("L1", ShareRole.EDIT) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.token).isEqualTo("bare-tok") + } + + @Test + fun `revokeShareLink deletes by token`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.revokeShareLink("L1", "tok-gone") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/lists/L1/share-links/tok-gone") + } + + @Test + fun `getSharedWithMe parses watching lists with owner and role`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "lists": [ + { "id": "w1", "title": "Shows", "description": null, "isPublic": true, + "user": { "id": "u1", "username": "adron", "displayName": "Adron Hall" }, + "role": "collaborator" }, + { "id": "w2", "title": "Videos", "isPublic": true, + "user": { "id": "u1", "username": "adron", "displayName": "Adron Hall" }, + "role": "watcher" } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getSharedWithMe() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val shared = (result as ApiResult.Success).data + assertThat(shared.map { it.id }).containsExactly("w1", "w2").inOrder() + assertThat(shared[0].ownerName).isEqualTo("Adron Hall") + // "collaborator" maps to EDIT; "watcher" is read-only -> VIEW. + assertThat(shared[0].role).isEqualTo(ShareRole.EDIT) + assertThat(shared[1].role).isEqualTo(ShareRole.VIEW) + + assertThat(server.takeRequest().path).isEqualTo("/api/lists/watching") + } + + @Test + fun `resolveSharedList maps preview metadata rows and role`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "id": "L5", "title": "Public Reading", "description": "Books", + "role": "edit", + "user": { "id": "u2", "username": "grace", "displayName": "Grace H" }, + "rows": [ { "id": "r1", "data": { "title": "Dune" } } ] + } + """.trimIndent(), + ), + ) + + val result = repository.resolveSharedList("shared-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val res = (result as ApiResult.Success).data + assertThat(res.token).isEqualTo("shared-tok") + assertThat(res.listId).isEqualTo("L5") + assertThat(res.title).isEqualTo("Public Reading") + assertThat(res.ownerName).isEqualTo("Grace H") + assertThat(res.role).isEqualTo(ShareRole.EDIT) + assertThat(res.canClaim).isTrue() + assertThat(res.rows.single().valueFor("title")).isEqualTo("Dune") + + assertThat(server.takeRequest().path).isEqualTo("/api/lists/shared/shared-tok") + } + + @Test + fun `resolveSharedList maps a 404 to NotFound`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Share link not found, expired, or revoked", "code": "not_found" }"""), + ) + + val result = repository.resolveSharedList("dead-tok") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `claimSharedList posts to the shared token`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + + val result = repository.claimSharedList("claim-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/lists/shared/claim-tok") + } +} + +/** Minimal no-op [ListDao] — the share endpoints don't touch Room. */ +private class FakeShareDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + override fun observeLists(): Flow> = state + override suspend fun upsertAll(lists: List) {} + override suspend fun upsert(list: CachedListEntity) {} + override suspend fun deleteById(id: String) {} + override suspend fun clear() {} +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModelTest.kt new file mode 100644 index 0000000..f560641 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/ShareViewModelTest.kt @@ -0,0 +1,134 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.ShareLink +import com.interlinedlist.android.feature.lists.domain.ShareRole +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ShareViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun link(token: String, role: ShareRole = ShareRole.VIEW) = + ShareLink("id-$token", token, role, null, null, null) + + private fun viewModel(repo: FakeListsRepository) = + ShareViewModel(repo, SavedStateHandle(mapOf(SHARE_LIST_ID_ARG to "L1"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads existing share links on init`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + shareLinksResult = ApiResult.Success(listOf(link("a"), link("b", ShareRole.EDIT))) + } + val vm = viewModel(repo) + + vm.uiState.test { + awaitItem() // initial loading + advanceUntilIdle() + val loaded = expectMostRecentItem() + assertThat(loaded.isLoading).isFalse() + assertThat(loaded.activeLinks.map { it.token }).containsExactly("a", "b").inOrder() + } + } + + @Test + fun `createLink optimistically appends the created link with the chosen role`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + shareLinksResult = ApiResult.Success(emptyList()) + createShareLinkResult = ApiResult.Success(link("fresh", ShareRole.ADMIN)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.selectRole(ShareRole.ADMIN) + vm.createLink() + advanceUntilIdle() + + assertThat(repo.createShareLinkCount).isEqualTo(1) + assertThat(repo.lastCreatedShareRole).isEqualTo(ShareRole.ADMIN) + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("fresh") + assertThat(vm.uiState.value.isCreating).isFalse() + } + + @Test + fun `createLink failure surfaces an error and adds nothing`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + shareLinksResult = ApiResult.Success(emptyList()) + createShareLinkResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.createLink() + advanceUntilIdle() + + assertThat(vm.uiState.value.activeLinks).isEmpty() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `revokeLink optimistically removes the link on success`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + shareLinksResult = ApiResult.Success(listOf(link("keep"), link("drop"))) + revokeShareLinkResult = ApiResult.Success(Unit) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revokeLink(link("drop")) + // Optimistic removal is immediate, before the network call resolves. + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("keep") + + advanceUntilIdle() + assertThat(repo.lastRevokedToken).isEqualTo("drop") + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("keep") + } + + @Test + fun `revokeLink rolls back and shows an error on failure`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + shareLinksResult = ApiResult.Success(listOf(link("keep"), link("drop"))) + revokeShareLinkResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revokeLink(link("drop")) + advanceUntilIdle() + + // Rolled back: both links are present again. + assertThat(vm.uiState.value.activeLinks.map { it.token }).containsExactly("keep", "drop").inOrder() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `load failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + shareLinksResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.isLoading).isFalse() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModelTest.kt new file mode 100644 index 0000000..7e9b3b8 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/share/SharedListViewModelTest.kt @@ -0,0 +1,127 @@ +package com.interlinedlist.android.feature.lists.ui.share + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.ShareRole +import com.interlinedlist.android.feature.lists.domain.SharedList +import com.interlinedlist.android.feature.lists.domain.SharedListResolution +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SharedListViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun resolution(role: ShareRole) = + SharedListResolution("tok", "L5", "Reading", "Books", "Grace", role, emptyList()) + + private fun sharedVm(repo: FakeListsRepository) = + SharedListViewModel(repo, SavedStateHandle(mapOf(SHARED_TOKEN_ARG to "tok"))) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `resolves the token into a preview on init`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + resolveSharedResult = ApiResult.Success(resolution(ShareRole.EDIT)) + } + val vm = sharedVm(repo) + advanceUntilIdle() + + assertThat(repo.lastResolvedToken).isEqualTo("tok") + assertThat(vm.uiState.value.resolution?.listId).isEqualTo("L5") + assertThat(vm.uiState.value.canClaim).isTrue() + } + + @Test + fun `a view-only link cannot be claimed`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + resolveSharedResult = ApiResult.Success(resolution(ShareRole.VIEW)) + } + val vm = sharedVm(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.canClaim).isFalse() + vm.claim() + advanceUntilIdle() + assertThat(repo.claimSharedCount).isEqualTo(0) + } + + @Test + fun `claim success flips claimed`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + resolveSharedResult = ApiResult.Success(resolution(ShareRole.ADMIN)) + claimSharedResult = ApiResult.Success(Unit) + } + val vm = sharedVm(repo) + advanceUntilIdle() + + vm.claim() + advanceUntilIdle() + + assertThat(repo.claimSharedCount).isEqualTo(1) + assertThat(repo.lastClaimedToken).isEqualTo("tok") + assertThat(vm.uiState.value.claimed).isTrue() + assertThat(vm.uiState.value.canClaim).isFalse() + } + + @Test + fun `claim failure surfaces an error and stays unclaimed`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + resolveSharedResult = ApiResult.Success(resolution(ShareRole.EDIT)) + claimSharedResult = ApiResult.Failure(AppError.Forbidden("nope")) + } + val vm = sharedVm(repo) + advanceUntilIdle() + + vm.claim() + advanceUntilIdle() + + assertThat(vm.uiState.value.claimed).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `resolve failure surfaces an error`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + resolveSharedResult = ApiResult.Failure(AppError.NotFound("gone")) + } + val vm = sharedVm(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.resolution).isNull() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `shared-with-me loads lists with owner and role`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + sharedWithMeResult = ApiResult.Success( + listOf( + SharedList("w1", "Shows", null, "Adron Hall", ShareRole.EDIT, true), + SharedList("w2", "Videos", null, "Adron Hall", ShareRole.VIEW, true), + ), + ) + } + val vm = SharedWithMeViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.lists.map { it.id }).containsExactly("w1", "w2").inOrder() + assertThat(vm.uiState.value.lists[0].role).isEqualTo(ShareRole.EDIT) + assertThat(vm.uiState.value.isLoading).isFalse() + } +} From 0c9da8e6dc94032562fec425d54c265675bef8c1 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 15:36:21 -0700 Subject: [PATCH 15/25] feat(auth): complete account lifecycle (Milestone B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Register, Forgot-password, Reset-password, and Email-verification flows to :feature:auth (was login-only), reusing login's sync-token/session path. New module-local AuthApi (POST /api/auth/register|forgot-password|reset-password| verify-email|send-verification-email) — no :core:network edits. Self-contained unauth nav sub-graph with reset/verify deep-link definitions. 29 unit tests green. App-level nav + manifest intent-filters deferred (snippets in report). Co-Authored-By: Claude Opus 4.8 (1M context) --- feature/auth/build.gradle.kts | 7 + .../auth/ui/ForgotPasswordScreenTest.kt | 63 +++++ .../feature/auth/ui/RegisterScreenTest.kt | 69 +++++ .../feature/auth/data/AuthRepository.kt | 27 +- .../auth/data/DefaultAuthRepository.kt | 106 ++++++-- .../feature/auth/data/remote/AuthApi.kt | 41 +++ .../auth/data/remote/dto/AuthRequests.kt | 42 +++ .../android/feature/auth/di/AuthModule.kt | 18 ++ .../feature/auth/nav/AuthNavigation.kt | 148 +++++++++++ .../feature/auth/ui/ForgotPasswordScreen.kt | 197 ++++++++++++++ .../auth/ui/ForgotPasswordViewModel.kt | 53 ++++ .../android/feature/auth/ui/LoginScreen.kt | 25 ++ .../android/feature/auth/ui/RegisterScreen.kt | 251 ++++++++++++++++++ .../feature/auth/ui/RegisterViewModel.kt | 97 +++++++ .../feature/auth/ui/ResetPasswordScreen.kt | 208 +++++++++++++++ .../feature/auth/ui/ResetPasswordViewModel.kt | 86 ++++++ .../feature/auth/ui/VerifyEmailScreen.kt | 180 +++++++++++++ .../feature/auth/ui/VerifyEmailViewModel.kt | 106 ++++++++ .../auth/data/DefaultAuthRepositoryTest.kt | 203 ++++++++++++++ .../android/feature/auth/data/TestDoubles.kt | 93 +++++++ .../feature/auth/ui/FakeAuthRepository.kt | 85 ++++++ .../auth/ui/ForgotPasswordViewModelTest.kt | 66 +++++ .../feature/auth/ui/LoginViewModelTest.kt | 25 +- .../feature/auth/ui/RegisterViewModelTest.kt | 113 ++++++++ .../auth/ui/ResetPasswordViewModelTest.kt | 79 ++++++ .../auth/ui/VerifyEmailViewModelTest.kt | 82 ++++++ 26 files changed, 2425 insertions(+), 45 deletions(-) create mode 100644 feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreenTest.kt create mode 100644 feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreenTest.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModel.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModel.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/TestDoubles.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModelTest.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModelTest.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModelTest.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModelTest.kt diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 8039185..92bade6 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -44,12 +44,19 @@ dependencies { implementation(libs.hilt.android) ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + // The module owns its unauthenticated nav sub-graph (Login/Register/Forgot/Reset/Verify). + implementation(libs.androidx.navigation.compose) // Unit tests testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.turbine) testImplementation(libs.truth) + // Repository tests exercise the module-local AuthApi over MockWebServer. + testImplementation(libs.retrofit.core) + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.kotlinx.serialization.json) + testImplementation(libs.retrofit.kotlinx.serialization) // Instrumented / UI tests androidTestImplementation(libs.androidx.test.ext.junit) diff --git a/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreenTest.kt b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreenTest.kt new file mode 100644 index 0000000..28d5d69 --- /dev/null +++ b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreenTest.kt @@ -0,0 +1,63 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ForgotPasswordScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + /** + * Hosts the stateless [ForgotPasswordScreen]. On submit it flips a local + * `emailSent` flag, standing in for the ViewModel's success transition so + * the confirmation swap can be asserted without a repository. + */ + private fun setForgot() { + composeRule.setContent { + var state by mutableStateOf(ForgotPasswordUiState()) + InterlinedListTheme { + ForgotPasswordScreen( + state = state, + onEmailChange = { state = state.copy(email = it, errorMessage = null) }, + onSubmit = { state = state.copy(emailSent = true) }, + onBackToLogin = {}, + ) + } + } + } + + @Test + fun submit_disabledUntilEmailEntered() { + setForgot() + + composeRule.onNodeWithTag(ForgotPasswordTestTags.SUBMIT).assertIsNotEnabled() + composeRule.onNodeWithTag(ForgotPasswordTestTags.EMAIL).performTextInput("me@example.com") + composeRule.onNodeWithTag(ForgotPasswordTestTags.SUBMIT).assertIsEnabled() + } + + @Test + fun submit_showsCheckYourEmailConfirmation() { + setForgot() + + composeRule.onNodeWithTag(ForgotPasswordTestTags.EMAIL).performTextInput("me@example.com") + composeRule.onNodeWithTag(ForgotPasswordTestTags.SUBMIT).performClick() + + // The form is replaced by the confirmation; the email field goes away. + composeRule.onNodeWithTag(ForgotPasswordTestTags.CONFIRMATION).assertExists() + composeRule.onNodeWithTag(ForgotPasswordTestTags.EMAIL).assertDoesNotExist() + } +} diff --git a/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreenTest.kt b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreenTest.kt new file mode 100644 index 0000000..450d368 --- /dev/null +++ b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreenTest.kt @@ -0,0 +1,69 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RegisterScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + /** Hosts the stateless [RegisterScreen] with an in-memory state holder. */ + private fun setRegister() { + composeRule.setContent { + var state by mutableStateOf(RegisterUiState()) + InterlinedListTheme { + RegisterScreen( + state = state, + onDisplayNameChange = { state = state.copy(displayName = it, errorMessage = null) }, + onUsernameChange = { state = state.copy(username = it, errorMessage = null) }, + onEmailChange = { state = state.copy(email = it, errorMessage = null) }, + onPasswordChange = { state = state.copy(password = it, errorMessage = null) }, + onConfirmPasswordChange = { state = state.copy(confirmPassword = it, errorMessage = null) }, + onSubmit = {}, + onBackToLogin = {}, + ) + } + } + } + + @Test + fun submit_disabledUntilAllRequiredFieldsFilledAndPasswordsMatch() { + setRegister() + + composeRule.onNodeWithTag(RegisterTestTags.SUBMIT).assertIsNotEnabled() + + composeRule.onNodeWithTag(RegisterTestTags.USERNAME).performTextInput("newbie") + composeRule.onNodeWithTag(RegisterTestTags.EMAIL).performTextInput("new@example.com") + composeRule.onNodeWithTag(RegisterTestTags.PASSWORD).performTextInput("s3cret!!") + + // Mismatched confirmation keeps submit disabled and shows the hint. + composeRule.onNodeWithTag(RegisterTestTags.CONFIRM).performTextInput("different") + composeRule.onNodeWithTag(RegisterTestTags.SUBMIT).assertIsNotEnabled() + composeRule.onNodeWithTag(RegisterTestTags.MISMATCH).assertExists() + } + + @Test + fun submit_enabledWhenPasswordsMatch() { + setRegister() + + composeRule.onNodeWithTag(RegisterTestTags.USERNAME).performTextInput("newbie") + composeRule.onNodeWithTag(RegisterTestTags.EMAIL).performTextInput("new@example.com") + composeRule.onNodeWithTag(RegisterTestTags.PASSWORD).performTextInput("s3cret!!") + composeRule.onNodeWithTag(RegisterTestTags.CONFIRM).performTextInput("s3cret!!") + + composeRule.onNodeWithTag(RegisterTestTags.SUBMIT).assertIsEnabled() + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt index 15a405f..6555799 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt @@ -3,7 +3,7 @@ package com.interlinedlist.android.feature.auth.data import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.model.User -/** Authentication and session operations for the app. */ +/** Authentication, account-lifecycle, and session operations for the app. */ interface AuthRepository { /** Whether a bearer token is already persisted. */ @@ -15,6 +15,31 @@ interface AuthRepository { */ suspend fun login(email: String, password: String): ApiResult + /** + * Creates an account, then signs in exactly the way [login] does (mint a + * sync-token + fetch the user), so the caller lands in the authed state with + * no session left behind on failure. The returned [User] carries + * `emailVerified` so the UI can surface an "unverified email" hint. + */ + suspend fun register( + email: String, + username: String, + password: String, + displayName: String?, + ): ApiResult + + /** Starts a password reset; the server emails a tokenised reset link. */ + suspend fun forgotPassword(email: String): ApiResult + + /** Completes a password reset with the emailed token and a new password. */ + suspend fun resetPassword(token: String, newPassword: String): ApiResult + + /** Confirms an email address with the token from the verification link. */ + suspend fun verifyEmail(token: String): ApiResult + + /** Resends the verification email to the signed-in (unverified) user. */ + suspend fun resendVerificationEmail(): ApiResult + /** Clears the persisted session and cached user. */ suspend fun logout() } diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt index 14784ce..862d770 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt @@ -10,12 +10,18 @@ import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.core.network.dto.SyncTokenRequest import com.interlinedlist.android.core.network.dto.toDomain import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.auth.data.remote.AuthApi +import com.interlinedlist.android.feature.auth.data.remote.dto.ForgotPasswordRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.RegisterRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.ResetPasswordRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailRequest import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import javax.inject.Inject class DefaultAuthRepository @Inject constructor( private val api: InterlinedListApi, + private val authApi: AuthApi, private val sessionStore: SessionStore, private val userDao: UserDao, private val json: Json, @@ -26,36 +32,92 @@ class DefaultAuthRepository @Inject constructor( override suspend fun login(email: String, password: String): ApiResult = withContext(dispatchers.io) { - // 1) Exchange credentials for a bearer token. - val tokenResult = safeApiCall(json) { - api.createSyncToken(SyncTokenRequest(email, password)) - } - val token = when (tokenResult) { - is ApiResult.Success -> tokenResult.data.token - is ApiResult.Failure -> return@withContext tokenResult - } - sessionStore.saveToken(token) + signIn(email, password) + } - // 2) Fetch the authenticated user using the new token. - when (val userResult = safeApiCall(json) { api.getCurrentUser().user }) { - is ApiResult.Success -> { - val user = userResult.data.toDomain() - sessionStore.userId = user.id - userDao.upsert(user.toCacheEntity()) - ApiResult.Success(user) - } - is ApiResult.Failure -> { - // Don't leave a half-authenticated session behind. - sessionStore.clear() - userResult - } + override suspend fun register( + email: String, + username: String, + password: String, + displayName: String?, + ): ApiResult = withContext(dispatchers.io) { + // 1) Create the account. On failure (e.g. email/username taken, weak + // password) surface the mapped error without touching the session. + val created = safeApiCall(json) { + authApi.register( + RegisterRequest( + email = email, + username = username, + password = password, + displayName = displayName, + ), + ) + } + when (created) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> return@withContext created + } + + // 2) Sign in exactly like login so the caller lands in the authed state. + signIn(email, password) + } + + override suspend fun forgotPassword(email: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { authApi.forgotPassword(ForgotPasswordRequest(email)) } + } + + override suspend fun resetPassword(token: String, newPassword: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { + authApi.resetPassword(ResetPasswordRequest(token = token, password = newPassword)) } } + override suspend fun verifyEmail(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { authApi.verifyEmail(VerifyEmailRequest(token)) } + } + + override suspend fun resendVerificationEmail(): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { authApi.sendVerificationEmail() } + } + override suspend fun logout() = withContext(dispatchers.io) { sessionStore.clear() userDao.clear() } + + /** + * Shared sign-in used by both [login] and [register]: exchange credentials + * for a bearer token, persist it, then fetch and cache the user. Leaves no + * half-authenticated session behind if the user fetch fails. + */ + private suspend fun signIn(email: String, password: String): ApiResult { + val tokenResult = safeApiCall(json) { + api.createSyncToken(SyncTokenRequest(email, password)) + } + val token = when (tokenResult) { + is ApiResult.Success -> tokenResult.data.token + is ApiResult.Failure -> return tokenResult + } + sessionStore.saveToken(token) + + return when (val userResult = safeApiCall(json) { api.getCurrentUser().user }) { + is ApiResult.Success -> { + val user = userResult.data.toDomain() + sessionStore.userId = user.id + userDao.upsert(user.toCacheEntity()) + ApiResult.Success(user) + } + is ApiResult.Failure -> { + // Don't leave a half-authenticated session behind. + sessionStore.clear() + userResult + } + } + } } private fun User.toCacheEntity() = CachedUserEntity( diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt new file mode 100644 index 0000000..a064be9 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.feature.auth.data.remote + +import com.interlinedlist.android.feature.auth.data.remote.dto.ForgotPasswordRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.RegisterRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.ResetPasswordRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailRequest +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Module-local Retrofit description of the pre-auth account-lifecycle endpoints. + * + * Built from the shared [retrofit2.Retrofit] singleton (base URL + auth + * interceptor) so it stays self-contained within `:feature:auth`; the shared + * `InterlinedListApi` in `:core:network` is intentionally left untouched. These + * calls need no bearer token — the interceptor simply omits it when no session + * exists. Every endpoint returns `201` on success (no body) and `400` with the + * `{ "error": ... }` envelope on validation failure. + */ +interface AuthApi { + + /** Creates a new account. Sign-in still goes through the sync-token exchange. */ + @POST("api/auth/register") + suspend fun register(@Body body: RegisterRequest) + + /** Starts a password reset; the server emails a tokenised reset link. */ + @POST("api/auth/forgot-password") + suspend fun forgotPassword(@Body body: ForgotPasswordRequest) + + /** Completes a password reset with the emailed token and a new password. */ + @POST("api/auth/reset-password") + suspend fun resetPassword(@Body body: ResetPasswordRequest) + + /** Confirms an email address with the token from the verification link. */ + @POST("api/auth/verify-email") + suspend fun verifyEmail(@Body body: VerifyEmailRequest) + + /** Resends the verification email to the signed-in (unverified) user. */ + @POST("api/auth/send-verification-email") + suspend fun sendVerificationEmail() +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt new file mode 100644 index 0000000..1d94363 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.auth.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Request bodies for the pre-auth account-lifecycle endpoints under the + * `api/auth/` path prefix. + * + * Field names mirror the InterlinedList OpenAPI component schemas exactly. The + * server does not model response bodies for these endpoints in the spec — they + * return `201` on success and `400` (with the standard `{ "error": ... }` + * envelope) on failure — so we only need typed request payloads here; success + * is signalled purely by the HTTP status via `safeApiCall`. + */ + +/** `POST /api/auth/register` — creates an account. `displayName` is optional. */ +@Serializable +data class RegisterRequest( + val email: String, + val username: String, + val password: String, + val displayName: String? = null, +) + +/** `POST /api/auth/forgot-password` — starts a password reset (emails a token link). */ +@Serializable +data class ForgotPasswordRequest( + val email: String, +) + +/** `POST /api/auth/reset-password` — completes a reset with the emailed token. */ +@Serializable +data class ResetPasswordRequest( + val token: String, + val password: String, +) + +/** `POST /api/auth/verify-email` — confirms an email address with the emailed token. */ +@Serializable +data class VerifyEmailRequest( + val token: String, +) diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt index bb992a1..d5400a8 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/di/AuthModule.kt @@ -2,10 +2,13 @@ package com.interlinedlist.android.feature.auth.di import com.interlinedlist.android.feature.auth.data.AuthRepository import com.interlinedlist.android.feature.auth.data.DefaultAuthRepository +import com.interlinedlist.android.feature.auth.data.remote.AuthApi import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit import javax.inject.Singleton @Module @@ -16,3 +19,18 @@ abstract class AuthModule { @Singleton abstract fun bindAuthRepository(impl: DefaultAuthRepository): AuthRepository } + +/** + * Provides the module-local [AuthApi] from the shared, already-configured + * [Retrofit] singleton (base URL + auth interceptor) owned by `:core:network`, + * keeping the account-lifecycle endpoints self-contained within `:feature:auth`. + */ +@Module +@InstallIn(SingletonComponent::class) +object AuthApiModule { + + @Provides + @Singleton + fun provideAuthApi(retrofit: Retrofit): AuthApi = + retrofit.create(AuthApi::class.java) +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt new file mode 100644 index 0000000..40e3413 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt @@ -0,0 +1,148 @@ +package com.interlinedlist.android.feature.auth.nav + +import androidx.compose.runtime.Composable +import androidx.navigation.NavController +import androidx.navigation.NavDeepLink +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.interlinedlist.android.feature.auth.ui.ForgotPasswordRoute +import com.interlinedlist.android.feature.auth.ui.LoginRoute +import com.interlinedlist.android.feature.auth.ui.RegisterRoute +import com.interlinedlist.android.feature.auth.ui.ResetPasswordRoute +import com.interlinedlist.android.feature.auth.ui.VerifyEmailRoute + +/** + * Route keys for the module's own unauthenticated sub-graph. Kept here so the + * app can nest this graph without knowing the individual screen wiring, and so + * the reset/verify deep-link paths live next to their consuming destinations. + */ +object AuthRoutes { + /** Nested-graph route the app points at as the "signed-out" destination. */ + const val GRAPH = "auth" + + const val LOGIN = "auth/login" + const val REGISTER = "auth/register" + const val FORGOT = "auth/forgot" + + /** Query-arg name carrying the emailed token on the reset/verify links. */ + const val TOKEN_ARG = "token" + + const val RESET = "auth/reset?$TOKEN_ARG={$TOKEN_ARG}" + const val VERIFY = "auth/verify?$TOKEN_ARG={$TOKEN_ARG}" + + /** + * Web deep links the app forwards into this graph. The app-level manifest + * `` maps `https://interlinedlist.com/reset-password` and + * `/verify-email` onto these so `NavController.handleDeepLink` lands directly + * on the matching screen with its `token` populated. + */ + val RESET_DEEP_LINKS: List = listOf( + navDeepLink { uriPattern = "https://interlinedlist.com/reset-password?$TOKEN_ARG={$TOKEN_ARG}" }, + navDeepLink { uriPattern = "interlinedlist://reset-password?$TOKEN_ARG={$TOKEN_ARG}" }, + ) + val VERIFY_DEEP_LINKS: List = listOf( + navDeepLink { uriPattern = "https://interlinedlist.com/verify-email?$TOKEN_ARG={$TOKEN_ARG}" }, + navDeepLink { uriPattern = "interlinedlist://verify-email?$TOKEN_ARG={$TOKEN_ARG}" }, + ) + + fun reset(token: String) = "auth/reset?$TOKEN_ARG=$token" + fun verify(token: String) = "auth/verify?$TOKEN_ARG=$token" +} + +/** + * Registers the unauthenticated sub-graph: Login ⇄ Register ⇄ Forgot → Reset, + * plus a verify-email handler. All navigation stays inside the module; the only + * hook the host provides is [onAuthenticated], invoked once a session exists + * (identical to the existing login callback), so the app can swap to its + * signed-in shell. + */ +fun NavGraphBuilder.authGraph( + navController: NavController, + onAuthenticated: () -> Unit, +) { + composable(AuthRoutes.LOGIN) { + LoginRoute( + onLoggedIn = onAuthenticated, + onRegister = { navController.navigate(AuthRoutes.REGISTER) }, + onForgotPassword = { navController.navigate(AuthRoutes.FORGOT) }, + ) + } + + composable(AuthRoutes.REGISTER) { + RegisterRoute( + onRegistered = onAuthenticated, + onBackToLogin = { navController.popBackStack() }, + ) + } + + composable(AuthRoutes.FORGOT) { + ForgotPasswordRoute( + onBackToLogin = { navController.popBackStack() }, + ) + } + + composable( + route = AuthRoutes.RESET, + arguments = listOf( + navArgument(AuthRoutes.TOKEN_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + deepLinks = AuthRoutes.RESET_DEEP_LINKS, + ) { + ResetPasswordRoute( + onReset = { navController.popToLogin() }, + onBackToLogin = { navController.popToLogin() }, + ) + } + + composable( + route = AuthRoutes.VERIFY, + arguments = listOf( + navArgument(AuthRoutes.TOKEN_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + deepLinks = AuthRoutes.VERIFY_DEEP_LINKS, + ) { + VerifyEmailRoute( + onDone = { navController.popToLogin() }, + ) + } +} + +/** Returns to Login, clearing any Register/Forgot/Reset/Verify screens above it. */ +private fun NavController.popToLogin() { + navigate(AuthRoutes.LOGIN) { + popUpTo(AuthRoutes.LOGIN) { inclusive = true } + launchSingleTop = true + } +} + +/** + * Standalone host for the whole unauthenticated flow. Handy for the auth + * module's own instrumented tests, and usable by the app as the pre-login + * screen. Deep links resolve through the nested [authGraph]. + */ +@Composable +fun AuthNavHost( + onAuthenticated: () -> Unit, + navController: NavHostController = rememberNavController(), +) { + NavHost( + navController = navController, + startDestination = AuthRoutes.LOGIN, + ) { + authGraph(navController = navController, onAuthenticated = onAuthenticated) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreen.kt new file mode 100644 index 0000000..7dbeac7 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordScreen.kt @@ -0,0 +1,197 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.component.InterlinedListWordmark +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme + +/** Stable test tags for the forgot-password controls. */ +object ForgotPasswordTestTags { + const val EMAIL = "forgotEmail" + const val SUBMIT = "forgotSubmit" + const val ERROR = "forgotError" + const val PROGRESS = "forgotProgress" + const val CONFIRMATION = "forgotConfirmation" + const val BACK = "forgotBack" +} + +/** Hilt-wired entry point. */ +@Composable +fun ForgotPasswordRoute( + onBackToLogin: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ForgotPasswordViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ForgotPasswordScreen( + state = state, + onEmailChange = viewModel::onEmailChange, + onSubmit = viewModel::submit, + onBackToLogin = onBackToLogin, + modifier = modifier, + ) +} + +/** Stateless forgot-password UI: an email form that becomes a confirmation. */ +@Composable +fun ForgotPasswordScreen( + state: ForgotPasswordUiState, + onEmailChange: (String) -> Unit, + onSubmit: () -> Unit, + onBackToLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + InterlinedListWordmark() + Spacer(Modifier.height(24.dp)) + + if (state.emailSent) { + Text( + text = "Check your email", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "If an account exists for that address, we've sent a link " + + "to reset your password.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().testTag(ForgotPasswordTestTags.CONFIRMATION), + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onBackToLogin, + modifier = Modifier.fillMaxWidth().testTag(ForgotPasswordTestTags.BACK), + ) { + Text("Back to sign in") + } + } else { + Text( + text = "Reset your password", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Enter your email and we'll send you a reset link.", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + + OutlinedTextField( + value = state.email, + onValueChange = onEmailChange, + label = { Text("Email") }, + singleLine = true, + enabled = !state.isLoading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth().testTag(ForgotPasswordTestTags.EMAIL), + ) + + if (state.errorMessage != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().testTag(ForgotPasswordTestTags.ERROR), + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = onSubmit, + enabled = state.canSubmit, + modifier = Modifier.fillMaxWidth().testTag(ForgotPasswordTestTags.SUBMIT), + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp).testTag(ForgotPasswordTestTags.PROGRESS), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Send reset link") + } + } + + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = onBackToLogin, + enabled = !state.isLoading, + modifier = Modifier.testTag(ForgotPasswordTestTags.BACK), + ) { + Text("Back to sign in") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ForgotPasswordFormPreview() { + InterlinedListTheme { + ForgotPasswordScreen( + state = ForgotPasswordUiState(email = "you@example.com"), + onEmailChange = {}, + onSubmit = {}, + onBackToLogin = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ForgotPasswordSentPreview() { + InterlinedListTheme { + ForgotPasswordScreen( + state = ForgotPasswordUiState(email = "you@example.com", emailSent = true), + onEmailChange = {}, + onSubmit = {}, + onBackToLogin = {}, + ) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModel.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModel.kt new file mode 100644 index 0000000..6b61a60 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModel.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.auth.data.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the forgot-password screen. */ +data class ForgotPasswordUiState( + val email: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, + /** Set once the reset email has been requested — swaps the form for a confirmation. */ + val emailSent: Boolean = false, +) { + val canSubmit: Boolean get() = email.isNotBlank() && !isLoading +} + +@HiltViewModel +class ForgotPasswordViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(ForgotPasswordUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEmailChange(value: String) = + _uiState.update { it.copy(email = value, errorMessage = null) } + + /** Requests a reset link; on success the UI shows a "check your email" confirmation. */ + fun submit() { + val current = _uiState.value + if (!current.canSubmit) return + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = authRepository.forgotPassword(current.email.trim())) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoading = false, emailSent = true) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/LoginScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/LoginScreen.kt index ff97dea..2186fce 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/LoginScreen.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/LoginScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -40,6 +41,8 @@ object LoginTestTags { const val SUBMIT = "loginSubmit" const val ERROR = "loginError" const val PROGRESS = "loginProgress" + const val REGISTER = "loginRegister" + const val FORGOT = "loginForgot" } /** Hilt-wired entry point; collects state and forwards events to the ViewModel. */ @@ -47,6 +50,8 @@ object LoginTestTags { fun LoginRoute( onLoggedIn: () -> Unit, modifier: Modifier = Modifier, + onRegister: () -> Unit = {}, + onForgotPassword: () -> Unit = {}, viewModel: LoginViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -55,6 +60,8 @@ fun LoginRoute( onEmailChange = viewModel::onEmailChange, onPasswordChange = viewModel::onPasswordChange, onSubmit = { viewModel.login(onLoggedIn) }, + onRegister = onRegister, + onForgotPassword = onForgotPassword, modifier = modifier, ) } @@ -67,6 +74,8 @@ fun LoginScreen( onPasswordChange: (String) -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier, + onRegister: () -> Unit = {}, + onForgotPassword: () -> Unit = {}, ) { Scaffold(modifier = modifier.fillMaxSize()) { padding -> Column( @@ -146,6 +155,22 @@ fun LoginScreen( Text("Sign in") } } + + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = onForgotPassword, + enabled = !state.isLoading, + modifier = Modifier.testTag(LoginTestTags.FORGOT), + ) { + Text("Forgot password?") + } + TextButton( + onClick = onRegister, + enabled = !state.isLoading, + modifier = Modifier.testTag(LoginTestTags.REGISTER), + ) { + Text("Create an account") + } } } } diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreen.kt new file mode 100644 index 0000000..8459e58 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterScreen.kt @@ -0,0 +1,251 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.component.InterlinedListWordmark +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme + +/** Stable test tags so UI/instrumented tests can address the register controls. */ +object RegisterTestTags { + const val DISPLAY_NAME = "registerDisplayName" + const val USERNAME = "registerUsername" + const val EMAIL = "registerEmail" + const val PASSWORD = "registerPassword" + const val CONFIRM = "registerConfirm" + const val SUBMIT = "registerSubmit" + const val ERROR = "registerError" + const val MISMATCH = "registerMismatch" + const val PROGRESS = "registerProgress" + const val SIGN_IN = "registerSignIn" +} + +/** Hilt-wired entry point; collects state and forwards events to the ViewModel. */ +@Composable +fun RegisterRoute( + onRegistered: () -> Unit, + onBackToLogin: () -> Unit, + modifier: Modifier = Modifier, + viewModel: RegisterViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + RegisterScreen( + state = state, + onDisplayNameChange = viewModel::onDisplayNameChange, + onUsernameChange = viewModel::onUsernameChange, + onEmailChange = viewModel::onEmailChange, + onPasswordChange = viewModel::onPasswordChange, + onConfirmPasswordChange = viewModel::onConfirmPasswordChange, + onSubmit = { viewModel.register(onRegistered) }, + onBackToLogin = onBackToLogin, + modifier = modifier, + ) +} + +/** Stateless register UI — easy to preview and to drive from Compose tests. */ +@Composable +fun RegisterScreen( + state: RegisterUiState, + onDisplayNameChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, + onEmailChange: (String) -> Unit, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onSubmit: () -> Unit, + onBackToLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + // Only warn about a mismatch once the user has typed a confirmation. + val showMismatch = state.confirmPassword.isNotEmpty() && !state.passwordsMatch + + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + InterlinedListWordmark() + Spacer(Modifier.height(12.dp)) + Text("Create your account", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(24.dp)) + + OutlinedTextField( + value = state.displayName, + onValueChange = onDisplayNameChange, + label = { Text("Display name (optional)") }, + singleLine = true, + enabled = !state.isLoading, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.DISPLAY_NAME), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.username, + onValueChange = onUsernameChange, + label = { Text("Username") }, + singleLine = true, + enabled = !state.isLoading, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.USERNAME), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.email, + onValueChange = onEmailChange, + label = { Text("Email") }, + singleLine = true, + enabled = !state.isLoading, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Email, + imeAction = ImeAction.Next, + ), + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.EMAIL), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + label = { Text("Password") }, + singleLine = true, + enabled = !state.isLoading, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.PASSWORD), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.confirmPassword, + onValueChange = onConfirmPasswordChange, + label = { Text("Confirm password") }, + singleLine = true, + isError = showMismatch, + enabled = !state.isLoading, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.CONFIRM), + ) + + if (showMismatch) { + Spacer(Modifier.height(8.dp)) + Text( + text = "Passwords don't match.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.MISMATCH), + ) + } + + if (state.showVerifyEmailHint) { + Spacer(Modifier.height(12.dp)) + Text( + text = "Almost there — check your inbox to verify your email address.", + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth(), + ) + } + + if (state.errorMessage != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.ERROR), + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = onSubmit, + enabled = state.canSubmit, + modifier = Modifier.fillMaxWidth().testTag(RegisterTestTags.SUBMIT), + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp).testTag(RegisterTestTags.PROGRESS), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Create account") + } + } + + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = onBackToLogin, + enabled = !state.isLoading, + modifier = Modifier.testTag(RegisterTestTags.SIGN_IN), + ) { + Text("Already have an account? Sign in") + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RegisterScreenPreview() { + InterlinedListTheme { + RegisterScreen( + state = RegisterUiState( + displayName = "New Bie", + username = "newbie", + email = "you@example.com", + password = "secret", + confirmPassword = "secret", + ), + onDisplayNameChange = {}, + onUsernameChange = {}, + onEmailChange = {}, + onPasswordChange = {}, + onConfirmPasswordChange = {}, + onSubmit = {}, + onBackToLogin = {}, + ) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModel.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModel.kt new file mode 100644 index 0000000..36d19e6 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModel.kt @@ -0,0 +1,97 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.auth.data.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the registration screen. */ +data class RegisterUiState( + val displayName: String = "", + val username: String = "", + val email: String = "", + val password: String = "", + val confirmPassword: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, + /** Set when the created account still needs email verification. */ + val showVerifyEmailHint: Boolean = false, +) { + val passwordsMatch: Boolean get() = password == confirmPassword + + val canSubmit: Boolean + get() = username.isNotBlank() && + email.isNotBlank() && + password.isNotBlank() && + confirmPassword.isNotBlank() && + passwordsMatch && + !isLoading +} + +@HiltViewModel +class RegisterViewModel @Inject constructor( + private val authRepository: AuthRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(RegisterUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onDisplayNameChange(value: String) = + _uiState.update { it.copy(displayName = value, errorMessage = null) } + + fun onUsernameChange(value: String) = + _uiState.update { it.copy(username = value, errorMessage = null) } + + fun onEmailChange(value: String) = + _uiState.update { it.copy(email = value, errorMessage = null) } + + fun onPasswordChange(value: String) = + _uiState.update { it.copy(password = value, errorMessage = null) } + + fun onConfirmPasswordChange(value: String) = + _uiState.update { it.copy(confirmPassword = value, errorMessage = null) } + + /** + * Creates the account and signs in via the repository (same session path as + * login). [onRegistered] fires only once the authed session is established. + */ + fun register(onRegistered: () -> Unit) { + val current = _uiState.value + if (!current.canSubmit) { + if (!current.passwordsMatch) { + _uiState.update { it.copy(errorMessage = "Passwords don't match.") } + } + return + } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val result = authRepository.register( + email = current.email.trim(), + username = current.username.trim(), + password = current.password, + displayName = current.displayName.trim().ifBlank { null }, + ) + when (result) { + is ApiResult.Success -> { + _uiState.update { + it.copy( + isLoading = false, + showVerifyEmailHint = !result.data.emailVerified, + ) + } + onRegistered() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordScreen.kt new file mode 100644 index 0000000..f1b0d52 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordScreen.kt @@ -0,0 +1,208 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.component.InterlinedListWordmark +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme + +/** Stable test tags for the reset-password controls. */ +object ResetPasswordTestTags { + const val PASSWORD = "resetPassword" + const val CONFIRM = "resetConfirm" + const val SUBMIT = "resetSubmit" + const val ERROR = "resetError" + const val MISMATCH = "resetMismatch" + const val MISSING_TOKEN = "resetMissingToken" + const val PROGRESS = "resetProgress" + const val BACK = "resetBack" +} + +/** Hilt-wired entry point; the token is supplied via the deep-link nav argument. */ +@Composable +fun ResetPasswordRoute( + onReset: () -> Unit, + onBackToLogin: () -> Unit, + modifier: Modifier = Modifier, + viewModel: ResetPasswordViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ResetPasswordScreen( + state = state, + onPasswordChange = viewModel::onPasswordChange, + onConfirmPasswordChange = viewModel::onConfirmPasswordChange, + onSubmit = { viewModel.submit(onReset) }, + onBackToLogin = onBackToLogin, + modifier = modifier, + ) +} + +/** Stateless reset-password UI. */ +@Composable +fun ResetPasswordScreen( + state: ResetPasswordUiState, + onPasswordChange: (String) -> Unit, + onConfirmPasswordChange: (String) -> Unit, + onSubmit: () -> Unit, + onBackToLogin: () -> Unit, + modifier: Modifier = Modifier, +) { + val showMismatch = state.confirmPassword.isNotEmpty() && !state.passwordsMatch + val hasToken = state.token.isNotBlank() + + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .imePadding() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + InterlinedListWordmark() + Spacer(Modifier.height(24.dp)) + Text("Choose a new password", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(24.dp)) + + if (!hasToken) { + Text( + text = "This reset link is missing or invalid. Request a new one from " + + "the sign-in screen.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.MISSING_TOKEN), + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onBackToLogin, + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.BACK), + ) { + Text("Back to sign in") + } + return@Column + } + + OutlinedTextField( + value = state.password, + onValueChange = onPasswordChange, + label = { Text("New password") }, + singleLine = true, + enabled = !state.isLoading, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Next, + ), + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.PASSWORD), + ) + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = state.confirmPassword, + onValueChange = onConfirmPasswordChange, + label = { Text("Confirm new password") }, + singleLine = true, + isError = showMismatch, + enabled = !state.isLoading, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.CONFIRM), + ) + + if (showMismatch) { + Spacer(Modifier.height(8.dp)) + Text( + text = "Passwords don't match.", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.MISMATCH), + ) + } + + if (state.errorMessage != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.ERROR), + ) + } + + Spacer(Modifier.height(24.dp)) + Button( + onClick = onSubmit, + enabled = state.canSubmit, + modifier = Modifier.fillMaxWidth().testTag(ResetPasswordTestTags.SUBMIT), + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp).testTag(ResetPasswordTestTags.PROGRESS), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Reset password") + } + } + + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = onBackToLogin, + enabled = !state.isLoading, + modifier = Modifier.testTag(ResetPasswordTestTags.BACK), + ) { + Text("Back to sign in") + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun ResetPasswordScreenPreview() { + InterlinedListTheme { + ResetPasswordScreen( + state = ResetPasswordUiState(token = "abc", password = "secret", confirmPassword = "secret"), + onPasswordChange = {}, + onConfirmPasswordChange = {}, + onSubmit = {}, + onBackToLogin = {}, + ) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModel.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModel.kt new file mode 100644 index 0000000..f4c6c53 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModel.kt @@ -0,0 +1,86 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.auth.data.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav argument key for the reset token delivered by the deep link. */ +const val RESET_TOKEN_ARG = "token" + +/** UI state for the reset-password screen. */ +data class ResetPasswordUiState( + /** Token from the emailed reset link (may be blank if opened without one). */ + val token: String = "", + val password: String = "", + val confirmPassword: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, +) { + val passwordsMatch: Boolean get() = password == confirmPassword + + val canSubmit: Boolean + get() = token.isNotBlank() && + password.isNotBlank() && + confirmPassword.isNotBlank() && + passwordsMatch && + !isLoading +} + +@HiltViewModel +class ResetPasswordViewModel( + private val authRepository: AuthRepository, + token: String?, +) : ViewModel() { + + /** Hilt entry point: pulls the token from the deep-link nav argument. */ + @Inject + constructor( + authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, + ) : this(authRepository, savedStateHandle.get(RESET_TOKEN_ARG)) + + private val _uiState = MutableStateFlow(ResetPasswordUiState(token = token.orEmpty())) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onPasswordChange(value: String) = + _uiState.update { it.copy(password = value, errorMessage = null) } + + fun onConfirmPasswordChange(value: String) = + _uiState.update { it.copy(confirmPassword = value, errorMessage = null) } + + /** Completes the reset; on success [onReset] returns the user to login. */ + fun submit(onReset: () -> Unit) { + val current = _uiState.value + if (!current.canSubmit) { + if (!current.passwordsMatch) { + _uiState.update { it.copy(errorMessage = "Passwords don't match.") } + } + return + } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val result = authRepository.resetPassword( + token = current.token, + newPassword = current.password, + ) + when (result) { + is ApiResult.Success -> { + _uiState.update { it.copy(isLoading = false) } + onReset() + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailScreen.kt new file mode 100644 index 0000000..39082ba --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailScreen.kt @@ -0,0 +1,180 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.component.InterlinedListWordmark +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme + +/** Stable test tags for the verify-email controls. */ +object VerifyEmailTestTags { + const val STATUS = "verifyStatus" + const val MESSAGE = "verifyMessage" + const val RESEND = "verifyResend" + const val PROGRESS = "verifyProgress" + const val CONTINUE = "verifyContinue" + const val BACK = "verifyBack" +} + +/** Hilt-wired entry point; the token (if any) is supplied via the deep-link nav argument. */ +@Composable +fun VerifyEmailRoute( + onDone: () -> Unit, + modifier: Modifier = Modifier, + viewModel: VerifyEmailViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + VerifyEmailScreen( + state = state, + onResend = viewModel::resend, + onDone = onDone, + modifier = modifier, + ) +} + +/** Stateless verify-email UI: confirms a deep-link token and/or offers a resend. */ +@Composable +fun VerifyEmailScreen( + state: VerifyEmailUiState, + onResend: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + InterlinedListWordmark() + Spacer(Modifier.height(24.dp)) + + val heading = when (state.status) { + VerifyEmailStatus.VERIFYING -> "Verifying your email…" + VerifyEmailStatus.VERIFIED -> "Email verified" + VerifyEmailStatus.FAILED -> "Couldn't verify your email" + VerifyEmailStatus.IDLE -> "Verify your email" + } + Text( + text = heading, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.testTag(VerifyEmailTestTags.STATUS), + ) + + if (state.status == VerifyEmailStatus.VERIFYING) { + Spacer(Modifier.height(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(28.dp).testTag(VerifyEmailTestTags.PROGRESS), + ) + } + + val body = state.message ?: when (state.status) { + VerifyEmailStatus.IDLE -> + "We've sent a verification link to your email. Didn't get it? " + + "Resend it below." + else -> null + } + if (body != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = body, + color = if (state.status == VerifyEmailStatus.FAILED) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().testTag(VerifyEmailTestTags.MESSAGE), + ) + } + + Spacer(Modifier.height(24.dp)) + if (state.status == VerifyEmailStatus.VERIFIED) { + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth().testTag(VerifyEmailTestTags.CONTINUE), + ) { + Text("Continue") + } + } else { + OutlinedButton( + onClick = onResend, + enabled = !state.isResending, + modifier = Modifier.fillMaxWidth().testTag(VerifyEmailTestTags.RESEND), + ) { + if (state.isResending) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Resend verification email") + } + } + Spacer(Modifier.height(8.dp)) + TextButton( + onClick = onDone, + modifier = Modifier.testTag(VerifyEmailTestTags.BACK), + ) { + Text("Back to sign in") + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun VerifyEmailIdlePreview() { + InterlinedListTheme { + VerifyEmailScreen( + state = VerifyEmailUiState(status = VerifyEmailStatus.IDLE), + onResend = {}, + onDone = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun VerifyEmailVerifiedPreview() { + InterlinedListTheme { + VerifyEmailScreen( + state = VerifyEmailUiState( + status = VerifyEmailStatus.VERIFIED, + message = "Your email is verified. You're all set.", + ), + onResend = {}, + onDone = {}, + ) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModel.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModel.kt new file mode 100644 index 0000000..4c34ed9 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModel.kt @@ -0,0 +1,106 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.auth.data.AuthRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav argument key for the verification token delivered by the deep link. */ +const val VERIFY_TOKEN_ARG = "token" + +/** Where the verify-email flow currently stands. */ +enum class VerifyEmailStatus { + /** No token to check — the screen only offers a resend action. */ + IDLE, + + /** A deep-link token is being confirmed with the server. */ + VERIFYING, + + /** The token was accepted. */ + VERIFIED, + + /** The token was rejected / expired. */ + FAILED, +} + +/** UI state for the email-verification screen. */ +data class VerifyEmailUiState( + val status: VerifyEmailStatus = VerifyEmailStatus.IDLE, + /** Success or error message describing the last verify/resend outcome. */ + val message: String? = null, + val isResending: Boolean = false, + /** Set once a fresh verification email has been requested. */ + val resendConfirmed: Boolean = false, +) + +@HiltViewModel +class VerifyEmailViewModel( + private val authRepository: AuthRepository, + token: String?, +) : ViewModel() { + + /** Hilt entry point: pulls the token from the deep-link nav argument. */ + @Inject + constructor( + authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, + ) : this(authRepository, savedStateHandle.get(VERIFY_TOKEN_ARG)) + + private val _uiState = MutableStateFlow(VerifyEmailUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + val trimmed = token?.trim() + if (!trimmed.isNullOrEmpty()) { + verify(trimmed) + } + } + + private fun verify(token: String) { + _uiState.update { it.copy(status = VerifyEmailStatus.VERIFYING, message = null) } + viewModelScope.launch { + when (val result = authRepository.verifyEmail(token)) { + is ApiResult.Success -> _uiState.update { + it.copy( + status = VerifyEmailStatus.VERIFIED, + message = "Your email is verified. You're all set.", + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + status = VerifyEmailStatus.FAILED, + message = result.error.toUserMessage(), + ) + } + } + } + } + + /** Requests a fresh verification email for the signed-in user. */ + fun resend() { + if (_uiState.value.isResending) return + _uiState.update { it.copy(isResending = true, message = null, resendConfirmed = false) } + viewModelScope.launch { + when (val result = authRepository.resendVerificationEmail()) { + is ApiResult.Success -> _uiState.update { + it.copy( + isResending = false, + resendConfirmed = true, + message = "We've sent a new verification link to your email.", + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isResending = false, message = result.error.toUserMessage()) + } + } + } + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt new file mode 100644 index 0000000..accfb65 --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt @@ -0,0 +1,203 @@ +package com.interlinedlist.android.feature.auth.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.datastore.SessionStore +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.feature.auth.data.remote.AuthApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Exercises [DefaultAuthRepository] against a [MockWebServer] so the request + * shapes, HTTP-status → [AppError] mapping, and session/cache side effects are + * all covered without hitting the real API. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultAuthRepositoryTest { + + private val dispatcher = StandardTestDispatcher() + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + private lateinit var server: MockWebServer + private lateinit var api: InterlinedListApi + private lateinit var authApi: AuthApi + private lateinit var session: SessionStore + private lateinit var dao: FakeUserDao + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(InterlinedListApi::class.java) + authApi = retrofit.create(AuthApi::class.java) + session = fakeSessionStore() + dao = FakeUserDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultAuthRepository( + api = api, + authApi = authApi, + sessionStore = session, + userDao = dao, + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueue(code: Int, body: String = "") { + server.enqueue(MockResponse().setResponseCode(code).setBody(body)) + } + + // ---- register ---------------------------------------------------------- + + @Test + fun `register success signs in and stores the token and user`() = runTest(dispatcher) { + enqueue(201) // POST /api/auth/register + enqueue(200, """{ "token": "il_tok_abc" }""") // POST /api/auth/sync-token + enqueue(200, """{ "user": { "id": "u1", "username": "newbie", "emailVerified": false } }""") + + val result = repository().register( + email = "new@example.com", + username = "newbie", + password = "s3cret!!", + displayName = "New Bie", + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val user = (result as ApiResult.Success).data + assertThat(user.id).isEqualTo("u1") + assertThat(user.emailVerified).isFalse() // drives the "verify your email" hint + assertThat(session.currentToken()).isEqualTo("il_tok_abc") + assertThat(session.userId).isEqualTo("u1") + assertThat(dao.stored.value?.id).isEqualTo("u1") + + // First request was the register call with the expected body fields. + val registerBody = server.takeRequest().body.readUtf8() + assertThat(registerBody).contains("\"email\":\"new@example.com\"") + assertThat(registerBody).contains("\"username\":\"newbie\"") + assertThat(registerBody).contains("\"displayName\":\"New Bie\"") + } + + @Test + fun `register with a taken email surfaces the mapped error and no session`() = runTest(dispatcher) { + // 409 Conflict is what the API returns when the email/username is taken. + enqueue(409, """{ "error": "Email already in use" }""") + + val result = repository().register( + email = "taken@example.com", + username = "taken", + password = "s3cret!!", + displayName = null, + ) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(AppError.Conflict::class.java) + assertThat(error.message).isEqualTo("Email already in use") + // No sign-in was attempted, so no session was created. + assertThat(session.isLoggedIn).isFalse() + assertThat(dao.stored.value).isNull() + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `register maps a weak-password validation error`() = runTest(dispatcher) { + enqueue(400, """{ "error": "Password is too weak" }""") + + val result = repository().register("a@b.com", "abc", "123", null) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("Password is too weak") + assertThat(session.isLoggedIn).isFalse() + } + + // ---- forgot password --------------------------------------------------- + + @Test + fun `forgotPassword success posts the email`() = runTest(dispatcher) { + enqueue(201) + + val result = repository().forgotPassword("me@example.com") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.path).contains("api/auth/forgot-password") + assertThat(request.body.readUtf8()).contains("\"email\":\"me@example.com\"") + } + + // ---- reset password ---------------------------------------------------- + + @Test + fun `resetPassword success posts the token and new password`() = runTest(dispatcher) { + enqueue(201) + + val result = repository().resetPassword(token = "reset-tok", newPassword = "brandN3w!") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"token\":\"reset-tok\"") + assertThat(body).contains("\"password\":\"brandN3w!\"") + } + + @Test + fun `resetPassword invalid token maps to a failure`() = runTest(dispatcher) { + enqueue(400, """{ "error": "Invalid or expired token" }""") + + val result = repository().resetPassword(token = "bad", newPassword = "brandN3w!") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("Invalid or expired token") + } + + // ---- verify email / resend -------------------------------------------- + + @Test + fun `verifyEmail success posts the token`() = runTest(dispatcher) { + enqueue(201) + + val result = repository().verifyEmail("verify-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.path).contains("api/auth/verify-email") + assertThat(request.body.readUtf8()).contains("\"token\":\"verify-tok\"") + } + + @Test + fun `verifyEmail expired token maps to a failure`() = runTest(dispatcher) { + enqueue(400, """{ "error": "Verification link has expired" }""") + + val result = repository().verifyEmail("stale") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("Verification link has expired") + } + + @Test + fun `resendVerificationEmail success hits the send endpoint`() = runTest(dispatcher) { + enqueue(201) + + val result = repository().resendVerificationEmail() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.takeRequest().path).contains("api/auth/send-verification-email") + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/TestDoubles.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/TestDoubles.kt new file mode 100644 index 0000000..016cf0b --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/TestDoubles.kt @@ -0,0 +1,93 @@ +package com.interlinedlist.android.feature.auth.data + +import android.content.SharedPreferences +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.database.dao.UserDao +import com.interlinedlist.android.core.database.entity.CachedUserEntity +import com.interlinedlist.android.core.datastore.SessionStore +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** DispatcherProvider that runs everything on the supplied test dispatcher. */ +class TestDispatcherProvider(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher +} + +/** Records what the repository writes so tests can assert cache/session effects. */ +class FakeUserDao : UserDao { + val stored = MutableStateFlow(null) + var cleared = false + + override fun observeUser(id: String): Flow = stored + override suspend fun upsert(user: CachedUserEntity) { + stored.value = user + } + + override suspend fun clear() { + cleared = true + stored.value = null + } +} + +/** Builds a [SessionStore] backed by in-memory prefs so tests need no Android runtime. */ +fun fakeSessionStore(): SessionStore = SessionStore(InMemorySharedPreferences()) + +/** Minimal in-memory [SharedPreferences] covering the getString/putString/clear path. */ +private class InMemorySharedPreferences : SharedPreferences { + private val values = mutableMapOf() + + override fun getString(key: String?, defValue: String?): String? = + (values[key] as? String) ?: defValue + + override fun contains(key: String?): Boolean = values.containsKey(key) + override fun getAll(): MutableMap = values + override fun getInt(key: String?, defValue: Int): Int = (values[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (values[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (values[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = + (values[key] as? Boolean) ?: defValue + + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: MutableSet?): MutableSet? = + (values[key] as? MutableSet) ?: defValues + + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit + + override fun edit(): SharedPreferences.Editor = Editor() + + private inner class Editor : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clear = false + + override fun putString(key: String, value: String?): SharedPreferences.Editor = + apply { pending[key] = value } + override fun putStringSet(key: String, values: MutableSet?): SharedPreferences.Editor = + apply { pending[key] = values } + override fun putInt(key: String, value: Int): SharedPreferences.Editor = apply { pending[key] = value } + override fun putLong(key: String, value: Long): SharedPreferences.Editor = apply { pending[key] = value } + override fun putFloat(key: String, value: Float): SharedPreferences.Editor = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean): SharedPreferences.Editor = apply { pending[key] = value } + override fun remove(key: String): SharedPreferences.Editor = apply { pending[key] = REMOVED } + override fun clear(): SharedPreferences.Editor = apply { clear = true } + + override fun commit(): Boolean { + apply() + return true + } + + override fun apply() { + if (clear) values.clear() + pending.forEach { (k, v) -> if (v === REMOVED) values.remove(k) else values[k] = v } + pending.clear() + clear = false + } + } + + companion object { + private val REMOVED = Any() + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt new file mode 100644 index 0000000..649a62a --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt @@ -0,0 +1,85 @@ +package com.interlinedlist.android.feature.auth.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.core.model.User +import com.interlinedlist.android.feature.auth.data.AuthRepository + +/** Sample verified user handy across the auth ViewModel tests. */ +val sampleUser = User( + id = "1", username = "messenger", displayName = "Messenger", + email = null, avatarUrl = null, bio = null, + emailVerified = true, customerStatus = CustomerStatus.SUBSCRIBER, +) + +/** + * Programmable [AuthRepository] test double. Each operation returns its + * configured result and records that it was invoked (with the arguments the + * ViewModel forwarded), so tests can assert both navigation and the request. + */ +class FakeAuthRepository( + var loginResult: ApiResult = ApiResult.Success(sampleUser), + var registerResult: ApiResult = ApiResult.Success(sampleUser), + var forgotResult: ApiResult = ApiResult.Success(Unit), + var resetResult: ApiResult = ApiResult.Success(Unit), + var verifyResult: ApiResult = ApiResult.Success(Unit), + var resendResult: ApiResult = ApiResult.Success(Unit), +) : AuthRepository { + + var loginCount = 0 + var registerCount = 0 + var lastRegister: RegisterArgs? = null + var lastForgotEmail: String? = null + var lastReset: ResetArgs? = null + var lastVerifyToken: String? = null + var resendCount = 0 + + data class RegisterArgs( + val email: String, + val username: String, + val password: String, + val displayName: String?, + ) + + data class ResetArgs(val token: String, val newPassword: String) + + override fun isLoggedIn(): Boolean = false + + override suspend fun login(email: String, password: String): ApiResult { + loginCount++ + return loginResult + } + + override suspend fun register( + email: String, + username: String, + password: String, + displayName: String?, + ): ApiResult { + registerCount++ + lastRegister = RegisterArgs(email, username, password, displayName) + return registerResult + } + + override suspend fun forgotPassword(email: String): ApiResult { + lastForgotEmail = email + return forgotResult + } + + override suspend fun resetPassword(token: String, newPassword: String): ApiResult { + lastReset = ResetArgs(token, newPassword) + return resetResult + } + + override suspend fun verifyEmail(token: String): ApiResult { + lastVerifyToken = token + return verifyResult + } + + override suspend fun resendVerificationEmail(): ApiResult { + resendCount++ + return resendResult + } + + override suspend fun logout() = Unit +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModelTest.kt new file mode 100644 index 0000000..277aa50 --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ForgotPasswordViewModelTest.kt @@ -0,0 +1,66 @@ +package com.interlinedlist.android.feature.auth.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ForgotPasswordViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `submit success shows the check-your-email confirmation`() = runTest(dispatcher) { + val repo = FakeAuthRepository(forgotResult = ApiResult.Success(Unit)) + val vm = ForgotPasswordViewModel(repo) + vm.onEmailChange(" me@example.com ") + + vm.submit() + advanceUntilIdle() + + assertThat(vm.uiState.value.emailSent).isTrue() + assertThat(vm.uiState.value.errorMessage).isNull() + assertThat(repo.lastForgotEmail).isEqualTo("me@example.com") + } + + @Test + fun `blank email blocks submit`() = runTest(dispatcher) { + val repo = FakeAuthRepository() + val vm = ForgotPasswordViewModel(repo) + + assertThat(vm.uiState.value.canSubmit).isFalse() + + vm.submit() + advanceUntilIdle() + + assertThat(repo.lastForgotEmail).isNull() + } + + @Test + fun `failure surfaces a mapped error and stays on the form`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + forgotResult = ApiResult.Failure(AppError.RateLimited(null)), + ) + val vm = ForgotPasswordViewModel(repo) + vm.onEmailChange("me@example.com") + + vm.submit() + advanceUntilIdle() + + assertThat(vm.uiState.value.emailSent).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/LoginViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/LoginViewModelTest.kt index 6f57dc0..6692818 100644 --- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/LoginViewModelTest.kt +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/LoginViewModelTest.kt @@ -3,9 +3,6 @@ package com.interlinedlist.android.feature.auth.ui import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError -import com.interlinedlist.android.core.model.CustomerStatus -import com.interlinedlist.android.core.model.User -import com.interlinedlist.android.feature.auth.data.AuthRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,29 +19,13 @@ class LoginViewModelTest { private val dispatcher = StandardTestDispatcher() - private class FakeAuthRepository(var result: ApiResult) : AuthRepository { - var loginCount = 0 - override fun isLoggedIn() = false - override suspend fun login(email: String, password: String): ApiResult { - loginCount++ - return result - } - override suspend fun logout() = Unit - } - - private val sampleUser = User( - id = "1", username = "messenger", displayName = "Messenger", - email = null, avatarUrl = null, bio = null, - emailVerified = true, customerStatus = CustomerStatus.SUBSCRIBER, - ) - @Before fun setUp() = Dispatchers.setMain(dispatcher) @After fun tearDown() = Dispatchers.resetMain() @Test fun `blank credentials show validation error and skip the repository`() = runTest(dispatcher) { - val repo = FakeAuthRepository(ApiResult.Success(sampleUser)) + val repo = FakeAuthRepository(loginResult = ApiResult.Success(sampleUser)) val vm = LoginViewModel(repo) var succeeded = false @@ -58,7 +39,7 @@ class LoginViewModelTest { @Test fun `successful login invokes onSuccess and clears loading`() = runTest(dispatcher) { - val repo = FakeAuthRepository(ApiResult.Success(sampleUser)) + val repo = FakeAuthRepository(loginResult = ApiResult.Success(sampleUser)) val vm = LoginViewModel(repo) vm.onEmailChange("you@example.com") vm.onPasswordChange("secret") @@ -75,7 +56,7 @@ class LoginViewModelTest { @Test fun `failed login surfaces a mapped error and does not navigate`() = runTest(dispatcher) { - val repo = FakeAuthRepository(ApiResult.Failure(AppError.Unauthorized("Invalid credentials"))) + val repo = FakeAuthRepository(loginResult = ApiResult.Failure(AppError.Unauthorized("Invalid credentials"))) val vm = LoginViewModel(repo) vm.onEmailChange("you@example.com") vm.onPasswordChange("wrong") diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModelTest.kt new file mode 100644 index 0000000..2c4fe8d --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/RegisterViewModelTest.kt @@ -0,0 +1,113 @@ +package com.interlinedlist.android.feature.auth.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.core.model.User +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RegisterViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private fun filledVm(repo: FakeAuthRepository): RegisterViewModel = + RegisterViewModel(repo).apply { + onDisplayNameChange("New Bie") + onUsernameChange("newbie") + onEmailChange("new@example.com") + onPasswordChange("s3cret!!") + onConfirmPasswordChange("s3cret!!") + } + + @Test + fun `mismatched passwords block submit and show a field error`() = runTest(dispatcher) { + val repo = FakeAuthRepository() + val vm = filledVm(repo) + vm.onConfirmPasswordChange("different") + + assertThat(vm.uiState.value.canSubmit).isFalse() + assertThat(vm.uiState.value.passwordsMatch).isFalse() + + vm.register(onRegistered = {}) + advanceUntilIdle() + + assertThat(repo.registerCount).isEqualTo(0) + } + + @Test + fun `blank required fields block submit`() = runTest(dispatcher) { + val vm = RegisterViewModel(FakeAuthRepository()) + vm.onEmailChange("only@example.com") + + assertThat(vm.uiState.value.canSubmit).isFalse() + } + + @Test + fun `successful register forwards the trimmed fields and navigates`() = runTest(dispatcher) { + val repo = FakeAuthRepository(registerResult = ApiResult.Success(sampleUser)) + val vm = filledVm(repo) + vm.onEmailChange(" new@example.com ") + + var registered = false + vm.register(onRegistered = { registered = true }) + advanceUntilIdle() + + assertThat(registered).isTrue() + assertThat(repo.registerCount).isEqualTo(1) + assertThat(repo.lastRegister).isEqualTo( + FakeAuthRepository.RegisterArgs( + email = "new@example.com", + username = "newbie", + password = "s3cret!!", + displayName = "New Bie", + ), + ) + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `unverified user surfaces the email hint after registering`() = runTest(dispatcher) { + val unverified = User( + id = "9", username = "newbie", displayName = null, email = "new@example.com", + avatarUrl = null, bio = null, emailVerified = false, + customerStatus = CustomerStatus.FREE, + ) + val repo = FakeAuthRepository(registerResult = ApiResult.Success(unverified)) + val vm = filledVm(repo) + + vm.register(onRegistered = {}) + advanceUntilIdle() + + assertThat(vm.uiState.value.showVerifyEmailHint).isTrue() + } + + @Test + fun `email already taken maps to an error and does not navigate`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + registerResult = ApiResult.Failure(AppError.Conflict("Email already in use")), + ) + val vm = filledVm(repo) + + var registered = false + vm.register(onRegistered = { registered = true }) + advanceUntilIdle() + + assertThat(registered).isFalse() + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.errorMessage).isEqualTo("Email already in use") + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModelTest.kt new file mode 100644 index 0000000..4132017 --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/ResetPasswordViewModelTest.kt @@ -0,0 +1,79 @@ +package com.interlinedlist.android.feature.auth.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ResetPasswordViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `token from the deep link seeds the state`() = runTest(dispatcher) { + val vm = ResetPasswordViewModel(FakeAuthRepository(), token = "deep-link-token") + assertThat(vm.uiState.value.token).isEqualTo("deep-link-token") + } + + @Test + fun `mismatched passwords block submit`() = runTest(dispatcher) { + val repo = FakeAuthRepository() + val vm = ResetPasswordViewModel(repo, token = "t") + vm.onPasswordChange("brandN3w!") + vm.onConfirmPasswordChange("nope") + + assertThat(vm.uiState.value.canSubmit).isFalse() + + vm.submit(onReset = {}) + advanceUntilIdle() + + assertThat(repo.lastReset).isNull() + } + + @Test + fun `success forwards token and new password then navigates back to login`() = runTest(dispatcher) { + val repo = FakeAuthRepository(resetResult = ApiResult.Success(Unit)) + val vm = ResetPasswordViewModel(repo, token = "reset-tok") + vm.onPasswordChange("brandN3w!") + vm.onConfirmPasswordChange("brandN3w!") + + var reset = false + vm.submit(onReset = { reset = true }) + advanceUntilIdle() + + assertThat(reset).isTrue() + assertThat(repo.lastReset).isEqualTo( + FakeAuthRepository.ResetArgs(token = "reset-tok", newPassword = "brandN3w!"), + ) + } + + @Test + fun `invalid token maps to an error and stays on the form`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + resetResult = ApiResult.Failure(AppError.Unknown("Invalid or expired token")), + ) + val vm = ResetPasswordViewModel(repo, token = "bad") + vm.onPasswordChange("brandN3w!") + vm.onConfirmPasswordChange("brandN3w!") + + var reset = false + vm.submit(onReset = { reset = true }) + advanceUntilIdle() + + assertThat(reset).isFalse() + assertThat(vm.uiState.value.errorMessage).isEqualTo("Invalid or expired token") + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModelTest.kt new file mode 100644 index 0000000..b894a06 --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/VerifyEmailViewModelTest.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.feature.auth.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class VerifyEmailViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `a deep-link token verifies automatically and reports success`() = runTest(dispatcher) { + val repo = FakeAuthRepository(verifyResult = ApiResult.Success(Unit)) + val vm = VerifyEmailViewModel(repo, token = "verify-tok") + advanceUntilIdle() + + assertThat(repo.lastVerifyToken).isEqualTo("verify-tok") + assertThat(vm.uiState.value.status).isEqualTo(VerifyEmailStatus.VERIFIED) + } + + @Test + fun `an invalid deep-link token reports failure`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + verifyResult = ApiResult.Failure(AppError.Unknown("Verification link has expired")), + ) + val vm = VerifyEmailViewModel(repo, token = "stale") + advanceUntilIdle() + + assertThat(vm.uiState.value.status).isEqualTo(VerifyEmailStatus.FAILED) + assertThat(vm.uiState.value.message).isEqualTo("Verification link has expired") + } + + @Test + fun `with no token the screen only offers a resend action`() = runTest(dispatcher) { + val repo = FakeAuthRepository() + val vm = VerifyEmailViewModel(repo, token = null) + advanceUntilIdle() + + assertThat(repo.lastVerifyToken).isNull() + assertThat(vm.uiState.value.status).isEqualTo(VerifyEmailStatus.IDLE) + } + + @Test + fun `resend requests a fresh verification email`() = runTest(dispatcher) { + val repo = FakeAuthRepository(resendResult = ApiResult.Success(Unit)) + val vm = VerifyEmailViewModel(repo, token = null) + + vm.resend() + advanceUntilIdle() + + assertThat(repo.resendCount).isEqualTo(1) + assertThat(vm.uiState.value.resendConfirmed).isTrue() + } + + @Test + fun `resend failure surfaces a mapped error`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + resendResult = ApiResult.Failure(AppError.Network(null)), + ) + val vm = VerifyEmailViewModel(repo, token = null) + + vm.resend() + advanceUntilIdle() + + assertThat(vm.uiState.value.resendConfirmed).isFalse() + assertThat(vm.uiState.value.message).isNotNull() + } +} From ea0db68affcc625efd0fc88397813f223e386194 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 15:56:05 -0700 Subject: [PATCH 16/25] feat(lists): completeness polish (Milestone M, lists slice) Edit list metadata (PUT /api/lists/{id}), folder rename/move/delete (PUT/DELETE /api/folders/{id}), contributors (GET /api/lists/{id}/contributors, shown in watchers), and single-row fetch (GET /api/lists/{id}/data/{rowId}). 111 lists unit tests green. Documents slice (create-in-folder, templates seed-defaults) follows after Milestone G. Nav wiring deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../lists/ui/detail/ListMetadataEditorTest.kt | 65 ++++ .../ui/folders/FolderBrowserScreenTest.kt | 89 +++++ .../feature/lists/data/ContributorMapper.kt | 21 ++ .../lists/data/DefaultListsRepository.kt | 67 ++++ .../feature/lists/data/ListsRepository.kt | 33 ++ .../feature/lists/data/remote/ListsApi.kt | 33 ++ .../lists/data/remote/dto/ContributorDtos.kt | 37 ++ .../lists/data/remote/dto/FolderDtos.kt | 23 ++ .../feature/lists/domain/Contributor.kt | 19 + .../lists/ui/detail/ListDetailScreen.kt | 37 +- .../lists/ui/detail/ListDetailViewModel.kt | 62 ++++ .../lists/ui/detail/ListMetadataEditor.kt | 118 +++++++ .../lists/ui/folders/FolderBrowserScreen.kt | 325 ++++++++++++++++++ .../ui/folders/FolderBrowserViewModel.kt | 124 +++++++ .../lists/ui/watchers/WatchersScreen.kt | 53 +++ .../lists/ui/watchers/WatchersViewModel.kt | 7 + .../feature/lists/FakeListsRepository.kt | 73 +++- .../data/DefaultListsRepositoryPolishTest.kt | 280 +++++++++++++++ .../ui/detail/ListDetailViewModelTest.kt | 62 ++++ .../ui/folders/FolderBrowserViewModelTest.kt | 131 +++++++ .../ui/watchers/WatchersViewModelTest.kt | 16 + 21 files changed, 1672 insertions(+), 3 deletions(-) create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditorTest.kt create mode 100644 feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreenTest.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ContributorMapper.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ContributorDtos.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Contributor.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditor.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreen.kt create mode 100644 feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModel.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt create mode 100644 feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModelTest.kt diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditorTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditorTest.kt new file mode 100644 index 0000000..0e5a1f6 --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditorTest.kt @@ -0,0 +1,65 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListSummary +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies the edit-list metadata form seeds from the current summary, renders its + * fields, and hands the edited values (title, description, visibility) back on save. + */ +@RunWith(AndroidJUnit4::class) +class ListMetadataEditorTest { + + @get:Rule + val composeRule = createComposeRule() + + private val summary = ListSummary("L1", "Reading", "Books", 3, null, isPublic = false, updatedAt = null) + + @Test + fun rendersSeededFieldsAndActions() { + composeRule.setContent { + InterlinedListTheme { + ListMetadataEditor(summary = summary, isSaving = false, onSave = { _, _, _ -> }, onCancel = {}) + } + } + + composeRule.onNodeWithTag(ListMetadataEditorTestTags.TITLE).assertIsDisplayed() + composeRule.onNodeWithTag(ListMetadataEditorTestTags.DESCRIPTION).assertIsDisplayed() + composeRule.onNodeWithTag(ListMetadataEditorTestTags.VISIBILITY).assertIsDisplayed() + composeRule.onNodeWithTag(ListMetadataEditorTestTags.SAVE).assertIsDisplayed() + } + + @Test + fun editsAndSubmitsTheNewValues() { + var saved: Triple? = null + composeRule.setContent { + InterlinedListTheme { + ListMetadataEditor( + summary = summary, + isSaving = false, + onSave = { title, description, isPublic -> saved = Triple(title, description, isPublic) }, + onCancel = {}, + ) + } + } + + composeRule.onNodeWithTag(ListMetadataEditorTestTags.TITLE).performTextClearance() + composeRule.onNodeWithTag(ListMetadataEditorTestTags.TITLE).performTextInput("Reading v2") + composeRule.onNodeWithTag(ListMetadataEditorTestTags.VISIBILITY).performClick() + composeRule.onNodeWithTag(ListMetadataEditorTestTags.SAVE).performClick() + + assert(saved != null) { "onSave was not invoked" } + assert(saved!!.first == "Reading v2") { "Expected new title, got ${saved!!.first}" } + assert(saved!!.third) { "Expected visibility toggled to public" } + } +} diff --git a/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreenTest.kt b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreenTest.kt new file mode 100644 index 0000000..90aa48f --- /dev/null +++ b/feature/lists/src/androidTest/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreenTest.kt @@ -0,0 +1,89 @@ +package com.interlinedlist.android.feature.lists.ui.folders + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListFolder +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Verifies the folder browser renders folders and that the rename dialog opens from + * a folder's overflow menu, accepts a new name, and submits it to the caller. + */ +@RunWith(AndroidJUnit4::class) +class FolderBrowserScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private val folders = listOf( + ListFolder("f1", "Work", null), + ListFolder("f2", "Personal", null), + ) + + private fun setScreen( + onRename: (ListFolder, String) -> Unit = { _, _ -> }, + onDelete: (ListFolder) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + FolderBrowserScreen( + state = FolderBrowserUiState(folders = folders, isLoading = false), + onBack = {}, + onRename = onRename, + onMove = { _, _ -> }, + onDelete = onDelete, + ) + } + } + } + + @Test + fun rendersFolders() { + setScreen() + + composeRule.onNodeWithTag(FolderBrowserTestTags.folder("f1")).assertIsDisplayed() + composeRule.onNodeWithTag(FolderBrowserTestTags.folder("f2")).assertIsDisplayed() + composeRule.onNodeWithText("Work").assertIsDisplayed() + } + + @Test + fun renameDialog_opensEditsAndSubmits() { + var renamedTo: Pair? = null + setScreen(onRename = { folder, name -> renamedTo = folder.id to name }) + + // Open the row's overflow, then the rename action. + composeRule.onNodeWithTag(FolderBrowserTestTags.overflow("f1")).performClick() + composeRule.onNodeWithText("Rename").performClick() + + // The rename dialog is shown with the field seeded from the folder name. + composeRule.onNodeWithTag(FolderBrowserTestTags.RENAME_DIALOG).assertIsDisplayed() + composeRule.onNodeWithTag(FolderBrowserTestTags.RENAME_FIELD).performTextClearance() + composeRule.onNodeWithTag(FolderBrowserTestTags.RENAME_FIELD).performTextInput("Archive") + composeRule.onNodeWithTag(FolderBrowserTestTags.RENAME_CONFIRM).performClick() + + assert(renamedTo == "f1" to "Archive") { "Expected rename f1 -> Archive, got $renamedTo" } + } + + @Test + fun deleteDialog_confirmsDeletion() { + var deletedId: String? = null + setScreen(onDelete = { deletedId = it.id }) + + composeRule.onNodeWithTag(FolderBrowserTestTags.overflow("f2")).performClick() + composeRule.onNodeWithText("Delete").performClick() + + composeRule.onNodeWithTag(FolderBrowserTestTags.DELETE_DIALOG).assertIsDisplayed() + composeRule.onNodeWithTag(FolderBrowserTestTags.DELETE_CONFIRM).performClick() + + assert(deletedId == "f2") { "Expected delete f2, got $deletedId" } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ContributorMapper.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ContributorMapper.kt new file mode 100644 index 0000000..5c66ef8 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ContributorMapper.kt @@ -0,0 +1,21 @@ +package com.interlinedlist.android.feature.lists.data + +import com.interlinedlist.android.feature.lists.data.remote.dto.ContributorDto +import com.interlinedlist.android.feature.lists.domain.Contributor + +/** + * DTO → domain mapping for list contributors. The avatar arrives as `avatar` on the + * wire (not `avatarUrl`); the username falls back to the id so a row is never blank. + */ +object ContributorMapper { + + fun fromDto(dto: ContributorDto): Contributor = Contributor( + userId = dto.id, + username = dto.username.ifBlank { dto.id }, + displayName = dto.displayName, + avatarUrl = dto.avatar, + addedCount = dto.addedCount, + editedCount = dto.editedCount, + score = dto.score, + ) +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt index 4945300..6cbb302 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepository.kt @@ -14,8 +14,11 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.CreateShareLinkR import com.interlinedlist.android.feature.lists.data.remote.dto.ListDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowDto import com.interlinedlist.android.feature.lists.data.remote.dto.RowWriteRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateFolderRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest +import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder @@ -108,6 +111,35 @@ class DefaultListsRepository @Inject constructor( } } + override suspend fun updateList( + id: String, + title: String?, + description: String?, + isPublic: Boolean?, + folderId: String?, + ): ApiResult = withContext(dispatchers.io) { + val body = UpdateListRequest( + title = title, + description = description, + isPublic = isPublic, + folderId = folderId, + ) + when (val result = safeApiCall(json) { api.updateList(id, body) }) { + is ApiResult.Success -> { + val dto = result.data.list ?: result.data.data + ?: return@withContext ApiResult.Failure( + com.interlinedlist.android.core.common.result.AppError.Unknown( + "List update returned no list", + ), + ) + val summary = ListMapper.summaryFromDto(dto) + listDao.upsert(ListMapper.summaryToEntity(summary)) + ApiResult.Success(summary) + } + is ApiResult.Failure -> result + } + } + override suspend fun deleteList(id: String): ApiResult = withContext(dispatchers.io) { when (val result = safeApiCall(json) { api.deleteList(id) }) { is ApiResult.Success -> { @@ -149,6 +181,13 @@ class DefaultListsRepository @Inject constructor( ApiResult.Success(ListDetail(summary = summary, schema = schema, rows = rows)) } + override suspend fun getRow(listId: String, rowId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.getRow(listId, rowId) } + .map { it.row ?: it.data ?: RowDto(id = rowId) } + .map(RowMapper::fromDto) + } + override suspend fun addRow(listId: String, values: Map): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.createRow(listId, RowWriteRequest(values.toJsonData())) } @@ -182,6 +221,34 @@ class DefaultListsRepository @Inject constructor( .map(ListMapper::folderFromDto) } + override suspend fun updateFolder( + id: String, + name: String?, + parentId: String?, + ): ApiResult = withContext(dispatchers.io) { + val body = UpdateFolderRequest(name = name?.takeIf { it.isNotBlank() }, parentId = parentId) + when (val result = safeApiCall(json) { api.updateFolder(id, body) }) { + is ApiResult.Success -> { + val dto = result.data.folderOrData + ?: return@withContext ApiResult.Success( + ListFolder(id = id, name = name.orEmpty(), parentId = parentId), + ) + ApiResult.Success(ListMapper.folderFromDto(dto)) + } + is ApiResult.Failure -> result + } + } + + override suspend fun deleteFolder(id: String): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { api.deleteFolder(id) }.map { } + } + + override suspend fun getContributors(listId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getContributors(listId) } + .map { response -> response.items.map(ContributorMapper::fromDto) } + } + override suspend fun updateSchema(listId: String, schema: ListSchema): ApiResult = withContext(dispatchers.io) { // The API expects the schema as a serialised DSL string; send the edited diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt index 29ec578..3ce1e28 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/ListsRepository.kt @@ -1,6 +1,7 @@ package com.interlinedlist.android.feature.lists.data import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder @@ -40,12 +41,28 @@ interface ListsRepository { /** Creates a list; caches the result and returns its summary. */ suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult + /** + * Updates a list's metadata (title/description/visibility/folder). Only the + * supplied fields change; the returned summary reflects the server's echo and + * the cache is updated to match. + */ + suspend fun updateList( + id: String, + title: String? = null, + description: String? = null, + isPublic: Boolean? = null, + folderId: String? = null, + ): ApiResult + /** Deletes a list and evicts it from the cache. */ suspend fun deleteList(id: String): ApiResult /** Loads a list's metadata, schema, and first page of rows for the detail screen. */ suspend fun getListDetail(id: String, rowLimit: Int = DEFAULT_PAGE_SIZE): ApiResult + /** Fetches a single data row by id (e.g. for a row-detail view). */ + suspend fun getRow(listId: String, rowId: String): ApiResult + suspend fun addRow(listId: String, values: Map): ApiResult suspend fun updateRow(listId: String, rowId: String, values: Map): ApiResult @@ -56,6 +73,22 @@ interface ListsRepository { suspend fun createFolder(name: String, parentId: String?): ApiResult + /** + * Renames and/or moves a folder. Pass only the fields to change; the returned + * folder reflects the server's echo. + */ + suspend fun updateFolder( + id: String, + name: String? = null, + parentId: String? = null, + ): ApiResult + + /** Soft-deletes a folder; its lists move to the root on the server. */ + suspend fun deleteFolder(id: String): ApiResult + + /** People who have contributed rows to a list, ranked (read-only). */ + suspend fun getContributors(listId: String): ApiResult> + /** Replaces a list's schema (add/edit/remove columns) and returns the parsed result. */ suspend fun updateSchema(listId: String, schema: ListSchema): ApiResult diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt index 9656a6a..44aa00d 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/ListsApi.kt @@ -3,10 +3,12 @@ package com.interlinedlist.android.feature.lists.data.remote import com.interlinedlist.android.feature.lists.data.remote.dto.AddWatcherRequest import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.ConnectionsResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.ContributorsResponse import com.interlinedlist.android.feature.lists.data.remote.dto.CreateConnectionRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.lists.data.remote.dto.CreateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.FolderDto +import com.interlinedlist.android.feature.lists.data.remote.dto.FolderEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.FoldersResponse import com.interlinedlist.android.feature.lists.data.remote.dto.ListEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.ListsResponse @@ -19,6 +21,8 @@ import com.interlinedlist.android.feature.lists.data.remote.dto.SchemaEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.ShareLinkEnvelope import com.interlinedlist.android.feature.lists.data.remote.dto.ShareLinksResponse import com.interlinedlist.android.feature.lists.data.remote.dto.SharedListResponse +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateFolderRequest +import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateListRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateSchemaRequest import com.interlinedlist.android.feature.lists.data.remote.dto.UpdateWatcherRoleRequest import com.interlinedlist.android.feature.lists.data.remote.dto.WatchingResponse @@ -60,9 +64,20 @@ interface ListsApi { @GET("api/lists/{id}") suspend fun getList(@Path("id") id: String): ListEnvelope + /** Updates a list's metadata (title/description/visibility/folder). */ + @PUT("api/lists/{id}") + suspend fun updateList( + @Path("id") id: String, + @Body body: UpdateListRequest, + ): ListEnvelope + @DELETE("api/lists/{id}") suspend fun deleteList(@Path("id") id: String) + /** People who have contributed rows to a list (read-only, unpaged). */ + @GET("api/lists/{id}/contributors") + suspend fun getContributors(@Path("id") id: String): ContributorsResponse + /** The schema DSL — shape is dynamic, so it is received as a raw element. */ @GET("api/lists/{id}/schema") suspend fun getSchema(@Path("id") id: String): JsonElement @@ -132,6 +147,13 @@ interface ListsApi { @Query("offset") offset: Int, ): RowsResponse + /** Fetches a single data row by id. */ + @GET("api/lists/{id}/data/{rowId}") + suspend fun getRow( + @Path("id") id: String, + @Path("rowId") rowId: String, + ): RowEnvelope + @POST("api/lists/{id}/data") suspend fun createRow( @Path("id") id: String, @@ -157,6 +179,17 @@ interface ListsApi { @POST("api/folders") suspend fun createFolder(@Body body: CreateFolderRequest): FolderDto + /** Renames and/or moves a folder. */ + @PUT("api/folders/{id}") + suspend fun updateFolder( + @Path("id") id: String, + @Body body: UpdateFolderRequest, + ): FolderEnvelope + + /** Soft-deletes a folder; its lists move to the root. */ + @DELETE("api/folders/{id}") + suspend fun deleteFolder(@Path("id") id: String) + // --- Sharing ----------------------------------------------------------- /** Existing public share links for a list. */ diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ContributorDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ContributorDtos.kt new file mode 100644 index 0000000..4d167b3 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/ContributorDtos.kt @@ -0,0 +1,37 @@ +package com.interlinedlist.android.feature.lists.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Wire models for `GET /api/lists/{id}/contributors`. Field names follow the + * InterlinedList REST contract (`ListContributor`); the shared Json ignores + * unknown keys, so only the fields the UI renders are declared. All optionals are + * defaulted so the shared `coerceInputValues` Json never fails on explicit nulls. + * + * Note the avatar arrives as `avatar` (not `avatarUrl`), verified against the web + * app's contributors handler. + */ +@Serializable +data class ContributorDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, + val avatar: String? = null, + val addedCount: Int = 0, + val editedCount: Int = 0, + val score: Int = 0, +) + +/** + * Envelope for `GET /api/lists/{id}/contributors`. Contributors arrive under + * `contributors` (verified against the web handler) with a `totalContributors` + * count; `data` is tolerated for forward-compatibility. + */ +@Serializable +data class ContributorsResponse( + val contributors: List? = null, + val data: List? = null, + val totalContributors: Int? = null, +) { + val items: List get() = contributors ?: data ?: emptyList() +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt index 464b066..31dee7e 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/data/remote/dto/FolderDtos.kt @@ -25,3 +25,26 @@ data class CreateFolderRequest( val name: String, val parentId: String? = null, ) + +/** + * Body for `PUT /api/folders/{id}` — partial rename/move. Both fields are optional + * so a rename need not resend the parent (and a move need not resend the name); the + * shared Json drops nulls so only the supplied fields reach the server. + */ +@Serializable +data class UpdateFolderRequest( + val name: String? = null, + val parentId: String? = null, +) + +/** + * Envelope for `PUT /api/folders/{id}`. The updated folder arrives under `folder` + * (verified against the web handler); `data` is tolerated for forward-compatibility. + */ +@Serializable +data class FolderEnvelope( + val folder: FolderDto? = null, + val data: FolderDto? = null, +) { + val folderOrData: FolderDto? get() = folder ?: data +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Contributor.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Contributor.kt new file mode 100644 index 0000000..ea3a3aa --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/domain/Contributor.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.lists.domain + +/** + * A person who has contributed rows to a list, ranked by contribution. [addedCount] + * and [editedCount] break down the work; [score] is their sum (the server's ranking + * key). This is read-only detail shown alongside a list's watchers. + */ +data class Contributor( + val userId: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, + val addedCount: Int, + val editedCount: Int, + val score: Int, +) { + /** Best label for the row: display name when present, else the username. */ + val label: String get() = displayName?.takeIf { it.isNotBlank() } ?: username +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt index e3820a9..6656ad1 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailScreen.kt @@ -69,6 +69,7 @@ object ListDetailTestTags { const val DELETE_LIST = "listDetailDeleteList" const val REFRESH = "listDetailRefresh" const val OVERFLOW = "listDetailOverflow" + const val EDIT_LIST = "listDetailEditList" const val EDIT_SCHEMA = "listDetailEditSchema" const val WATCHERS = "listDetailWatchers" const val SHARE = "listDetailShare" @@ -110,8 +111,13 @@ fun ListDetailRoute( state = state, onBack = onBack, onAddRow = { editing = EditorTarget.New }, - onEditRow = { editing = EditorTarget.Existing(it) }, + onEditRow = { + // Seed the editor from the freshest server copy of the row. + viewModel.loadRow(it.id) + editing = EditorTarget.Existing(it) + }, onDeleteRow = viewModel::deleteRow, + onEditList = viewModel::startEditingMetadata, onDeleteList = { viewModel.deleteList(onListDeleted) }, onRefresh = viewModel::refreshFromGithub, onEditSchema = onEditSchema, @@ -123,10 +129,14 @@ fun ListDetailRoute( val target = editing if (target != null) { + // Re-read the (possibly refreshed) row from state so single-row load is reflected. + val liveRow = (target as? EditorTarget.Existing)?.let { existing -> + state.rows.firstOrNull { it.id == existing.row.id } ?: existing.row + } ModalBottomSheet(onDismissRequest = { editing = null }, sheetState = sheetState) { RowEditor( schema = state.schema, - row = (target as? EditorTarget.Existing)?.row, + row = liveRow, isSaving = state.isSaving, onSave = { values -> when (target) { @@ -138,6 +148,23 @@ fun ListDetailRoute( ) } } + + val summary = state.summary + if (state.isEditingMetadata && summary != null) { + ModalBottomSheet( + onDismissRequest = viewModel::stopEditingMetadata, + sheetState = sheetState, + ) { + ListMetadataEditor( + summary = summary, + isSaving = state.isSaving, + onSave = { title, description, isPublic -> + viewModel.editMetadata(title, description, isPublic) + }, + onCancel = viewModel::stopEditingMetadata, + ) + } + } } private sealed interface EditorTarget { @@ -156,6 +183,7 @@ fun ListDetailScreen( onDeleteRow: (String) -> Unit, onDeleteList: () -> Unit, modifier: Modifier = Modifier, + onEditList: () -> Unit = {}, onRefresh: () -> Unit = {}, onEditSchema: () -> Unit = {}, onOpenWatchers: () -> Unit = {}, @@ -192,6 +220,11 @@ fun ListDetailScreen( modifier = Modifier.testTag(ListDetailTestTags.OVERFLOW), ) { Icon(Icons.Default.MoreVert, contentDescription = "More actions") } DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Edit list") }, + onClick = { menuOpen = false; onEditList() }, + modifier = Modifier.testTag(ListDetailTestTags.EDIT_LIST), + ) DropdownMenuItem( text = { Text("Edit columns") }, onClick = { menuOpen = false; onEditSchema() }, diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt index f2ae575..356f3f9 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModel.kt @@ -30,6 +30,7 @@ data class ListDetailUiState( val deleted: Boolean = false, val isRefreshing: Boolean = false, val refreshMessage: String? = null, + val isEditingMetadata: Boolean = false, ) { val title: String get() = summary?.title.orEmpty() val isEmpty: Boolean get() = rows.isEmpty() && !isLoading && errorMessage == null @@ -113,6 +114,23 @@ class ListDetailViewModel @Inject constructor( } } + /** + * Fetches the freshest copy of a single row from the server and merges it into + * state, so a row-detail/edit view always seeds from current server data rather + * than a possibly-stale cached page. Failures are silent — the cached row still + * shows and edits still work. + */ + fun loadRow(rowId: String) { + viewModelScope.launch { + when (val result = repository.getRow(listId, rowId)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(rows = state.rows.map { if (it.id == rowId) result.data else it }) + } + is ApiResult.Failure -> Unit + } + } + } + fun deleteRow(rowId: String) { viewModelScope.launch { when (val result = repository.deleteRow(listId, rowId)) { @@ -172,6 +190,50 @@ class ListDetailViewModel @Inject constructor( fun clearRefreshMessage() = _uiState.update { it.copy(refreshMessage = null) } + /** + * Renames / re-describes / toggles the visibility of the list. The summary is + * updated optimistically so the change shows instantly; a failure rolls it back + * to the previous summary and surfaces the error. + */ + fun editMetadata( + title: String, + description: String?, + isPublic: Boolean, + onDone: () -> Unit = {}, + ) { + val previous = _uiState.value.summary ?: return + val trimmedTitle = title.trim().ifBlank { previous.title } + val trimmedDescription = description?.trim()?.ifBlank { null } + val optimistic = previous.copy( + title = trimmedTitle, + description = trimmedDescription, + isPublic = isPublic, + ) + // Optimistic: reflect the edit immediately. + _uiState.update { it.copy(summary = optimistic, isSaving = true) } + viewModelScope.launch { + when (val result = repository.updateList( + id = listId, + title = trimmedTitle, + description = trimmedDescription, + isPublic = isPublic, + )) { + is ApiResult.Success -> { + _uiState.update { it.copy(summary = result.data, isSaving = false, isEditingMetadata = false) } + onDone() + } + is ApiResult.Failure -> _uiState.update { + // Rollback to the pre-edit summary. + it.copy(summary = previous, isSaving = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun startEditingMetadata() = _uiState.update { it.copy(isEditingMetadata = true) } + + fun stopEditingMetadata() = _uiState.update { it.copy(isEditingMetadata = false) } + fun deleteList(onDeleted: () -> Unit = {}) { viewModelScope.launch { when (val result = repository.deleteList(listId)) { diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditor.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditor.kt new file mode 100644 index 0000000..f547c92 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListMetadataEditor.kt @@ -0,0 +1,118 @@ +package com.interlinedlist.android.feature.lists.ui.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.lists.domain.ListSummary + +/** Stable test tags for the list metadata editor form. */ +object ListMetadataEditorTestTags { + const val TITLE = "listEditTitle" + const val DESCRIPTION = "listEditDescription" + const val VISIBILITY = "listEditVisibility" + const val SAVE = "listEditSave" + const val CANCEL = "listEditCancel" +} + +/** + * Edit form for a list's metadata: title, description, and public/private + * visibility. Seeds from the current [summary]; [onSave] hands back the edited + * values (title, description, isPublic) for the caller to persist. + */ +@Composable +fun ListMetadataEditor( + summary: ListSummary, + isSaving: Boolean, + onSave: (title: String, description: String?, isPublic: Boolean) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + var title by rememberSaveable(summary.id) { mutableStateOf(summary.title) } + var description by rememberSaveable(summary.id) { mutableStateOf(summary.description.orEmpty()) } + var isPublic by rememberSaveable(summary.id) { mutableStateOf(summary.isPublic) } + + Column( + modifier = modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Edit list", style = MaterialTheme.typography.titleLarge) + + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + isError = title.isBlank(), + modifier = Modifier + .fillMaxWidth() + .testTag(ListMetadataEditorTestTags.TITLE), + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + modifier = Modifier + .fillMaxWidth() + .testTag(ListMetadataEditorTestTags.DESCRIPTION), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text("Public", style = MaterialTheme.typography.bodyLarge) + Text( + text = if (isPublic) "Anyone with the link can view" else "Only you and watchers", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = isPublic, + onCheckedChange = { isPublic = it }, + modifier = Modifier.testTag(ListMetadataEditorTestTags.VISIBILITY), + ) + } + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + TextButton( + onClick = onCancel, + modifier = Modifier.testTag(ListMetadataEditorTestTags.CANCEL), + ) { Text("Cancel") } + Button( + onClick = { onSave(title, description.ifBlank { null }, isPublic) }, + enabled = !isSaving && title.isNotBlank(), + modifier = Modifier.testTag(ListMetadataEditorTestTags.SAVE), + ) { Text("Save") } + } + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreen.kt new file mode 100644 index 0000000..c5e74f4 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserScreen.kt @@ -0,0 +1,325 @@ +package com.interlinedlist.android.feature.lists.ui.folders + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Folder +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.ListFolder + +/** Stable test tags for the folder browser. */ +object FolderBrowserTestTags { + const val LIST = "folderList" + const val EMPTY = "folderEmpty" + const val PROGRESS = "folderProgress" + const val ERROR = "folderError" + const val RENAME_DIALOG = "folderRenameDialog" + const val RENAME_FIELD = "folderRenameField" + const val RENAME_CONFIRM = "folderRenameConfirm" + const val DELETE_DIALOG = "folderDeleteDialog" + const val DELETE_CONFIRM = "folderDeleteConfirm" + fun folder(id: String) = "folder_$id" + fun overflow(id: String) = "folderOverflow_$id" +} + +/** + * Hilt-wired entry for the folder browser. [onBack] pops navigation. Folders can be + * renamed, moved under another folder (or to the root), and deleted (with a confirm + * dialog) via each row's overflow menu. + */ +@Composable +fun FolderBrowserRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: FolderBrowserViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + FolderBrowserScreen( + state = state, + onBack = onBack, + onRename = viewModel::renameFolder, + onMove = viewModel::moveFolder, + onDelete = viewModel::deleteFolder, + modifier = modifier, + ) +} + +/** Stateless folder browser — list folders, rename/move/delete each. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FolderBrowserScreen( + state: FolderBrowserUiState, + onBack: () -> Unit, + onRename: (ListFolder, String) -> Unit, + onMove: (ListFolder, String?) -> Unit, + onDelete: (ListFolder) -> Unit, + modifier: Modifier = Modifier, +) { + var renaming by remember { mutableStateOf(null) } + var deleting by remember { mutableStateOf(null) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Folders") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(FolderBrowserTestTags.PROGRESS)) } + + state.isEmpty -> Box( + Modifier.fillMaxSize().padding(padding).testTag(FolderBrowserTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No folders yet", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "Create a folder to organise your lists.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + else -> Column(Modifier.padding(padding)) { + if (state.errorMessage != null) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .testTag(FolderBrowserTestTags.ERROR), + ) + } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .testTag(FolderBrowserTestTags.LIST), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.folders, key = { it.id }) { folder -> + FolderRow( + folder = folder, + others = state.folders.filterNot { it.id == folder.id }, + onRename = { renaming = folder }, + onMove = { onMove(folder, it) }, + onDelete = { deleting = folder }, + ) + } + } + } + } + } + + renaming?.let { folder -> + RenameFolderDialog( + folder = folder, + onConfirm = { newName -> + onRename(folder, newName) + renaming = null + }, + onDismiss = { renaming = null }, + ) + } + + deleting?.let { folder -> + DeleteFolderDialog( + folder = folder, + onConfirm = { + onDelete(folder) + deleting = null + }, + onDismiss = { deleting = null }, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun FolderRow( + folder: ListFolder, + others: List, + onRename: () -> Unit, + onMove: (String?) -> Unit, + onDelete: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(FolderBrowserTestTags.folder(folder.id)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.Folder, contentDescription = null) + Text( + text = folder.name.ifBlank { "Untitled folder" }, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) + IconButton( + onClick = { menuOpen = true }, + modifier = Modifier.testTag(FolderBrowserTestTags.overflow(folder.id)), + ) { Icon(Icons.Default.MoreVert, contentDescription = "Folder actions") } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Rename") }, + onClick = { menuOpen = false; onRename() }, + ) + if (folder.parentId != null) { + DropdownMenuItem( + text = { Text("Move to root") }, + onClick = { menuOpen = false; onMove(null) }, + ) + } + others.forEach { target -> + DropdownMenuItem( + text = { Text("Move to \"${target.name}\"") }, + onClick = { menuOpen = false; onMove(target.id) }, + ) + } + DropdownMenuItem( + text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + onClick = { menuOpen = false; onDelete() }, + ) + } + } + } +} + +@Composable +private fun RenameFolderDialog( + folder: ListFolder, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember(folder.id) { mutableStateOf(folder.name) } + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(FolderBrowserTestTags.RENAME_DIALOG), + title = { Text("Rename folder") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Name") }, + singleLine = true, + isError = name.isBlank(), + modifier = Modifier + .fillMaxWidth() + .testTag(FolderBrowserTestTags.RENAME_FIELD), + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(name) }, + enabled = name.isNotBlank(), + modifier = Modifier.testTag(FolderBrowserTestTags.RENAME_CONFIRM), + ) { Text("Save") } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable +private fun DeleteFolderDialog( + folder: ListFolder, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(FolderBrowserTestTags.DELETE_DIALOG), + title = { Text("Delete folder?") }, + text = { + Text("\"${folder.name}\" will be deleted. Its lists move to the root; they are not deleted.") + }, + confirmButton = { + TextButton( + onClick = onConfirm, + modifier = Modifier.testTag(FolderBrowserTestTags.DELETE_CONFIRM), + ) { Text("Delete", color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Preview(showBackground = true) +@Composable +private fun FolderBrowserScreenPreview() { + InterlinedListTheme { + FolderBrowserScreen( + state = FolderBrowserUiState( + folders = listOf( + ListFolder("f1", "Work", null), + ListFolder("f2", "Personal", null), + ), + isLoading = false, + ), + onBack = {}, + onRename = { _, _ -> }, + onMove = { _, _ -> }, + onDelete = {}, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModel.kt new file mode 100644 index 0000000..d9aa682 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModel.kt @@ -0,0 +1,124 @@ +package com.interlinedlist.android.feature.lists.ui.folders + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.ListFolder +import com.interlinedlist.android.feature.lists.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the folder browser: browse folders, plus rename/move/delete. */ +data class FolderBrowserUiState( + val folders: List = emptyList(), + val isLoading: Boolean = true, + val errorMessage: String? = null, +) { + val isEmpty: Boolean get() = folders.isEmpty() && !isLoading && errorMessage == null +} + +@HiltViewModel +class FolderBrowserViewModel @Inject constructor( + private val repository: ListsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(FolderBrowserUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getFolders()) { + is ApiResult.Success -> _uiState.update { it.copy(folders = result.data, isLoading = false) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun createFolder(name: String, parentId: String? = null) { + if (name.isBlank()) return + viewModelScope.launch { + when (val result = repository.createFolder(name.trim(), parentId)) { + is ApiResult.Success -> _uiState.update { it.copy(folders = it.folders + result.data) } + is ApiResult.Failure -> _uiState.update { it.copy(errorMessage = result.error.toUserMessage()) } + } + } + } + + /** + * Renames a folder. The label is updated optimistically so the change shows + * instantly; a failure rolls it back to the previous folders and surfaces the + * error. + */ + fun renameFolder(folder: ListFolder, newName: String) { + val trimmed = newName.trim() + if (trimmed.isBlank() || trimmed == folder.name) return + val previous = _uiState.value.folders + _uiState.update { state -> + state.copy(folders = state.folders.map { if (it.id == folder.id) it.copy(name = trimmed) else it }) + } + viewModelScope.launch { + when (val result = repository.updateFolder(folder.id, name = trimmed)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(folders = state.folders.map { if (it.id == folder.id) result.data else it }) + } + is ApiResult.Failure -> _uiState.update { + it.copy(folders = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** + * Moves a folder under [newParentId] (null = root). Optimistic with rollback on + * failure. + */ + fun moveFolder(folder: ListFolder, newParentId: String?) { + if (newParentId == folder.id) return // A folder cannot be its own parent. + val previous = _uiState.value.folders + _uiState.update { state -> + state.copy(folders = state.folders.map { if (it.id == folder.id) it.copy(parentId = newParentId) else it }) + } + viewModelScope.launch { + when (val result = repository.updateFolder(folder.id, parentId = newParentId)) { + is ApiResult.Success -> _uiState.update { state -> + state.copy(folders = state.folders.map { if (it.id == folder.id) result.data else it }) + } + is ApiResult.Failure -> _uiState.update { + it.copy(folders = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** + * Deletes a folder (confirmed by the UI). The row is removed optimistically and + * restored on failure. + */ + fun deleteFolder(folder: ListFolder) { + val previous = _uiState.value.folders + _uiState.update { state -> state.copy(folders = state.folders.filterNot { it.id == folder.id }) } + viewModelScope.launch { + when (val result = repository.deleteFolder(folder.id)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> _uiState.update { + it.copy(folders = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt index c0b2701..9fa2f71 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole @@ -51,9 +52,11 @@ object WatchersTestTags { const val EMPTY = "watchersEmpty" const val PROGRESS = "watchersProgress" const val ERROR = "watchersError" + const val CONTRIBUTORS = "watchersContributors" fun watcher(userId: String) = "watcher_$userId" fun remove(userId: String) = "watcherRemove_$userId" fun candidate(userId: String) = "watcherCandidate_$userId" + fun contributor(userId: String) = "contributor_$userId" } /** @@ -161,6 +164,21 @@ fun WatchersScreen( ) } } + + if (state.contributors.isNotEmpty()) { + item { + Text( + text = "Contributors", + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .padding(top = 8.dp) + .testTag(WatchersTestTags.CONTRIBUTORS), + ) + } + items(state.contributors, key = { "contributor-${it.userId}" }) { contributor -> + ContributorRow(contributor = contributor) + } + } } } } @@ -246,6 +264,41 @@ private fun CandidateRow(candidate: WatcherCandidate, onAdd: () -> Unit) { } } +@Composable +private fun ContributorRow(contributor: Contributor) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(WatchersTestTags.contributor(contributor.userId)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = contributor.label, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${contributor.username}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = "${contributor.addedCount} added · ${contributor.editedCount} edited", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun EmptyState() { Box( diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt index f7f2ae0..af84385 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole @@ -23,6 +24,7 @@ const val WATCHERS_LIST_ID_ARG = "listId" /** UI state for the watchers screen. */ data class WatchersUiState( val watchers: List = emptyList(), + val contributors: List = emptyList(), val isWatching: Boolean = false, val isLoading: Boolean = true, val errorMessage: String? = null, @@ -64,6 +66,11 @@ class WatchersViewModel @Inject constructor( is ApiResult.Success -> _uiState.update { it.copy(isWatching = status.data) } is ApiResult.Failure -> Unit } + // Contributors are read-only supplementary detail; a failure leaves them empty. + when (val contributors = repository.getContributors(listId)) { + is ApiResult.Success -> _uiState.update { it.copy(contributors = contributors.data) } + is ApiResult.Failure -> Unit + } } } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt index 67056c1..ddf2f13 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/FakeListsRepository.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.lists import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.lists.data.ListsRepository +import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.ListConnection import com.interlinedlist.android.feature.lists.domain.ListDetail import com.interlinedlist.android.feature.lists.domain.ListFolder @@ -35,12 +36,20 @@ class FakeListsRepository : ListsRepository { var loadMoreResult: ApiResult> = refreshResult var searchResult: ApiResult> = ApiResult.Success(emptyList()) var createResult: ApiResult? = null + var updateListResult: ApiResult? = null var deleteResult: ApiResult = ApiResult.Success(Unit) var detailResult: ApiResult? = null + var getRowResult: ApiResult? = null var addRowResult: ApiResult? = null var updateRowResult: ApiResult? = null var deleteRowResult: ApiResult = ApiResult.Success(Unit) + // Folder management + contributors. + var foldersResult: ApiResult> = ApiResult.Success(emptyList()) + var updateFolderResult: ApiResult? = null + var deleteFolderResult: ApiResult = ApiResult.Success(Unit) + var contributorsResult: ApiResult> = ApiResult.Success(emptyList()) + // Round-2 deferred features. var updateSchemaResult: ApiResult? = null var refreshGithubResult: ApiResult = @@ -65,6 +74,15 @@ class FakeListsRepository : ListsRepository { var refreshCount = 0 var loadMoreCount = 0 + var updateListCount = 0 + var updateFolderCount = 0 + var deleteFolderCount = 0 + var lastUpdatedTitle: String? = null + var lastUpdatedDescription: String? = null + var lastUpdatedIsPublic: Boolean? = null + var lastUpdatedFolderName: String? = null + var lastUpdatedFolderParentId: String? = null + var lastDeletedFolderId: String? = null var updateSchemaCount = 0 var refreshGithubCount = 0 var addWatcherCount = 0 @@ -100,6 +118,37 @@ class FakeListsRepository : ListsRepository { ListSummary("new", title, description, 0, null, isPublic, null), ) + override suspend fun updateList( + id: String, + title: String?, + description: String?, + isPublic: Boolean?, + folderId: String?, + ): ApiResult { + updateListCount++ + lastUpdatedTitle = title + lastUpdatedDescription = description + lastUpdatedIsPublic = isPublic + val result = updateListResult ?: run { + val current = cache.value.firstOrNull { it.id == id } + ApiResult.Success( + ListSummary( + id = id, + title = title ?: current?.title.orEmpty(), + description = description ?: current?.description, + itemCount = current?.itemCount ?: 0, + folderId = folderId ?: current?.folderId, + isPublic = isPublic ?: current?.isPublic ?: false, + updatedAt = current?.updatedAt, + ), + ) + } + (result as? ApiResult.Success)?.let { success -> + cache.value = cache.value.map { if (it.id == id) success.data else it } + } + return result + } + override suspend fun deleteList(id: String): ApiResult { if (deleteResult is ApiResult.Success) cache.value = cache.value.filterNot { it.id == id } return deleteResult @@ -114,6 +163,9 @@ class FakeListsRepository : ListsRepository { ), ) + override suspend fun getRow(listId: String, rowId: String): ApiResult = + getRowResult ?: ApiResult.Success(ListRow(rowId, emptyMap())) + override suspend fun addRow(listId: String, values: Map): ApiResult = addRowResult ?: ApiResult.Success(ListRow("row-new", values)) @@ -122,11 +174,30 @@ class FakeListsRepository : ListsRepository { override suspend fun deleteRow(listId: String, rowId: String): ApiResult = deleteRowResult - override suspend fun getFolders(): ApiResult> = ApiResult.Success(emptyList()) + override suspend fun getFolders(): ApiResult> = foldersResult override suspend fun createFolder(name: String, parentId: String?): ApiResult = ApiResult.Success(ListFolder("f", name, parentId)) + override suspend fun updateFolder( + id: String, + name: String?, + parentId: String?, + ): ApiResult { + updateFolderCount++ + lastUpdatedFolderName = name + lastUpdatedFolderParentId = parentId + return updateFolderResult ?: ApiResult.Success(ListFolder(id, name.orEmpty(), parentId)) + } + + override suspend fun deleteFolder(id: String): ApiResult { + deleteFolderCount++ + lastDeletedFolderId = id + return deleteFolderResult + } + + override suspend fun getContributors(listId: String): ApiResult> = contributorsResult + override suspend fun updateSchema(listId: String, schema: ListSchema): ApiResult { updateSchemaCount++ lastSchemaUpdate = schema diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt new file mode 100644 index 0000000..76ca859 --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/data/DefaultListsRepositoryPolishTest.kt @@ -0,0 +1,280 @@ +package com.interlinedlist.android.feature.lists.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.lists.data.local.CachedListEntity +import com.interlinedlist.android.feature.lists.data.local.ListDao +import com.interlinedlist.android.feature.lists.data.remote.ListsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * MockWebServer coverage for the Lists "completeness polish" endpoints: edit list + * metadata (`PUT /api/lists/{id}`), folder rename/move/delete + * (`PUT`/`DELETE /api/folders/{id}`), contributors parse + * (`GET /api/lists/{id}/contributors`), and single-row fetch + * (`GET /api/lists/{id}/data/{rowId}`). Confirmed request/response shapes against + * the live OpenAPI spec and the web app handlers; no live writes are performed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultListsRepositoryPolishTest { + + private lateinit var server: MockWebServer + private lateinit var api: ListsApi + private lateinit var dao: FakePolishDao + private lateinit var repository: DefaultListsRepository + + // Mirrors the app's shared Json (explicit nulls off, coerce defaults on). + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(ListsApi::class.java) + dao = FakePolishDao() + repository = DefaultListsRepository(api, dao, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + // --- Edit list metadata ----------------------------------------------- + + @Test + fun `updateList sends only the changed fields and caches the echoed summary`() = runTest(dispatcher) { + dao.upsert(CachedListEntity("L1", "Old", "Old desc", 3, null, false, null)) + // The web handler wraps the updated list under `data`. + server.enqueue( + MockResponse().setBody( + """ + { + "message": "List updated successfully", + "data": { "id": "L1", "title": "Reading", "description": "Books", "itemCount": 3, "isPublic": true } + } + """.trimIndent(), + ), + ) + + val result = repository.updateList("L1", title = "Reading", description = "Books", isPublic = true) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val summary = (result as ApiResult.Success).data + assertThat(summary.title).isEqualTo("Reading") + assertThat(summary.description).isEqualTo("Books") + assertThat(summary.isPublic).isTrue() + // Cache reflects the update (offline-first source of truth). + assertThat(dao.observeLists().first().single().title).isEqualTo("Reading") + + val request: RecordedRequest = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/lists/L1") + val body = request.body.readUtf8() + assertThat(body).contains("\"title\":\"Reading\"") + assertThat(body).contains("\"description\":\"Books\"") + assertThat(body).contains("\"isPublic\":true") + // Untouched fields (folderId) are dropped by the shared Json, not sent as null. + assertThat(body).doesNotContain("folderId") + } + + @Test + fun `updateList maps a 404 to NotFound`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404).setBody("""{ "error": "List not found" }"""), + ) + + val result = repository.updateList("gone", title = "X") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + // --- Folder rename / move / delete ------------------------------------ + + @Test + fun `updateFolder renames and parses the folder envelope`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "message": "Folder updated successfully", "folder": { "id": "f1", "name": "Archive", "parentId": null } }""", + ), + ) + + val result = repository.updateFolder("f1", name = "Archive") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val folder = (result as ApiResult.Success).data + assertThat(folder.id).isEqualTo("f1") + assertThat(folder.name).isEqualTo("Archive") + assertThat(folder.parentId).isNull() + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PUT") + assertThat(request.path).isEqualTo("/api/folders/f1") + val body = request.body.readUtf8() + assertThat(body).contains("\"name\":\"Archive\"") + // A pure rename does not resend the parent. + assertThat(body).doesNotContain("parentId") + } + + @Test + fun `updateFolder moves under a new parent`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "folder": { "id": "f2", "name": "Sub", "parentId": "p1" } }""", + ), + ) + + val result = repository.updateFolder("f2", parentId = "p1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.parentId).isEqualTo("p1") + + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"parentId\":\"p1\"") + assertThat(body).doesNotContain("\"name\"") + } + + @Test + fun `deleteFolder issues a DELETE and succeeds`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "message": "Folder deleted successfully" }""")) + + val result = repository.deleteFolder("f9") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("DELETE") + assertThat(request.path).isEqualTo("/api/folders/f9") + } + + // --- Contributors ------------------------------------------------------ + + @Test + fun `getContributors parses the ranked contributor list`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + { + "contributors": [ + { "id": "u1", "username": "ada", "displayName": "Ada Lovelace", "avatar": "https://a/1.png", + "addedCount": 5, "editedCount": 2, "score": 7 }, + { "id": "u2", "username": "grace", "displayName": null, "avatar": null, + "addedCount": 1, "editedCount": 0, "score": 1 } + ], + "totalContributors": 2 + } + """.trimIndent(), + ), + ) + + val result = repository.getContributors("L1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val contributors = (result as ApiResult.Success).data + assertThat(contributors.map { it.userId }).containsExactly("u1", "u2").inOrder() + assertThat(contributors[0].label).isEqualTo("Ada Lovelace") + assertThat(contributors[0].avatarUrl).isEqualTo("https://a/1.png") + assertThat(contributors[0].score).isEqualTo(7) + // Missing displayName falls back to the username for the label. + assertThat(contributors[1].label).isEqualTo("grace") + assertThat(contributors[1].avatarUrl).isNull() + + assertThat(server.takeRequest().path).isEqualTo("/api/lists/L1/contributors") + } + + @Test + fun `getContributors tolerates an empty contributor set`() = runTest(dispatcher) { + server.enqueue(MockResponse().setBody("""{ "contributors": [], "totalContributors": 0 }""")) + + val result = repository.getContributors("L1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEmpty() + } + + // --- Single-row fetch -------------------------------------------------- + + @Test + fun `getRow parses the wrapped single-row envelope`() = runTest(dispatcher) { + // The web handler wraps the row under `data`. + server.enqueue( + MockResponse().setBody( + """{ "data": { "id": "r1", "data": { "title": "Dune", "pages": 412 } } }""", + ), + ) + + val result = repository.getRow("L1", "r1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val row = (result as ApiResult.Success).data + assertThat(row.id).isEqualTo("r1") + assertThat(row.valueFor("title")).isEqualTo("Dune") + assertThat(row.valueFor("pages")).isEqualTo("412") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("GET") + assertThat(request.path).isEqualTo("/api/lists/L1/data/r1") + } + + @Test + fun `getRow maps a 404 to NotFound`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(404) + .setBody("""{ "error": "Row not found", "code": "not_found" }"""), + ) + + val result = repository.getRow("L1", "missing") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } +} + +/** In-memory [ListDao] backed by a StateFlow, for JVM repository tests. */ +private class FakePolishDao : ListDao { + private val state = MutableStateFlow>(emptyList()) + + override fun observeLists(): Flow> = state + + override suspend fun upsertAll(lists: List) { + val byId = state.value.associateBy { it.id }.toMutableMap() + lists.forEach { byId[it.id] = it } + state.value = byId.values.toList() + } + + override suspend fun upsert(list: CachedListEntity) = upsertAll(listOf(list)) + + override suspend fun deleteById(id: String) { + state.value = state.value.filterNot { it.id == id } + } + + override suspend fun clear() { + state.value = emptyList() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt index 724a994..14701ab 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/detail/ListDetailViewModelTest.kt @@ -111,6 +111,68 @@ class ListDetailViewModelTest { assertThat(vm.uiState.value.rows.map { it.id }).containsExactly("r2") } + @Test + fun `editMetadata optimistically updates the summary then confirms from the server`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(emptyList())) + updateListResult = ApiResult.Success( + ListSummary("L1", "Reading v2", "Updated", 0, null, isPublic = true, updatedAt = null), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + var done = false + vm.editMetadata(title = "Reading v2", description = "Updated", isPublic = true) { done = true } + advanceUntilIdle() + + assertThat(done).isTrue() + assertThat(repo.updateListCount).isEqualTo(1) + val state = vm.uiState.value + assertThat(state.summary?.title).isEqualTo("Reading v2") + assertThat(state.summary?.description).isEqualTo("Updated") + assertThat(state.summary?.isPublic).isTrue() + assertThat(state.isSaving).isFalse() + assertThat(state.isEditingMetadata).isFalse() + } + + @Test + fun `editMetadata rolls back the summary and surfaces the error on failure`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(emptyList())) + updateListResult = FakeListsRepository.subscriptionFailure() + } + val vm = viewModel(repo) + advanceUntilIdle() + val original = vm.uiState.value.summary + + vm.editMetadata(title = "Broken", description = "x", isPublic = true) + advanceUntilIdle() + + val state = vm.uiState.value + // Rolled back to the pre-edit summary. + assertThat(state.summary).isEqualTo(original) + assertThat(state.summary?.title).isEqualTo("Reading") + assertThat(state.summary?.isPublic).isFalse() + assertThat(state.errorMessage).isNotNull() + assertThat(state.isSaving).isFalse() + } + + @Test + fun `loadRow merges the freshest server copy into state`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + detailResult = ApiResult.Success(detail(listOf(ListRow("r1", mapOf("title" to "Stale"))))) + getRowResult = ApiResult.Success(ListRow("r1", mapOf("title" to "Fresh"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.loadRow("r1") + advanceUntilIdle() + + assertThat(vm.uiState.value.rows.single().valueFor("title")).isEqualTo("Fresh") + } + @Test fun `deleteList flags deleted and invokes callback`() = runTest(dispatcher) { val repo = FakeListsRepository().apply { detailResult = ApiResult.Success(detail(emptyList())) } diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModelTest.kt new file mode 100644 index 0000000..d80004e --- /dev/null +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/folders/FolderBrowserViewModelTest.kt @@ -0,0 +1,131 @@ +package com.interlinedlist.android.feature.lists.ui.folders + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.ListFolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class FolderBrowserViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private val folders = listOf( + ListFolder("f1", "Work", null), + ListFolder("f2", "Personal", null), + ) + + private fun repoWithFolders() = FakeListsRepository().apply { + foldersResult = ApiResult.Success(folders) + } + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads folders on init`() = runTest(dispatcher) { + val vm = FolderBrowserViewModel(repoWithFolders()) + advanceUntilIdle() + + val state = vm.uiState.value + assertThat(state.isLoading).isFalse() + assertThat(state.folders.map { it.id }).containsExactly("f1", "f2").inOrder() + } + + @Test + fun `renameFolder optimistically renames then confirms from the server`() = runTest(dispatcher) { + val repo = repoWithFolders().apply { + updateFolderResult = ApiResult.Success(ListFolder("f1", "Archive", null)) + } + val vm = FolderBrowserViewModel(repo) + advanceUntilIdle() + + vm.uiState.test { + assertThat(awaitItem().folders.first().name).isEqualTo("Work") + vm.renameFolder(folders[0], "Archive") + // Optimistic emission renames immediately. + assertThat(awaitItem().folders.first().name).isEqualTo("Archive") + advanceUntilIdle() + cancelAndIgnoreRemainingEvents() + } + + assertThat(repo.updateFolderCount).isEqualTo(1) + assertThat(repo.lastUpdatedFolderName).isEqualTo("Archive") + assertThat(vm.uiState.value.folders.first().name).isEqualTo("Archive") + } + + @Test + fun `renameFolder rolls back on failure`() = runTest(dispatcher) { + val repo = repoWithFolders().apply { + updateFolderResult = FakeListsRepository.subscriptionFailure() + } + val vm = FolderBrowserViewModel(repo) + advanceUntilIdle() + + vm.renameFolder(folders[0], "Archive") + advanceUntilIdle() + + // Rolled back to the original name. + assertThat(vm.uiState.value.folders.first().name).isEqualTo("Work") + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `deleteFolder optimistically removes then stays removed on success`() = runTest(dispatcher) { + val repo = repoWithFolders() + val vm = FolderBrowserViewModel(repo) + advanceUntilIdle() + + vm.deleteFolder(folders[0]) + advanceUntilIdle() + + assertThat(repo.deleteFolderCount).isEqualTo(1) + assertThat(repo.lastDeletedFolderId).isEqualTo("f1") + assertThat(vm.uiState.value.folders.map { it.id }).containsExactly("f2") + } + + @Test + fun `deleteFolder restores the row on failure`() = runTest(dispatcher) { + val repo = repoWithFolders().apply { + deleteFolderResult = FakeListsRepository.subscriptionFailure() + } + val vm = FolderBrowserViewModel(repo) + advanceUntilIdle() + + vm.deleteFolder(folders[0]) + advanceUntilIdle() + + // Restored after the failed delete. + assertThat(vm.uiState.value.folders.map { it.id }).containsExactly("f1", "f2").inOrder() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `moveFolder updates the parent optimistically`() = runTest(dispatcher) { + val nested = listOf(ListFolder("f1", "Work", null), ListFolder("f2", "Personal", "f1")) + val repo = FakeListsRepository().apply { + foldersResult = ApiResult.Success(nested) + updateFolderResult = ApiResult.Success(ListFolder("f2", "Personal", null)) + } + val vm = FolderBrowserViewModel(repo) + advanceUntilIdle() + + vm.moveFolder(nested[1], newParentId = null) + advanceUntilIdle() + + assertThat(repo.lastUpdatedFolderParentId).isNull() + assertThat(vm.uiState.value.folders.first { it.id == "f2" }.parentId).isNull() + } +} diff --git a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt index edb983f..c160c64 100644 --- a/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt +++ b/feature/lists/src/test/kotlin/com/interlinedlist/android/feature/lists/ui/watchers/WatchersViewModelTest.kt @@ -5,6 +5,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.lists.FakeListsRepository +import com.interlinedlist.android.feature.lists.domain.Contributor import com.interlinedlist.android.feature.lists.domain.Watcher import com.interlinedlist.android.feature.lists.domain.WatcherCandidate import com.interlinedlist.android.feature.lists.domain.WatcherRole @@ -52,6 +53,21 @@ class WatchersViewModelTest { } } + @Test + fun `loads contributors alongside watchers on init`() = runTest(dispatcher) { + val repo = FakeListsRepository().apply { + watchersResult = ApiResult.Success(listOf(watcher("1"))) + contributorsResult = ApiResult.Success( + listOf(Contributor("u1", "ada", "Ada", null, addedCount = 5, editedCount = 2, score = 7)), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.contributors.map { it.userId }).containsExactly("u1") + assertThat(vm.uiState.value.contributors.single().addedCount).isEqualTo(5) + } + @Test fun `search surfaces candidate users and clearing empties them`() = runTest(dispatcher) { val repo = FakeListsRepository().apply { From 92496010145a50d00921a2bb6bed271a49418d8e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 16:06:34 -0700 Subject: [PATCH 17/25] feat(app): wire 7 parity milestones into navigation; app assembles Adds a 5th 'DMs' bottom-nav tab (directMessagesGraph); wires the auth lifecycle nested graph (register/forgot/reset/verify); profile Account & Security (sessions/connected-accounts/settings, aliasing the profile vs integrations ConnectedAccountsRoute clash); public content (list/document); notification preferences (gear in Notifications); and lists/documents share + shared/{token} deep-link routes + folder browser. app/build.gradle.kts now depends on :feature:directmessages. :app:assembleDebug BUILD SUCCESSFUL. App-manifest deep-link intent-filters still deferred. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 1 + .../navigation/InterlinedListNavHost.kt | 166 ++++++++++++++++-- .../notifications/ui/NotificationsScreen.kt | 11 ++ 3 files changed, 166 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 020788c..2868454 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -51,6 +51,7 @@ dependencies { implementation(project(":feature:auth")) implementation(project(":feature:lists")) implementation(project(":feature:messages")) + implementation(project(":feature:directmessages")) implementation(project(":feature:documents")) implementation(project(":feature:profile")) implementation(project(":feature:notifications")) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 903de7d..6b794d1 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Forum +import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem @@ -24,36 +25,54 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.interlinedlist.android.feature.auth.ui.LoginRoute +import androidx.navigation.navDeepLink +import androidx.navigation.navigation +import com.interlinedlist.android.feature.auth.nav.AuthRoutes +import com.interlinedlist.android.feature.auth.nav.authGraph +import com.interlinedlist.android.feature.directmessages.navigation.DirectMessagesDestinations +import com.interlinedlist.android.feature.directmessages.navigation.directMessagesGraph +import com.interlinedlist.android.feature.directmessages.navigation.navigateToDmThread +import com.interlinedlist.android.feature.directmessages.navigation.navigateToNewDm import com.interlinedlist.android.feature.documents.ui.browser.DocumentsFolderRoute import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute +import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute +import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsRoute import com.interlinedlist.android.feature.lists.ui.connections.ConnectionsRoute import com.interlinedlist.android.feature.lists.ui.detail.ListDetailRoute +import com.interlinedlist.android.feature.lists.ui.folders.FolderBrowserRoute import com.interlinedlist.android.feature.lists.ui.list.ListsRoute import com.interlinedlist.android.feature.lists.ui.schema.SchemaEditorRoute +import com.interlinedlist.android.feature.lists.ui.share.ShareRoute +import com.interlinedlist.android.feature.lists.ui.share.SharedListRoute +import com.interlinedlist.android.feature.lists.ui.share.SharedWithMeRoute import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute +import com.interlinedlist.android.feature.notifications.ui.NotificationPreferencesRoute import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute import com.interlinedlist.android.feature.organizations.ui.list.OrganizationsRoute +import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsRoute +import com.interlinedlist.android.feature.profile.ui.account.ConnectedAccountsRoute as ProfileConnectedAccountsRoute +import com.interlinedlist.android.feature.profile.ui.account.SessionsRoute import com.interlinedlist.android.feature.profile.ui.edit.EditProfileRoute import com.interlinedlist.android.feature.profile.ui.follow.FollowRequestsRoute import com.interlinedlist.android.feature.profile.ui.follow.FollowersRoute import com.interlinedlist.android.feature.profile.ui.follow.FollowingRoute import com.interlinedlist.android.feature.profile.ui.profile.ProfileRoute +import com.interlinedlist.android.feature.profile.ui.profile.PublicDocumentRoute +import com.interlinedlist.android.feature.profile.ui.profile.PublicListRoute import com.interlinedlist.android.feature.profile.ui.profile.UserProfileRoute import com.interlinedlist.android.feature.profile.ui.search.UserSearchRoute import com.interlinedlist.android.ui.home.HomeViewModel /** Navigation route keys. */ object Routes { - const val LOGIN = "login" const val MAIN = "main" // Top-level tabs (bottom navigation). @@ -68,6 +87,12 @@ object Routes { const val LIST_WATCHERS = "lists/{listId}/watchers" const val LIST_CONNECTIONS = "lists/connections" + // Lists sharing & folders (Milestones F / M). + const val LIST_SHARE = "lists/{listId}/share" + const val LISTS_SHARED_WITH_ME = "lists/shared-with-me" + const val LIST_SHARED = "lists/shared/{token}" + const val LIST_FOLDERS = "lists/folders" + // Messages destinations. const val MESSAGE_DETAIL = "messageDetail/{messageId}" const val MESSAGES_SCHEDULED = "messages/scheduled" @@ -76,6 +101,14 @@ object Routes { const val DOCUMENT_FOLDER = "documents/folder/{folderId}" const val DOCUMENT_EDITOR = "documents/editor/{documentId}" + // Documents sharing (Milestone F). + const val DOCUMENT_SHARE = "documents/{documentId}/share" + const val DOCUMENT_SHARED = "documents/shared/{token}" + + // Public read-only content (Milestone L). + const val PUBLIC_LIST = "publicList/{username}/{listId}" + const val PUBLIC_DOCUMENT = "publicDocument/{documentId}" + // Profile / following destinations. Distinct prefixes so a username can // never collide with the edit/search/list routes. const val PROFILE_EDIT = "editProfile" @@ -85,8 +118,14 @@ object Routes { const val FOLLOWING = "following/{username}" const val FOLLOW_REQUESTS = "followRequests" + // Account & security (Milestone K), reached from the Account hub. + const val ACCOUNT_SESSIONS = "account/sessions" + const val ACCOUNT_CONNECTED = "account/connected-accounts" + const val ACCOUNT_SETTINGS = "account/settings" + // Notifications / organizations / integrations (reached from the Account hub). const val NOTIFICATIONS = "notifications" + const val NOTIFICATION_PREFERENCES = "notifications/preferences" const val ORGANIZATIONS = "organizations" const val ORGANIZATION_DETAIL = "organizations/{orgId}" const val INTEGRATIONS = "integrations" @@ -96,9 +135,15 @@ object Routes { fun listDetail(id: String) = "lists/$id" fun listSchema(id: String) = "lists/$id/schema" fun listWatchers(id: String) = "lists/$id/watchers" + fun listShare(id: String) = "lists/$id/share" + fun listShared(token: String) = "lists/shared/$token" fun messageDetail(id: String) = "messageDetail/$id" fun documentFolder(id: String) = "documents/folder/$id" fun documentEditor(id: String) = "documents/editor/$id" + fun documentShare(id: String) = "documents/$id/share" + fun documentShared(token: String) = "documents/shared/$token" + fun publicList(username: String, listId: String) = "publicList/$username/$listId" + fun publicDocument(documentId: String) = "publicDocument/$documentId" fun userProfile(username: String) = "user/$username" fun followers(username: String) = "followers/$username" fun following(username: String) = "following/$username" @@ -106,11 +151,12 @@ object Routes { } /** - * The four post-login home tabs shown in the bottom navigation bar. Order - * mirrors the web app: Messages, Lists, Documents (then Account). + * The post-login home tabs shown in the bottom navigation bar. Order mirrors the + * web app: Messages, DMs, Lists, Documents (then Account). */ private enum class HomeTab(val route: String, val label: String, val icon: ImageVector) { Messages(Routes.MESSAGES, "Messages", Icons.Filled.Forum), + DirectMessages(DirectMessagesDestinations.INBOX, "DMs", Icons.Filled.MailOutline), Lists(Routes.LISTS, "Lists", Icons.AutoMirrored.Filled.List), Documents(Routes.DOCUMENTS, "Documents", Icons.Filled.Description), Account(Routes.ACCOUNT, "Account", Icons.Filled.AccountCircle), @@ -126,13 +172,17 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) { val navController = rememberNavController() NavHost( navController = navController, - startDestination = if (startLoggedIn) Routes.MAIN else Routes.LOGIN, + startDestination = if (startLoggedIn) Routes.MAIN else AuthRoutes.GRAPH, ) { - composable(Routes.LOGIN) { - LoginRoute( - onLoggedIn = { + // Unauthenticated flow owned by the auth module: Login ⇄ Register ⇄ Forgot + // → Reset + verify (incl. its own deep links). Successful auth replaces the + // whole graph with the signed-in shell. + navigation(route = AuthRoutes.GRAPH, startDestination = AuthRoutes.LOGIN) { + authGraph( + navController = navController, + onAuthenticated = { navController.navigate(Routes.MAIN) { - popUpTo(Routes.LOGIN) { inclusive = true } + popUpTo(AuthRoutes.GRAPH) { inclusive = true } } }, ) @@ -140,7 +190,7 @@ fun InterlinedListNavHost(startLoggedIn: Boolean) { composable(Routes.MAIN) { MainShell( onLoggedOut = { - navController.navigate(Routes.LOGIN) { + navController.navigate(AuthRoutes.GRAPH) { popUpTo(Routes.MAIN) { inclusive = true } } }, @@ -211,11 +261,19 @@ private fun MainShell(onLoggedOut: () -> Unit) { ScheduledMessagesRoute(onBack = { tabNav.popBackStack() }) } + // ---- Direct Messages ---- + directMessagesGraph( + onBack = { tabNav.popBackStack() }, + onOpenThread = { username -> tabNav.navigateToDmThread(username) }, + onComposeNew = { tabNav.navigateToNewDm() }, + ) + // ---- Lists ---- composable(Routes.LISTS) { ListsRoute( onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, onOpenConnections = { tabNav.navigate(Routes.LIST_CONNECTIONS) }, + onOpenSharedWithMe = { tabNav.navigate(Routes.LISTS_SHARED_WITH_ME) }, ) } composable( @@ -228,6 +286,7 @@ private fun MainShell(onLoggedOut: () -> Unit) { onListDeleted = { tabNav.popBackStack() }, onEditSchema = { tabNav.navigate(Routes.listSchema(listId)) }, onOpenWatchers = { tabNav.navigate(Routes.listWatchers(listId)) }, + onOpenShare = { tabNav.navigate(Routes.listShare(listId)) }, ) } composable( @@ -248,6 +307,31 @@ private fun MainShell(onLoggedOut: () -> Unit) { composable(Routes.LIST_CONNECTIONS) { ConnectionsRoute(onBack = { tabNav.popBackStack() }) } + composable( + Routes.LIST_SHARE, + arguments = listOf(navArgument("listId") { type = NavType.StringType }), + ) { + ShareRoute(onDismiss = { tabNav.popBackStack() }) + } + composable(Routes.LISTS_SHARED_WITH_ME) { + SharedWithMeRoute( + onBack = { tabNav.popBackStack() }, + onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, + ) + } + composable( + Routes.LIST_SHARED, + arguments = listOf(navArgument("token") { type = NavType.StringType }), + deepLinks = listOf( + navDeepLink { uriPattern = "https://interlinedlist.com/lists/shared/{token}" }, + navDeepLink { uriPattern = "interlinedlist://lists/shared/{token}" }, + ), + ) { + SharedListRoute(onBack = { tabNav.popBackStack() }) + } + composable(Routes.LIST_FOLDERS) { + FolderBrowserRoute(onBack = { tabNav.popBackStack() }) + } // ---- Documents ---- composable(Routes.DOCUMENTS) { @@ -269,12 +353,30 @@ private fun MainShell(onLoggedOut: () -> Unit) { composable( Routes.DOCUMENT_EDITOR, arguments = listOf(navArgument("documentId") { type = NavType.StringType }), - ) { + ) { entry -> + val documentId = entry.arguments?.getString("documentId").orEmpty() DocumentEditorRoute( onBack = { tabNav.popBackStack() }, onDeleted = { tabNav.popBackStack() }, + onOpenShare = { tabNav.navigate(Routes.documentShare(documentId)) }, ) } + composable( + Routes.DOCUMENT_SHARE, + arguments = listOf(navArgument("documentId") { type = NavType.StringType }), + ) { + DocumentShareRoute(onDismiss = { tabNav.popBackStack() }) + } + composable( + Routes.DOCUMENT_SHARED, + arguments = listOf(navArgument("token") { type = NavType.StringType }), + deepLinks = listOf( + navDeepLink { uriPattern = "https://interlinedlist.com/documents/shared/{token}" }, + navDeepLink { uriPattern = "interlinedlist://documents/shared/{token}" }, + ), + ) { + SharedDocumentRoute(onBack = { tabNav.popBackStack() }) + } // ---- Account / Profile hub ---- composable(Routes.ACCOUNT) { @@ -290,9 +392,26 @@ private fun MainShell(onLoggedOut: () -> Unit) { onOpenNotifications = { tabNav.navigate(Routes.NOTIFICATIONS) }, onOpenOrganizations = { tabNav.navigate(Routes.ORGANIZATIONS) }, onOpenIntegrations = { tabNav.navigate(Routes.INTEGRATIONS) }, + onOpenSessions = { tabNav.navigate(Routes.ACCOUNT_SESSIONS) }, + onOpenConnectedAccounts = { tabNav.navigate(Routes.ACCOUNT_CONNECTED) }, + onOpenAccountSettings = { tabNav.navigate(Routes.ACCOUNT_SETTINGS) }, onSignOut = { logoutViewModel.logout(onLoggedOut) }, ) } + composable(Routes.ACCOUNT_SESSIONS) { + SessionsRoute(onBack = { tabNav.popBackStack() }) + } + composable(Routes.ACCOUNT_CONNECTED) { + ProfileConnectedAccountsRoute(onBack = { tabNav.popBackStack() }) + } + composable(Routes.ACCOUNT_SETTINGS) { + // Account deletion clears the session; reuse the same logout path as sign-out. + val logoutViewModel: HomeViewModel = hiltViewModel() + AccountSettingsRoute( + onBack = { tabNav.popBackStack() }, + onSignedOut = { logoutViewModel.logout(onLoggedOut) }, + ) + } composable(Routes.PROFILE_EDIT) { EditProfileRoute( onBack = { tabNav.popBackStack() }, @@ -313,8 +432,25 @@ private fun MainShell(onLoggedOut: () -> Unit) { onBack = { tabNav.popBackStack() }, onOpenFollowers = { username -> tabNav.navigate(Routes.followers(username)) }, onOpenFollowing = { username -> tabNav.navigate(Routes.following(username)) }, + onOpenList = { username, listId -> tabNav.navigate(Routes.publicList(username, listId)) }, + onOpenDocument = { documentId -> tabNav.navigate(Routes.publicDocument(documentId)) }, ) } + composable( + Routes.PUBLIC_LIST, + arguments = listOf( + navArgument("username") { type = NavType.StringType }, + navArgument("listId") { type = NavType.StringType }, + ), + ) { + PublicListRoute(onBack = { tabNav.popBackStack() }) + } + composable( + Routes.PUBLIC_DOCUMENT, + arguments = listOf(navArgument("documentId") { type = NavType.StringType }), + ) { + PublicDocumentRoute(onBack = { tabNav.popBackStack() }) + } composable( Routes.FOLLOWERS, arguments = listOf(navArgument("username") { type = NavType.StringType }), @@ -342,7 +478,13 @@ private fun MainShell(onLoggedOut: () -> Unit) { // ---- Notifications ---- composable(Routes.NOTIFICATIONS) { - NotificationsRoute(onBack = { tabNav.popBackStack() }) + NotificationsRoute( + onBack = { tabNav.popBackStack() }, + onOpenPreferences = { tabNav.navigate(Routes.NOTIFICATION_PREFERENCES) }, + ) + } + composable(Routes.NOTIFICATION_PREFERENCES) { + NotificationPreferencesRoute(onBack = { tabNav.popBackStack() }) } // ---- Organizations ---- diff --git a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt index f581985..8680308 100644 --- a/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt +++ b/feature/notifications/src/main/kotlin/com/interlinedlist/android/feature/notifications/ui/NotificationsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Button @@ -56,6 +57,7 @@ object NotificationsTags { const val BACK = "notificationsBack" const val MARK_ALL_READ = "notificationsMarkAllRead" const val UNREAD_BADGE = "notificationsUnreadBadge" + const val PREFERENCES = "notificationsPreferences" } /** @@ -74,12 +76,14 @@ fun NotificationsRoute( onBack: () -> Unit, modifier: Modifier = Modifier, onOpenTarget: (NotificationTarget) -> Unit = {}, + onOpenPreferences: () -> Unit = {}, viewModel: NotificationsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() NotificationsScreen( state = state, onBack = onBack, + onOpenPreferences = onOpenPreferences, onRefresh = viewModel::refresh, onLoadMore = viewModel::loadMore, onMarkAllRead = viewModel::onMarkAllRead, @@ -105,6 +109,7 @@ fun NotificationsScreen( onOpen: (Notification) -> Unit, onDismiss: (Notification) -> Unit, modifier: Modifier = Modifier, + onOpenPreferences: () -> Unit = {}, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -139,6 +144,12 @@ fun NotificationsScreen( Icon(Icons.Filled.DoneAll, contentDescription = "Mark all read") } } + IconButton( + onClick = onOpenPreferences, + modifier = Modifier.testTag(NotificationsTags.PREFERENCES), + ) { + Icon(Icons.Filled.Settings, contentDescription = "Notification preferences") + } }, ) }, From 610147a1d55c7fcd55a48d42ed123c752929e702 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 16:11:43 -0700 Subject: [PATCH 18/25] feat(documents): collaboration + delta sync (Milestone G) + wire manage-access Delta sync (GET/POST /api/documents/sync via WorkManager worker + cursor), PATCH save with If-Match optimistic concurrency (409 -> Conflict state), document /tree, collaborators CRUD (+user search) and lightweight presence heartbeat. 99 documents unit tests green. Wires the Manage-access route (documents/{id}/access -> DocumentCollaboratorsRoute) from the editor. :app:assembleDebug SUCCESSFUL. NOTE: the @HiltWorker delta-sync worker still needs the app-level HiltWorkerFactory bootstrap + a schedule call to run at runtime (deferred to device verification). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../navigation/InterlinedListNavHost.kt | 10 + feature/documents/build.gradle.kts | 5 + .../DocumentCollaboratorsScreenTest.kt | 101 ++++++ .../data/DefaultDocumentsRepository.kt | 225 ++++++++++++ .../documents/data/DocumentsRepository.kt | 81 +++++ .../documents/data/local/DocumentEntity.kt | 4 + .../documents/data/local/DocumentsDatabase.kt | 11 +- .../documents/data/local/PendingOpDao.kt | 31 ++ .../documents/data/local/PendingOpEntity.kt | 27 ++ .../documents/data/local/SyncMetaDao.kt | 19 + .../documents/data/local/SyncMetaEntity.kt | 19 + .../documents/data/mapper/DocumentMappers.kt | 1 + .../documents/data/mapper/SyncMappers.kt | 62 ++++ .../documents/data/remote/DocumentsApi.kt | 84 +++++ .../data/remote/dto/CollaboratorDtos.kt | 86 +++++ .../documents/data/remote/dto/DocumentDto.kt | 2 + .../data/remote/dto/DocumentResponses.kt | 2 + .../documents/data/remote/dto/PresenceDtos.kt | 41 +++ .../documents/data/remote/dto/SyncDtos.kt | 79 +++++ .../documents/data/remote/dto/TreeDto.kt | 15 + .../feature/documents/di/DocumentsModule.kt | 8 + .../feature/documents/domain/Collaborator.kt | 63 ++++ .../feature/documents/domain/Document.kt | 7 + .../feature/documents/domain/Presence.kt | 18 + .../documents/sync/DocumentsSyncScheduler.kt | 61 ++++ .../documents/sync/DocumentsSyncWorker.kt | 42 +++ .../DocumentCollaboratorsScreen.kt | 331 ++++++++++++++++++ .../DocumentCollaboratorsViewModel.kt | 161 +++++++++ .../ui/editor/DocumentEditorScreen.kt | 93 ++++- .../ui/editor/DocumentEditorViewModel.kt | 75 +++- .../ui/presence/DocumentPresenceViewModel.kt | 82 +++++ .../ui/presence/PresenceIndicator.kt | 86 +++++ ...aultDocumentsRepositoryCollaboratorTest.kt | 187 ++++++++++ .../DefaultDocumentsRepositoryShareTest.kt | 4 +- .../DefaultDocumentsRepositorySyncTest.kt | 253 +++++++++++++ .../data/DefaultDocumentsRepositoryTest.kt | 4 +- .../feature/documents/data/FakeDaos.kt | 46 +++ .../ui/DocumentEditorViewModelTest.kt | 64 +++- .../documents/ui/FakeDocumentsRepository.kt | 93 +++++ .../DocumentCollaboratorsViewModelTest.kt | 142 ++++++++ .../presence/DocumentPresenceViewModelTest.kt | 100 ++++++ 41 files changed, 2802 insertions(+), 23 deletions(-) create mode 100644 feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpDao.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpEntity.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaDao.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaEntity.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/SyncMappers.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/CollaboratorDtos.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/PresenceDtos.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/SyncDtos.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/TreeDto.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Collaborator.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Presence.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncScheduler.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncWorker.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModel.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/PresenceIndicator.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModelTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModelTest.kt diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 6b794d1..bd900f1 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -38,6 +38,7 @@ import com.interlinedlist.android.feature.documents.ui.browser.DocumentsRoute import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRoute import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute +import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsRoute @@ -104,6 +105,7 @@ object Routes { // Documents sharing (Milestone F). const val DOCUMENT_SHARE = "documents/{documentId}/share" const val DOCUMENT_SHARED = "documents/shared/{token}" + const val DOCUMENT_ACCESS = "documents/{documentId}/access" // Public read-only content (Milestone L). const val PUBLIC_LIST = "publicList/{username}/{listId}" @@ -142,6 +144,7 @@ object Routes { fun documentEditor(id: String) = "documents/editor/$id" fun documentShare(id: String) = "documents/$id/share" fun documentShared(token: String) = "documents/shared/$token" + fun documentAccess(id: String) = "documents/$id/access" fun publicList(username: String, listId: String) = "publicList/$username/$listId" fun publicDocument(documentId: String) = "publicDocument/$documentId" fun userProfile(username: String) = "user/$username" @@ -359,6 +362,7 @@ private fun MainShell(onLoggedOut: () -> Unit) { onBack = { tabNav.popBackStack() }, onDeleted = { tabNav.popBackStack() }, onOpenShare = { tabNav.navigate(Routes.documentShare(documentId)) }, + onOpenManageAccess = { tabNav.navigate(Routes.documentAccess(documentId)) }, ) } composable( @@ -367,6 +371,12 @@ private fun MainShell(onLoggedOut: () -> Unit) { ) { DocumentShareRoute(onDismiss = { tabNav.popBackStack() }) } + composable( + Routes.DOCUMENT_ACCESS, + arguments = listOf(navArgument("documentId") { type = NavType.StringType }), + ) { + DocumentCollaboratorsRoute(onDismiss = { tabNav.popBackStack() }) + } composable( Routes.DOCUMENT_SHARED, arguments = listOf(navArgument("token") { type = NavType.StringType }), diff --git a/feature/documents/build.gradle.kts b/feature/documents/build.gradle.kts index 0435625..9b9bd1f 100644 --- a/feature/documents/build.gradle.kts +++ b/feature/documents/build.gradle.kts @@ -53,6 +53,11 @@ dependencies { ksp(libs.hilt.compiler) implementation(libs.androidx.hilt.navigation.compose) + // Background delta-sync via WorkManager, with Hilt-injected workers. + implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.hilt.work) + ksp(libs.androidx.hilt.compiler) + implementation(libs.coil.compose) implementation(libs.retrofit.core) diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt new file mode 100644 index 0000000..6ad104b --- /dev/null +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreenTest.kt @@ -0,0 +1,101 @@ +package com.interlinedlist.android.feature.documents.ui.collaborators + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Verifies the Manage-access sheet renders collaborators and their role controls. */ +@RunWith(AndroidJUnit4::class) +class DocumentCollaboratorsScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent( + state: DocumentCollaboratorsUiState, + onChangeRole: (String, CollaboratorRole) -> Unit = { _, _ -> }, + onRevoke: (String) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + DocumentCollaboratorsSheetContent( + state = state, + onSearchQueryChange = {}, + onSearch = {}, + onSelectInviteRole = {}, + onInvite = {}, + onChangeRole = onChangeRole, + onRevoke = onRevoke, + ) + } + } + } + + @Test + fun rendersCollaborators_withRoleControls() { + setContent( + DocumentCollaboratorsUiState( + collaborators = listOf( + Collaborator("u1", CollaboratorRole.ADMIN, "Ada", "ada", "ada@x.io", null), + Collaborator("u2", CollaboratorRole.VIEWER, "Bob", "bob", null, null), + ), + isLoading = false, + ), + ) + + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.row("u1")).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.row("u2")).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.revoke("u1")).assertIsDisplayed() + composeRule + .onNodeWithTag(DocumentCollaboratorsTestTags.role("u2", CollaboratorRole.EDITOR)) + .assertIsDisplayed() + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.SEARCH_FIELD).assertIsDisplayed() + } + + @Test + fun roleChip_invokesChangeRole() { + var changed: Pair? = null + setContent( + DocumentCollaboratorsUiState( + collaborators = listOf(Collaborator("u2", CollaboratorRole.VIEWER, "Bob", "bob", null, null)), + isLoading = false, + ), + onChangeRole = { userId, role -> changed = userId to role }, + ) + + composeRule + .onNodeWithTag(DocumentCollaboratorsTestTags.role("u2", CollaboratorRole.ADMIN)) + .performClick() + + assert(changed == "u2" to CollaboratorRole.ADMIN) + } + + @Test + fun revoke_invokesCallback() { + var revoked: String? = null + setContent( + DocumentCollaboratorsUiState( + collaborators = listOf(Collaborator("u2", CollaboratorRole.VIEWER, "Bob", "bob", null, null)), + isLoading = false, + ), + onRevoke = { revoked = it }, + ) + + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.revoke("u2")).performClick() + assert(revoked == "u2") + } + + @Test + fun emptyState_isShown_whenNoCollaborators() { + setContent(DocumentCollaboratorsUiState(collaborators = emptyList(), isLoading = false)) + composeRule.onNodeWithTag(DocumentCollaboratorsTestTags.EMPTY).assertIsDisplayed() + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt index eb8bb84..55cd507 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt @@ -7,8 +7,13 @@ import com.interlinedlist.android.core.common.result.map import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.documents.data.local.DocumentDao import com.interlinedlist.android.feature.documents.data.local.FolderDao +import com.interlinedlist.android.feature.documents.data.local.PendingOpDao +import com.interlinedlist.android.feature.documents.data.local.PendingOpEntity +import com.interlinedlist.android.feature.documents.data.local.SyncMetaDao +import com.interlinedlist.android.feature.documents.data.local.SyncMetaEntity import com.interlinedlist.android.feature.documents.data.local.toDomain import com.interlinedlist.android.feature.documents.data.local.toEntity +import com.interlinedlist.android.feature.documents.data.mapper.toCandidate import com.interlinedlist.android.feature.documents.data.mapper.toDomain import com.interlinedlist.android.feature.documents.data.mapper.toSharedDocument import com.interlinedlist.android.feature.documents.data.mapper.toTemplate @@ -17,8 +22,15 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocume import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.InviteCollaboratorRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.SyncOperationDto +import com.interlinedlist.android.feature.documents.data.remote.dto.SyncPushRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateCollaboratorRoleRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateFolderRequest +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate @@ -26,6 +38,7 @@ import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.domain.FolderTree +import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.domain.ShareLink import com.interlinedlist.android.feature.documents.domain.ShareRole import com.interlinedlist.android.feature.documents.domain.SharedDocument @@ -50,6 +63,8 @@ class DefaultDocumentsRepository @Inject constructor( private val api: DocumentsApi, private val documentDao: DocumentDao, private val folderDao: FolderDao, + private val pendingOpDao: PendingOpDao, + private val syncMetaDao: SyncMetaDao, private val json: Json, private val dispatchers: DispatcherProvider, ) : DocumentsRepository { @@ -71,6 +86,8 @@ class DefaultDocumentsRepository @Inject constructor( override fun observeDocument(id: String): Flow = documentDao.observeDocument(id).map { it?.toDomain() } + override fun observePendingCount(): Flow = pendingOpDao.observeCount() + override suspend fun refreshTree(): ApiResult = withContext(dispatchers.io) { // One call returns the nested folder tree with embedded docs; a second returns // the unfiled root documents. We replace the whole cache so deletions drop out. @@ -361,8 +378,216 @@ class DefaultDocumentsRepository @Inject constructor( safeApiCall(json) { api.claimSharedDocument(token) }.map { } } + // --- Versioned save (PATCH + If-Match) -------------------------------- + + override suspend fun patchDocument( + id: String, + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + expectedVersion: Int?, + ): SaveOutcome = withContext(dispatchers.io) { + val body = UpdateDocumentRequest( + title = title, + content = content, + isPublic = isPublic, + folderId = folderId, + ) + val result = safeApiCall(json) { + api.patchDocument(id, body, ifMatch = expectedVersion?.toString()).documentOrSelf + } + when (result) { + is ApiResult.Success -> { + val domain = result.data?.toDomain()?.let { + it.copy(content = it.content ?: content) + } ?: Document( + id = id, + title = title, + content = content, + snippet = Document.snippetFrom(content), + folderId = folderId, + folderName = null, + isPublic = isPublic, + updatedAt = null, + version = expectedVersion?.plus(1), + ) + documentDao.upsert(domain.toEntity(sortOrder = existingOrder(id))) + // A successful save supersedes anything queued for this doc. + pendingOpDao.deleteById(id) + SaveOutcome.Success(domain) + } + is ApiResult.Failure -> when (result.error) { + // Version mismatch → let the UI reload/retry; do NOT clobber or queue. + is AppError.Conflict -> SaveOutcome.Conflict(result.error.message) + // Offline → persist locally to push on the next sync. + is AppError.Network -> { + pendingOpDao.upsert( + PendingOpEntity( + documentId = id, + op = PendingOpEntity.OP_UPDATE, + title = title, + content = content, + isPublic = isPublic, + folderId = folderId, + version = expectedVersion, + queuedAt = System.currentTimeMillis(), + ), + ) + SaveOutcome.Queued + } + else -> SaveOutcome.Error(result.error.message) + } + } + } + + // --- Delta sync -------------------------------------------------------- + + override suspend fun pullDelta(): ApiResult = withContext(dispatchers.io) { + val cursor = syncMetaDao.get(SyncMetaEntity.KEY_CURSOR) + when (val result = safeApiCall(json) { api.pullSync(cursor) }) { + is ApiResult.Success -> { + reconcile(result.data.folders, result.data.documents) + result.data.lastSyncAt?.let { + syncMetaDao.put(SyncMetaEntity(SyncMetaEntity.KEY_CURSOR, it)) + } + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + override suspend fun pushPendingOps(): ApiResult = withContext(dispatchers.io) { + val pending = pendingOpDao.all() + if (pending.isEmpty()) return@withContext ApiResult.Success(Unit) + + val operations = pending.map { op -> + SyncOperationDto( + id = op.documentId, + op = op.op, + title = op.title, + content = op.content, + isPublic = op.isPublic, + folderId = op.folderId, + version = op.version, + ) + } + when (val result = safeApiCall(json) { api.pushSync(SyncPushRequest(operations)) }) { + is ApiResult.Success -> { + pendingOpDao.deleteAllByIds(pending.map { it.documentId }) + // Fold any server echo back into the cache and advance the cursor. + reconcile(result.data.folders, result.data.documents) + result.data.lastSyncAt?.let { + syncMetaDao.put(SyncMetaEntity(SyncMetaEntity.KEY_CURSOR, it)) + } + ApiResult.Success(Unit) + } + is ApiResult.Failure -> result + } + } + + // --- Collaborators ----------------------------------------------------- + + override suspend fun getCollaborators(documentId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getCollaborators(documentId) } + .map { response -> response.items.map { it.toDomain() } } + } + + override suspend fun searchCollaboratorUsers( + documentId: String, + query: String, + ): ApiResult> = withContext(dispatchers.io) { + safeApiCall(json) { + api.searchCollaboratorUsers( + id = documentId, + search = query.takeIf { it.isNotBlank() }, + excludeCollaborators = true, + ) + }.map { response -> response.users.map { it.toCandidate() } } + } + + override suspend fun inviteCollaborator( + documentId: String, + userId: String, + role: CollaboratorRole, + ): ApiResult = withContext(dispatchers.io) { + when (val result = safeApiCall(json) { + api.inviteCollaborator(documentId, InviteCollaboratorRequest(userId, role.apiValue)) + }) { + is ApiResult.Success -> { + val dto = result.data.collaboratorOrNull + val domain = dto?.toDomain() + ?: Collaborator(userId, role, null, null, null, null) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + + override suspend fun updateCollaboratorRole( + documentId: String, + userId: String, + role: CollaboratorRole, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.updateCollaboratorRole(documentId, userId, UpdateCollaboratorRoleRequest(role.apiValue)) + }.map { } + } + + override suspend fun removeCollaborator(documentId: String, userId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.removeCollaborator(documentId, userId) }.map { } + } + + // --- Presence ---------------------------------------------------------- + + override suspend fun sendPresence(documentId: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.sendPresence(documentId) } + .map { response -> response.items.map { it.toDomain() } } + } + + override suspend fun leavePresence(documentId: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.leavePresence(documentId) }.map { } + } + // --- Helpers ----------------------------------------------------------- + /** + * Reconciles a batch of flat, versioned sync rows into Room: tombstones drop the + * cached row; upserts replace by id (append at the tail if new, keep the slot if + * known). Server ordering within a delta is not authoritative, so we sort new + * rows after existing ones. + */ + private suspend fun reconcile( + folders: List, + documents: List, + ) { + folders.forEach { dto -> + if (dto.isDeleted) { + pruneFolderCascade(dto.id) + } else { + val existing = folderDao.getFolder(dto.id) + val order = existing?.sortOrder ?: (folderDao.maxSortOrder() + 1) + folderDao.upsert(dto.toDomain().toEntity(sortOrder = order)) + } + } + documents.forEach { dto -> + if (dto.isDeleted) { + documentDao.deleteById(dto.id) + } else { + val existing = documentDao.getDocument(dto.id) + val order = existing?.sortOrder ?: (documentDao.maxSortOrder() + 1) + // Preserve a locally cached body if the delta row omits content. + documentDao.upsert( + dto.toDomain().toEntity(sortOrder = order, existingContent = existing?.content), + ) + } + } + } + private fun buildTree(folders: List, documents: List): FolderNode { val byFolder = documents.filter { it.folderId != null }.groupBy { it.folderId!! } val root = documents.filter { it.folderId == null } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt index 28522cc..dc04bd1 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt @@ -1,11 +1,15 @@ package com.interlinedlist.android.feature.documents.data import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderSummary +import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.domain.ShareLink import com.interlinedlist.android.feature.documents.domain.ShareRole import com.interlinedlist.android.feature.documents.domain.SharedDocument @@ -36,6 +40,12 @@ interface DocumentsRepository { /** Refreshes the entire folder tree + root documents from the API into Room. */ suspend fun refreshTree(): ApiResult + /** + * Number of local edits queued for the next push. The UI can surface an + * "unsynced changes" hint from this. + */ + fun observePendingCount(): Flow + /** Fetches a document detail (with body) and caches it. */ suspend fun refreshDocument(id: String): ApiResult @@ -57,6 +67,22 @@ interface DocumentsRepository { folderId: String?, ): ApiResult + /** + * Saves an edit via `PATCH /api/documents/{id}` carrying the given [expectedVersion] + * as the `If-Match` token. Returns [SaveOutcome.Conflict] when the server rejects + * the token (a concurrent write happened) so the editor can offer reload/retry + * instead of silently overwriting. On network failure the edit is queued for the + * next sync push and [SaveOutcome.Queued] is returned. + */ + suspend fun patchDocument( + id: String, + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + expectedVersion: Int?, + ): SaveOutcome + /** Moves a document into [folderId] (null == root/unfiled). */ suspend fun moveDocument(id: String, folderId: String?): ApiResult @@ -108,4 +134,59 @@ interface DocumentsRepository { /** Claims edit/admin access to a shared document via its token. */ suspend fun claimSharedDocument(token: String): ApiResult + + // --- Delta sync -------------------------------------------------------- + + /** + * Pulls changes since the persisted cursor and reconciles them into Room + * (upserts by id + version, tombstones removed), then advances the cursor. + */ + suspend fun pullDelta(): ApiResult + + /** Pushes any queued local edits/deletes via `POST /api/documents/sync`. */ + suspend fun pushPendingOps(): ApiResult + + // --- Collaborators ----------------------------------------------------- + + suspend fun getCollaborators(documentId: String): ApiResult> + + /** Searches users who can be invited as collaborators on [documentId]. */ + suspend fun searchCollaboratorUsers( + documentId: String, + query: String, + ): ApiResult> + + suspend fun inviteCollaborator( + documentId: String, + userId: String, + role: CollaboratorRole, + ): ApiResult + + suspend fun updateCollaboratorRole( + documentId: String, + userId: String, + role: CollaboratorRole, + ): ApiResult + + suspend fun removeCollaborator(documentId: String, userId: String): ApiResult + + // --- Presence ---------------------------------------------------------- + + /** Sends a presence heartbeat; returns everyone currently on the document. */ + suspend fun sendPresence(documentId: String): ApiResult> + + /** Leaves the document (stops presence). */ + suspend fun leavePresence(documentId: String): ApiResult +} + +/** + * Outcome of a versioned save. [Success] carries the reconciled document; [Conflict] + * means the server rejected the version token (surface reload/retry); [Queued] means + * the edit was persisted locally to push later (offline); [Error] is any other failure. + */ +sealed interface SaveOutcome { + data class Success(val document: Document) : SaveOutcome + data class Conflict(val message: String?) : SaveOutcome + data object Queued : SaveOutcome + data class Error(val message: String?) : SaveOutcome } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt index b53303c..91228ad 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentEntity.kt @@ -21,6 +21,8 @@ data class DocumentEntity( val isPublic: Boolean, val updatedAt: String?, val sortOrder: Int, + /** Server row version (optimistic-concurrency token); null until a versioned fetch. */ + val version: Int? = null, ) fun DocumentEntity.toDomain(): Document = Document( @@ -32,6 +34,7 @@ fun DocumentEntity.toDomain(): Document = Document( folderName = folderName, isPublic = isPublic, updatedAt = updatedAt, + version = version, ) /** @@ -50,4 +53,5 @@ fun Document.toEntity(sortOrder: Int, existingContent: String? = null): Document isPublic = isPublic, updatedAt = updatedAt, sortOrder = sortOrder, + version = version, ) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt index 24d9b6a..0715b7e 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/DocumentsDatabase.kt @@ -9,11 +9,18 @@ import androidx.room.RoomDatabase * engineering brief). Disposable during development via destructive migration. */ @Database( - entities = [DocumentEntity::class, FolderEntity::class], - version = 2, + entities = [ + DocumentEntity::class, + FolderEntity::class, + PendingOpEntity::class, + SyncMetaEntity::class, + ], + version = 3, exportSchema = false, ) abstract class DocumentsDatabase : RoomDatabase() { abstract fun documentDao(): DocumentDao abstract fun folderDao(): FolderDao + abstract fun pendingOpDao(): PendingOpDao + abstract fun syncMetaDao(): SyncMetaDao } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpDao.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpDao.kt new file mode 100644 index 0000000..ffea702 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpDao.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface PendingOpDao { + + /** Emits the count of queued ops so the UI can show an "unsynced" hint. */ + @Query("SELECT COUNT(*) FROM pending_op") + fun observeCount(): Flow + + @Query("SELECT * FROM pending_op ORDER BY queuedAt ASC") + suspend fun all(): List + + /** Coalesces onto the same document id — the newest edit replaces any earlier one. */ + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(op: PendingOpEntity) + + @Query("DELETE FROM pending_op WHERE documentId = :documentId") + suspend fun deleteById(documentId: String) + + @Query("DELETE FROM pending_op WHERE documentId IN (:documentIds)") + suspend fun deleteAllByIds(documentIds: List) + + @Query("DELETE FROM pending_op") + suspend fun clear() +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpEntity.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpEntity.kt new file mode 100644 index 0000000..8734501 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/PendingOpEntity.kt @@ -0,0 +1,27 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A queued local document mutation awaiting push to the server via + * `POST /api/documents/sync`. Keyed by [documentId] (one pending op per document — + * a newer edit coalesces onto the same row) so the queue stays small and the last + * local write wins locally. [op] is `update` or `delete`. + */ +@Entity(tableName = "pending_op") +data class PendingOpEntity( + @PrimaryKey val documentId: String, + val op: String, + val title: String? = null, + val content: String? = null, + val isPublic: Boolean? = null, + val folderId: String? = null, + val version: Int? = null, + val queuedAt: Long = 0L, +) { + companion object { + const val OP_UPDATE = "update" + const val OP_DELETE = "delete" + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaDao.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaDao.kt new file mode 100644 index 0000000..f5b0544 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaDao.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface SyncMetaDao { + + @Query("SELECT value FROM sync_meta WHERE key = :key") + suspend fun get(key: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun put(row: SyncMetaEntity) + + @Query("DELETE FROM sync_meta WHERE key = :key") + suspend fun clear(key: String) +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaEntity.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaEntity.kt new file mode 100644 index 0000000..1a34982 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/local/SyncMetaEntity.kt @@ -0,0 +1,19 @@ +package com.interlinedlist.android.feature.documents.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A tiny key/value row for module-local sync bookkeeping — primarily the delta-sync + * cursor (`lastSyncAt`). Kept inside this module's own Room DB so the feature stays + * self-contained and never reaches into `core:datastore`. + */ +@Entity(tableName = "sync_meta") +data class SyncMetaEntity( + @PrimaryKey val key: String, + val value: String?, +) { + companion object { + const val KEY_CURSOR = "documents_last_sync_at" + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt index daae3a7..96d7e82 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/DocumentMappers.kt @@ -25,6 +25,7 @@ fun DocumentDto.toDomain(): Document { folderName = folderName, isPublic = isPublic, updatedAt = updatedAt ?: createdAt, + version = version, ) } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/SyncMappers.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/SyncMappers.kt new file mode 100644 index 0000000..68353a4 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/mapper/SyncMappers.kt @@ -0,0 +1,62 @@ +package com.interlinedlist.android.feature.documents.data.mapper + +import com.interlinedlist.android.feature.documents.data.remote.dto.CollaboratorDto +import com.interlinedlist.android.feature.documents.data.remote.dto.PresenceDto +import com.interlinedlist.android.feature.documents.data.remote.dto.SyncDocumentDto +import com.interlinedlist.android.feature.documents.data.remote.dto.SyncFolderDto +import com.interlinedlist.android.feature.documents.data.remote.dto.UserSummaryDto +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.interlinedlist.android.feature.documents.domain.Document +import com.interlinedlist.android.feature.documents.domain.DocumentFolder +import com.interlinedlist.android.feature.documents.domain.Presence + +/** Maps a delta-sync document row into the domain [Document] (carries the version). */ +fun SyncDocumentDto.toDomain(): Document = Document( + id = id, + title = title?.takeIf { it.isNotBlank() } ?: "Untitled", + content = content, + snippet = Document.snippetFrom(content), + folderId = folderId, + folderName = null, + isPublic = isPublic, + updatedAt = updatedAt ?: createdAt, + version = version, +) + +/** Maps a delta-sync folder row into the domain [DocumentFolder]. */ +fun SyncFolderDto.toDomain(): DocumentFolder = DocumentFolder( + id = id, + name = name?.takeIf { it.isNotBlank() } ?: "Untitled folder", + parentId = parentId, + createdAt = createdAt, + updatedAt = updatedAt, +) + +/** Maps a collaborator wire row into the domain [Collaborator]. */ +fun CollaboratorDto.toDomain(): Collaborator = Collaborator( + userId = resolvedUserId, + role = CollaboratorRole.fromApi(role), + displayName = resolvedDisplayName, + username = resolvedUsername, + email = resolvedEmail, + avatarUrl = resolvedAvatar, +) + +/** Maps a searchable user into an invite [CollaboratorCandidate]. */ +fun UserSummaryDto.toCandidate(): CollaboratorCandidate = CollaboratorCandidate( + userId = id, + username = username, + displayName = displayName, + email = email, + avatarUrl = avatar, +) + +/** Maps a presence heartbeat row into the domain [Presence]. */ +fun PresenceDto.toDomain(): Presence = Presence( + userId = resolvedUserId, + displayName = resolvedDisplayName, + username = resolvedUsername, + avatarUrl = resolvedAvatar, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt index c4619e4..6c2a91d 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt @@ -1,5 +1,8 @@ package com.interlinedlist.android.feature.documents.data.remote +import com.interlinedlist.android.feature.documents.data.remote.dto.CollaboratorEnvelope +import com.interlinedlist.android.feature.documents.data.remote.dto.CollaboratorUsersResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.CollaboratorsResponse import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest @@ -8,9 +11,15 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentResp import com.interlinedlist.android.feature.documents.data.remote.dto.FolderListResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FolderResponse import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.InviteCollaboratorRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.PresenceResponse import com.interlinedlist.android.feature.documents.data.remote.dto.ShareLinkEnvelope import com.interlinedlist.android.feature.documents.data.remote.dto.ShareLinksResponse import com.interlinedlist.android.feature.documents.data.remote.dto.SharedDocumentResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.SyncPullResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.SyncPushRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.TreeResponse +import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateCollaboratorRoleRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.UpdateFolderRequest import okhttp3.MultipartBody @@ -18,7 +27,9 @@ import okhttp3.ResponseBody import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.Multipart +import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Part @@ -49,6 +60,18 @@ interface DocumentsApi { @Body body: UpdateDocumentRequest, ): DocumentResponse + /** + * Partial update with optimistic concurrency. The document's current [ifMatch] + * version is sent as the `If-Match` header; a stale value is rejected by the + * server (surfaced as a conflict) rather than overwriting a concurrent edit. + */ + @PATCH("api/documents/{id}") + suspend fun patchDocument( + @Path("id") id: String, + @Body body: UpdateDocumentRequest, + @Header("If-Match") ifMatch: String?, + ): DocumentResponse + @DELETE("api/documents/{id}") suspend fun deleteDocument(@Path("id") id: String) @@ -129,4 +152,65 @@ interface DocumentsApi { /** Claims edit/admin access to a shared document as the logged-in user. */ @POST("api/documents/shared/{token}") suspend fun claimSharedDocument(@Path("token") token: String) + + // --- Delta sync -------------------------------------------------------- + + /** + * Delta PULL: folders + documents changed since [lastSyncAt] (both upserts and + * `deletedAt` tombstones), plus a fresh `lastSyncAt` cursor to persist. A null + * cursor returns the full set. + */ + @GET("api/documents/sync") + suspend fun pullSync(@Query("lastSyncAt") lastSyncAt: String?): SyncPullResponse + + /** Batch PUSH of queued local operations. */ + @POST("api/documents/sync") + suspend fun pushSync(@Body body: SyncPushRequest): SyncPullResponse + + /** Combined folder + document sidebar tree (folders embed their documents). */ + @GET("api/documents/tree") + suspend fun getTree(): TreeResponse + + // --- Collaborators ----------------------------------------------------- + + @GET("api/documents/{id}/collaborators") + suspend fun getCollaborators(@Path("id") id: String): CollaboratorsResponse + + /** Searches users who can be invited (optionally excluding current collaborators). */ + @GET("api/documents/{id}/collaborators/users") + suspend fun searchCollaboratorUsers( + @Path("id") id: String, + @Query("search") search: String?, + @Query("limit") limit: Int? = null, + @Query("excludeCollaborators") excludeCollaborators: Boolean? = null, + ): CollaboratorUsersResponse + + @POST("api/documents/{id}/collaborators") + suspend fun inviteCollaborator( + @Path("id") id: String, + @Body body: InviteCollaboratorRequest, + ): CollaboratorEnvelope + + @PUT("api/documents/{id}/collaborators/{userId}") + suspend fun updateCollaboratorRole( + @Path("id") id: String, + @Path("userId") userId: String, + @Body body: UpdateCollaboratorRoleRequest, + ): CollaboratorEnvelope + + @DELETE("api/documents/{id}/collaborators/{userId}") + suspend fun removeCollaborator( + @Path("id") id: String, + @Path("userId") userId: String, + ) + + // --- Presence ---------------------------------------------------------- + + /** Heartbeat: marks the current user present on the document; returns everyone here. */ + @POST("api/documents/{id}/presence") + suspend fun sendPresence(@Path("id") id: String): PresenceResponse + + /** Leaves the document (stops the heartbeat). */ + @DELETE("api/documents/{id}/presence") + suspend fun leavePresence(@Path("id") id: String) } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/CollaboratorDtos.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/CollaboratorDtos.kt new file mode 100644 index 0000000..08b5afd --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/CollaboratorDtos.kt @@ -0,0 +1,86 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * A user profile as embedded in collaborator/search responses. All fields default + * so the DTO tolerates the shape variation across endpoints (with the shared, + * lenient Json: `ignoreUnknownKeys` + `coerceInputValues`). + */ +@Serializable +data class UserSummaryDto( + val id: String = "", + val username: String = "", + val displayName: String? = null, + val email: String? = null, + val avatar: String? = null, +) + +/** + * A collaborator row (`DocumentCollaborator`: id, userId, documentId, role, + * createdAt). Profile details may be flattened onto the row or nested under `user` + * depending on the endpoint; [resolvedUserId] and the accessors below tolerate both. + */ +@Serializable +data class CollaboratorDto( + val id: String = "", + val userId: String? = null, + val documentId: String? = null, + val role: String? = null, + val createdAt: String? = null, + val user: UserSummaryDto? = null, + // Flattened profile fields, when the server inlines them onto the row. + val username: String? = null, + val displayName: String? = null, + val email: String? = null, + val avatar: String? = null, +) { + val resolvedUserId: String get() = userId ?: user?.id.orEmpty() + val resolvedUsername: String? get() = username ?: user?.username + val resolvedDisplayName: String? get() = displayName ?: user?.displayName + val resolvedEmail: String? get() = email ?: user?.email + val resolvedAvatar: String? get() = avatar ?: user?.avatar +} + +/** `GET /api/documents/{id}/collaborators` — `{ collaborators, pagination }`. */ +@Serializable +data class CollaboratorsResponse( + val collaborators: List = emptyList(), + val data: List? = null, + val pagination: PaginationDto? = null, +) { + val items: List get() = data ?: collaborators +} + +/** + * A single collaborator, returned bare or wrapped in `{ "collaborator": ... }` from + * the invite (`POST`) / role-change (`PUT`) endpoints. + */ +@Serializable +data class CollaboratorEnvelope( + val collaborator: CollaboratorDto? = null, + val data: CollaboratorDto? = null, +) { + val collaboratorOrNull: CollaboratorDto? get() = collaborator ?: data +} + +/** `GET /api/documents/{id}/collaborators/users?search=` — `{ users, total, pagination }`. */ +@Serializable +data class CollaboratorUsersResponse( + val users: List = emptyList(), + val total: Int = 0, + val pagination: PaginationDto? = null, +) + +/** Body for `POST /api/documents/{id}/collaborators` — invite a user at a role. */ +@Serializable +data class InviteCollaboratorRequest( + val userId: String, + val role: String, +) + +/** Body for `PUT /api/documents/{id}/collaborators/{userId}` — change a role. */ +@Serializable +data class UpdateCollaboratorRoleRequest( + val role: String, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt index 1cc95f1..30ed673 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentDto.kt @@ -20,4 +20,6 @@ data class DocumentDto( val isPublic: Boolean = false, val updatedAt: String? = null, val createdAt: String? = null, + // Optimistic-concurrency token supplied by the detail/sync endpoints. + val version: Int? = null, ) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt index 6d14ad8..eb5c1f0 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt @@ -42,6 +42,7 @@ data class DocumentResponse( val isPublic: Boolean = false, val updatedAt: String? = null, val createdAt: String? = null, + val version: Int? = null, ) { /** The document payload, whether wrapped or inlined at the top level. */ val documentOrSelf: DocumentDto? @@ -57,6 +58,7 @@ data class DocumentResponse( isPublic = isPublic, updatedAt = updatedAt, createdAt = createdAt, + version = version, ) } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/PresenceDtos.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/PresenceDtos.kt new file mode 100644 index 0000000..3800cc8 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/PresenceDtos.kt @@ -0,0 +1,41 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * A presence heartbeat row (`DocumentPresence`: id, documentId, userId, updatedAt). + * Cursor offsets exist server-side but are ignored here — we only render "who is + * here". Profile bits may be inlined or nested under `user`; accessors tolerate both. + */ +@Serializable +data class PresenceDto( + val id: String = "", + val documentId: String? = null, + val userId: String? = null, + val updatedAt: String? = null, + val user: UserSummaryDto? = null, + val username: String? = null, + val displayName: String? = null, + val avatar: String? = null, +) { + val resolvedUserId: String get() = userId ?: user?.id.orEmpty() + val resolvedUsername: String? get() = username ?: user?.username + val resolvedDisplayName: String? get() = displayName ?: user?.displayName + val resolvedAvatar: String? get() = avatar ?: user?.avatar +} + +/** + * Response for `POST /api/documents/{id}/presence` — the current heartbeat plus the + * set of everyone present. Shapes vary; both `presence`/`participants` and a bare + * list under `data` are tolerated. + */ +@Serializable +data class PresenceResponse( + val presences: List? = null, + val participants: List? = null, + val data: List? = null, + val presence: PresenceDto? = null, +) { + val items: List + get() = presences ?: participants ?: data ?: presence?.let { listOf(it) } ?: emptyList() +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/SyncDtos.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/SyncDtos.kt new file mode 100644 index 0000000..b317cab --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/SyncDtos.kt @@ -0,0 +1,79 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * A folder as returned by the delta-sync endpoint. Unlike [FolderDto] (the nested + * tree), the sync row is flat and carries a [deletedAt] tombstone so the client can + * reconcile removals as well as upserts. + */ +@Serializable +data class SyncFolderDto( + val id: String, + val name: String? = null, + val parentId: String? = null, + val createdAt: String? = null, + val updatedAt: String? = null, + val deletedAt: String? = null, +) { + /** True when this row is a tombstone (soft-deleted server-side). */ + val isDeleted: Boolean get() = deletedAt != null +} + +/** + * A document as returned by the delta-sync endpoint: flat, versioned, and with a + * [deletedAt] tombstone. [version] is the optimistic-concurrency token used by + * `PATCH`'s `If-Match` header. + */ +@Serializable +data class SyncDocumentDto( + val id: String, + val title: String? = null, + val content: String? = null, + val folderId: String? = null, + val isPublic: Boolean = false, + val updatedAt: String? = null, + val createdAt: String? = null, + val version: Int? = null, + val deletedAt: String? = null, +) { + val isDeleted: Boolean get() = deletedAt != null +} + +/** + * `GET /api/documents/sync[?lastSyncAt=]` — the delta PULL. Returns folders + * and documents changed since the cursor (both upserts and [deletedAt] tombstones) + * plus a fresh [lastSyncAt] to persist as the next cursor. With no cursor the server + * returns the full set; a future cursor returns empty deltas. + */ +@Serializable +data class SyncPullResponse( + val folders: List = emptyList(), + val documents: List = emptyList(), + val lastSyncAt: String? = null, +) + +/** + * One queued local mutation to replay against the server via `POST /api/documents/sync`. + * [op] is `update` or `delete`; other fields are the payload for an update. + */ +@Serializable +data class SyncOperationDto( + val id: String, + val op: String, + val title: String? = null, + val content: String? = null, + val isPublic: Boolean? = null, + val folderId: String? = null, + val version: Int? = null, +) + +/** + * Body for the batch PUSH (`POST /api/documents/sync`). The server models + * `operations` loosely (a string in the spec); we send a typed JSON array which the + * shared, lenient Json serialises. + */ +@Serializable +data class SyncPushRequest( + val operations: List, +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/TreeDto.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/TreeDto.kt new file mode 100644 index 0000000..2835740 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/TreeDto.kt @@ -0,0 +1,15 @@ +package com.interlinedlist.android.feature.documents.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/documents/tree` — the combined sidebar tree in one call. Folders nest + * their own documents (verified live: `folders[].documents`); unfiled documents + * arrive under `rootDocuments`. This mirrors `/folders` + `/documents` but in a + * single round-trip, so it can back the browser refresh where that simplifies code. + */ +@Serializable +data class TreeResponse( + val folders: List = emptyList(), + val rootDocuments: List = emptyList(), +) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt index d9f0505..ef614ec 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/di/DocumentsModule.kt @@ -7,6 +7,8 @@ import com.interlinedlist.android.feature.documents.data.DocumentsRepository import com.interlinedlist.android.feature.documents.data.local.DocumentDao import com.interlinedlist.android.feature.documents.data.local.DocumentsDatabase import com.interlinedlist.android.feature.documents.data.local.FolderDao +import com.interlinedlist.android.feature.documents.data.local.PendingOpDao +import com.interlinedlist.android.feature.documents.data.local.SyncMetaDao import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import dagger.Binds import dagger.Module @@ -53,4 +55,10 @@ object DocumentsDataModule { @Provides fun provideFolderDao(db: DocumentsDatabase): FolderDao = db.folderDao() + + @Provides + fun providePendingOpDao(db: DocumentsDatabase): PendingOpDao = db.pendingOpDao() + + @Provides + fun provideSyncMetaDao(db: DocumentsDatabase): SyncMetaDao = db.syncMetaDao() } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Collaborator.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Collaborator.kt new file mode 100644 index 0000000..9714aa8 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Collaborator.kt @@ -0,0 +1,63 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * A person granted direct access to a document (distinct from a public share + * link). [userId] identifies the account; [role] is the access level. [displayName] + * / [username] / [avatarUrl] are best-effort profile bits for rendering a row, and + * may be blank when the list endpoint returns only the bare collaborator record. + */ +data class Collaborator( + val userId: String, + val role: CollaboratorRole, + val displayName: String?, + val username: String?, + val email: String?, + val avatarUrl: String?, +) { + /** A short label for the row / avatar, preferring the friendliest identifier. */ + val label: String + get() = displayName?.takeIf { it.isNotBlank() } + ?: username?.takeIf { it.isNotBlank() } + ?: email?.takeIf { it.isNotBlank() } + ?: userId + + /** A single-letter avatar fallback. */ + val initial: String get() = label.trim().firstOrNull()?.uppercase() ?: "?" +} + +/** + * Access a collaborator holds on a document. Mirrors the web app's viewer / editor + * / admin levels; unknown or absent server values map to [VIEWER] so access is + * never inadvertently escalated. + */ +enum class CollaboratorRole(val apiValue: String, val label: String) { + VIEWER("viewer", "Viewer"), + EDITOR("editor", "Editor"), + ADMIN("admin", "Admin"); + + companion object { + /** Maps an API role string (case-insensitive) to a [CollaboratorRole]. */ + fun fromApi(raw: String?): CollaboratorRole = when (raw?.trim()?.lowercase()) { + "editor", "edit", "write" -> EDITOR + "admin", "owner" -> ADMIN + else -> VIEWER + } + } +} + +/** + * A user surfaced by the collaborator search (`/collaborators/users`) — a candidate + * to invite. Carries just enough to render a pick row and issue the invite. + */ +data class CollaboratorCandidate( + val userId: String, + val username: String, + val displayName: String?, + val email: String?, + val avatarUrl: String?, +) { + val label: String + get() = displayName?.takeIf { it.isNotBlank() } ?: username.takeIf { it.isNotBlank() } ?: email.orEmpty() + + val initial: String get() = label.trim().firstOrNull()?.uppercase() ?: "?" +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt index 24f68a1..4448f7a 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Document.kt @@ -14,6 +14,13 @@ data class Document( val folderName: String?, val isPublic: Boolean, val updatedAt: String?, + /** + * Optimistic-concurrency token: the server's row version. Sent back as an + * `If-Match` header on `PATCH` so a save that raced another writer is rejected + * rather than silently clobbering their change. Null until a versioned response + * (sync / detail) has populated it. + */ + val version: Int? = null, ) { companion object { /** Longest preview we keep for the index snippet, in characters. */ diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Presence.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Presence.kt new file mode 100644 index 0000000..a203aed --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/domain/Presence.kt @@ -0,0 +1,18 @@ +package com.interlinedlist.android.feature.documents.domain + +/** + * A single participant currently viewing a document, derived from the server's + * lightweight presence heartbeats. Full live-cursor sync is out of scope — we only + * surface "who is here" as avatars, so cursor offsets are intentionally omitted. + */ +data class Presence( + val userId: String, + val displayName: String?, + val username: String?, + val avatarUrl: String?, +) { + val label: String + get() = displayName?.takeIf { it.isNotBlank() } ?: username?.takeIf { it.isNotBlank() } ?: userId + + val initial: String get() = label.trim().firstOrNull()?.uppercase() ?: "?" +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncScheduler.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncScheduler.kt new file mode 100644 index 0000000..b7987b3 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncScheduler.kt @@ -0,0 +1,61 @@ +package com.interlinedlist.android.feature.documents.sync + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import java.time.Duration + +/** + * Schedules the documents delta-sync work. Keep this the single place that enqueues + * [DocumentsSyncWorker] so the unique-work names and constraints stay consistent. + * + * Wiring (call from the app, e.g. after sign-in / on app start): + * ``` + * DocumentsSyncScheduler.schedulePeriodic(context) // hourly background pull+push + * DocumentsSyncScheduler.syncNow(context) // e.g. on foreground / after a save + * ``` + */ +object DocumentsSyncScheduler { + + private val NETWORK_CONSTRAINTS = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + /** Periodic background sync (min WorkManager interval is 15 minutes; we use 1 hour). */ + fun schedulePeriodic(context: Context, interval: Duration = Duration.ofHours(1)) { + val request = PeriodicWorkRequestBuilder(interval) + .setConstraints(NETWORK_CONSTRAINTS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(30)) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + DocumentsSyncWorker.PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + + /** One-shot sync now — e.g. after a save, or when the documents area is opened. */ + fun syncNow(context: Context) { + val request = OneTimeWorkRequestBuilder() + .setConstraints(NETWORK_CONSTRAINTS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, Duration.ofSeconds(15)) + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + DocumentsSyncWorker.ONE_SHOT_WORK_NAME, + ExistingWorkPolicy.REPLACE, + request, + ) + } + + /** Cancels all scheduled documents sync (e.g. on sign-out). */ + fun cancelAll(context: Context) { + WorkManager.getInstance(context).cancelUniqueWork(DocumentsSyncWorker.PERIODIC_WORK_NAME) + WorkManager.getInstance(context).cancelUniqueWork(DocumentsSyncWorker.ONE_SHOT_WORK_NAME) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncWorker.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncWorker.kt new file mode 100644 index 0000000..4ef1289 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/sync/DocumentsSyncWorker.kt @@ -0,0 +1,42 @@ +package com.interlinedlist.android.feature.documents.sync + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Background delta-sync for documents. On each run it first pushes any queued local + * edits (`POST /sync`) so offline changes reach the server, then pulls remote deltas + * (`GET /sync`) and reconciles them into the module's Room cache. Transient failures + * ask WorkManager to [retry] with its backoff policy; the reconciliation itself is + * idempotent (upsert by id/version, tombstones remove), so a re-run is safe. + */ +@HiltWorker +class DocumentsSyncWorker @AssistedInject constructor( + @Assisted appContext: Context, + @Assisted params: WorkerParameters, + private val repository: DocumentsRepository, +) : CoroutineWorker(appContext, params) { + + override suspend fun doWork(): Result { + // Push first so a stale pull never overwrites a not-yet-sent local edit. + val push = repository.pushPendingOps() + if (push is ApiResult.Failure) return Result.retry() + + return when (repository.pullDelta()) { + is ApiResult.Success -> Result.success() + is ApiResult.Failure -> Result.retry() + } + } + + companion object { + /** Unique names for the scheduled work (see [DocumentsSyncScheduler]). */ + const val PERIODIC_WORK_NAME = "documents-delta-sync-periodic" + const val ONE_SHOT_WORK_NAME = "documents-delta-sync-oneshot" + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt new file mode 100644 index 0000000..8cdc0d2 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsScreen.kt @@ -0,0 +1,331 @@ +package com.interlinedlist.android.feature.documents.ui.collaborators + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.outlined.Group +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole + +/** Stable test tags for the Manage-access sheet. */ +object DocumentCollaboratorsTestTags { + const val SHEET = "collabSheet" + const val LIST = "collabList" + const val EMPTY = "collabEmpty" + const val PROGRESS = "collabProgress" + const val ERROR = "collabError" + const val SEARCH_FIELD = "collabSearchField" + const val SEARCH_BUTTON = "collabSearchButton" + fun row(userId: String) = "collabRow_$userId" + fun revoke(userId: String) = "collabRevoke_$userId" + fun role(userId: String, role: CollaboratorRole) = "collabRole_${userId}_${role.apiValue}" + fun candidate(userId: String) = "collabCandidate_$userId" + fun inviteRole(role: CollaboratorRole) = "collabInviteRole_${role.apiValue}" +} + +/** + * Hilt-wired "Manage access" sheet, shown as a modal bottom sheet over the editor. + * Reads its `documentId` from the nav SavedStateHandle (see [COLLABORATORS_DOCUMENT_ID_ARG]). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentCollaboratorsRoute( + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + viewModel: DocumentCollaboratorsViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + modifier = modifier.testTag(DocumentCollaboratorsTestTags.SHEET), + ) { + DocumentCollaboratorsSheetContent( + state = state, + onSearchQueryChange = viewModel::onSearchQueryChange, + onSearch = viewModel::searchUsers, + onSelectInviteRole = viewModel::selectRole, + onInvite = viewModel::invite, + onChangeRole = viewModel::changeRole, + onRevoke = viewModel::revoke, + ) + } +} + +/** Stateless Manage-access body: current collaborators + role controls + invite search. */ +@Composable +fun DocumentCollaboratorsSheetContent( + state: DocumentCollaboratorsUiState, + onSearchQueryChange: (String) -> Unit, + onSearch: () -> Unit, + onSelectInviteRole: (CollaboratorRole) -> Unit, + onInvite: (CollaboratorCandidate) -> Unit, + onChangeRole: (String, CollaboratorRole) -> Unit, + onRevoke: (String) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 24.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Outlined.Group, contentDescription = null) + Text("Manage access", style = MaterialTheme.typography.titleLarge) + } + Spacer(Modifier.height(4.dp)) + Text( + text = "Invite people and choose what they can do with this document.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (state.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.ERROR), + ) + } + + // --- Invite ------------------------------------------------------- + Spacer(Modifier.height(16.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = state.searchQuery, + onValueChange = onSearchQueryChange, + label = { Text("Search people") }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag(DocumentCollaboratorsTestTags.SEARCH_FIELD), + ) + IconButton( + onClick = onSearch, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.SEARCH_BUTTON), + ) { Icon(Icons.Outlined.Search, contentDescription = "Search") } + } + + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CollaboratorRole.entries.forEach { role -> + FilterChip( + selected = state.selectedRole == role, + onClick = { onSelectInviteRole(role) }, + label = { Text(role.label) }, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.inviteRole(role)), + ) + } + } + + if (state.candidates.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 180.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(state.candidates, key = { it.userId }) { candidate -> + CandidateRow(candidate = candidate, onInvite = { onInvite(candidate) }) + } + } + } + + // --- Current collaborators --------------------------------------- + Spacer(Modifier.height(16.dp)) + Text("People with access", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(8.dp)) + + when { + state.isLoading -> Box( + Modifier.fillMaxWidth().padding(24.dp), + contentAlignment = Alignment.Center, + ) { CircularProgressIndicator(Modifier.testTag(DocumentCollaboratorsTestTags.PROGRESS)) } + + state.isEmpty -> Text( + text = "No collaborators yet. Search above to invite someone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 320.dp) + .testTag(DocumentCollaboratorsTestTags.LIST), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.collaborators, key = { it.userId }) { collaborator -> + CollaboratorRow( + collaborator = collaborator, + onChangeRole = { role -> onChangeRole(collaborator.userId, role) }, + onRevoke = { onRevoke(collaborator.userId) }, + ) + } + } + } + } +} + +@Composable +private fun Avatar(initial: String, modifier: Modifier = Modifier) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer, + modifier = modifier.size(36.dp), + ) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Text( + text = initial, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + +@Composable +private fun CandidateRow(candidate: CollaboratorCandidate, onInvite: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentCollaboratorsTestTags.candidate(candidate.userId)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(candidate.initial) + Column(Modifier.weight(1f)) { + Text(candidate.label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + candidate.email?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + IconButton(onClick = onInvite) { + Icon(Icons.Default.PersonAdd, contentDescription = "Invite ${candidate.label}") + } + } +} + +@Composable +private fun CollaboratorRow( + collaborator: Collaborator, + onChangeRole: (CollaboratorRole) -> Unit, + onRevoke: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .testTag(DocumentCollaboratorsTestTags.row(collaborator.userId)), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Avatar(collaborator.initial) + Column(Modifier.weight(1f)) { + Text(collaborator.label, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + collaborator.email?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + IconButton( + onClick = onRevoke, + modifier = Modifier.testTag(DocumentCollaboratorsTestTags.revoke(collaborator.userId)), + ) { Icon(Icons.Default.Close, contentDescription = "Remove ${collaborator.label}") } + } + Row( + modifier = Modifier.padding(start = 48.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + CollaboratorRole.entries.forEach { role -> + FilterChip( + selected = collaborator.role == role, + onClick = { onChangeRole(role) }, + label = { Text(role.label) }, + leadingIcon = if (collaborator.role == role) { + { Icon(Icons.Default.Check, contentDescription = null) } + } else { + null + }, + modifier = Modifier.testTag( + DocumentCollaboratorsTestTags.role(collaborator.userId, role), + ), + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentCollaboratorsSheetPreview() { + InterlinedListTheme { + DocumentCollaboratorsSheetContent( + state = DocumentCollaboratorsUiState( + collaborators = listOf( + Collaborator("u1", CollaboratorRole.ADMIN, "Ada Lovelace", "ada", "ada@x.io", null), + Collaborator("u2", CollaboratorRole.VIEWER, "Bob", "bob", null, null), + ), + isLoading = false, + ), + onSearchQueryChange = {}, + onSearch = {}, + onSelectInviteRole = {}, + onInvite = {}, + onChangeRole = { _, _ -> }, + onRevoke = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt new file mode 100644 index 0000000..30263ce --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModel.kt @@ -0,0 +1,161 @@ +package com.interlinedlist.android.feature.documents.ui.collaborators + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav argument key the Manage-access route reads its document id from. */ +const val COLLABORATORS_DOCUMENT_ID_ARG = "documentId" + +/** UI state for the "Manage access" sheet. */ +data class DocumentCollaboratorsUiState( + val collaborators: List = emptyList(), + val candidates: List = emptyList(), + val searchQuery: String = "", + val selectedRole: CollaboratorRole = CollaboratorRole.VIEWER, + val isLoading: Boolean = true, + val isSearching: Boolean = false, + val errorMessage: String? = null, +) { + val isEmpty: Boolean get() = collaborators.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the Manage-access sheet: list collaborators, search invitable users, invite + * at a chosen role, change a role, and revoke — all with optimistic UI + rollback so + * the sheet feels instant and self-corrects on failure. + */ +@HiltViewModel +class DocumentCollaboratorsViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val documentId: String = requireNotNull(savedStateHandle[COLLABORATORS_DOCUMENT_ID_ARG]) { + "DocumentCollaboratorsViewModel requires a '$COLLABORATORS_DOCUMENT_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(DocumentCollaboratorsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.getCollaborators(documentId)) { + is ApiResult.Success -> _uiState.update { + it.copy(collaborators = result.data, isLoading = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun onSearchQueryChange(query: String) = _uiState.update { it.copy(searchQuery = query) } + + fun selectRole(role: CollaboratorRole) = _uiState.update { it.copy(selectedRole = role) } + + fun searchUsers() { + val query = _uiState.value.searchQuery + _uiState.update { it.copy(isSearching = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.searchCollaboratorUsers(documentId, query)) { + is ApiResult.Success -> _uiState.update { + it.copy(candidates = result.data, isSearching = false) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isSearching = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Invites [candidate] at the selected role, optimistically; rolls back on failure. */ + fun invite(candidate: CollaboratorCandidate) { + val role = _uiState.value.selectedRole + val previous = _uiState.value.collaborators + val optimistic = Collaborator( + userId = candidate.userId, + role = role, + displayName = candidate.displayName, + username = candidate.username, + email = candidate.email, + avatarUrl = candidate.avatarUrl, + ) + _uiState.update { + it.copy( + collaborators = it.collaborators.filterNot { c -> c.userId == candidate.userId } + optimistic, + candidates = it.candidates.filterNot { u -> u.userId == candidate.userId }, + ) + } + viewModelScope.launch { + when (val result = repository.inviteCollaborator(documentId, candidate.userId, role)) { + is ApiResult.Success -> _uiState.update { state -> + // Replace the optimistic row with the server's authoritative record. + state.copy( + collaborators = state.collaborators.map { + if (it.userId == candidate.userId) result.data else it + }, + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(collaborators = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Optimistically applies a role change; rolls back on failure. */ + fun changeRole(userId: String, role: CollaboratorRole) { + val previous = _uiState.value.collaborators + _uiState.update { state -> + state.copy( + collaborators = state.collaborators.map { + if (it.userId == userId) it.copy(role = role) else it + }, + ) + } + viewModelScope.launch { + when (val result = repository.updateCollaboratorRole(documentId, userId, role)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> _uiState.update { + it.copy(collaborators = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + /** Optimistically removes a collaborator; restores them on failure. */ + fun revoke(userId: String) { + val previous = _uiState.value.collaborators + _uiState.update { it.copy(collaborators = it.collaborators.filterNot { c -> c.userId == userId }) } + viewModelScope.launch { + when (val result = repository.removeCollaborator(documentId, userId)) { + is ApiResult.Success -> Unit + is ApiResult.Failure -> _uiState.update { + it.copy(collaborators = previous, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt index a3c8664..2fd1021 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.outlined.Group import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -25,9 +26,12 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -39,7 +43,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.ui.common.MarkdownText +import com.interlinedlist.android.feature.documents.ui.presence.PresenceIndicator /** Stable test tags for the editor. */ object DocumentEditorTestTags { @@ -50,9 +56,14 @@ object DocumentEditorTestTags { const val DELETE = "editorDelete" const val UPLOAD_IMAGE = "editorUploadImage" const val SHARE = "editorShare" + const val MANAGE_ACCESS = "editorManageAccess" const val TOGGLE_PREVIEW = "editorTogglePreview" const val PROGRESS = "editorProgress" const val ERROR = "editorError" + const val CONFLICT_BANNER = "editorConflictBanner" + const val CONFLICT_RELOAD = "editorConflictReload" + const val CONFLICT_RETRY = "editorConflictRetry" + const val OFFLINE_HINT = "editorOfflineHint" } /** @@ -66,11 +77,20 @@ fun DocumentEditorRoute( onDeleted: () -> Unit, modifier: Modifier = Modifier, onOpenShare: () -> Unit = {}, + onOpenManageAccess: () -> Unit = {}, + presenceViewModel: com.interlinedlist.android.feature.documents.ui.presence.DocumentPresenceViewModel = hiltViewModel(), viewModel: DocumentEditorViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val presenceState by presenceViewModel.uiState.collectAsStateWithLifecycle() val context = androidx.compose.ui.platform.LocalContext.current + // Heartbeat presence while the editor is on screen; leave when it disappears. + androidx.compose.runtime.DisposableEffect(Unit) { + presenceViewModel.start() + onDispose { presenceViewModel.stop() } + } + // Android Photo Picker: reads the picked image's bytes and hands them to the VM. val pickImage = rememberLauncherForActivityResult( ActivityResultContracts.PickVisualMedia(), @@ -102,6 +122,10 @@ fun DocumentEditorRoute( }, onBack = onBack, onOpenShare = onOpenShare, + onOpenManageAccess = onOpenManageAccess, + onReloadConflict = viewModel::reloadForConflict, + onRetrySave = { viewModel.save() }, + presenceParticipants = presenceState.participants, modifier = modifier, ) } @@ -120,6 +144,10 @@ fun DocumentEditorScreen( modifier: Modifier = Modifier, onPickImage: () -> Unit = {}, onOpenShare: () -> Unit = {}, + onOpenManageAccess: () -> Unit = {}, + onReloadConflict: () -> Unit = {}, + onRetrySave: () -> Unit = {}, + presenceParticipants: List = emptyList(), ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -138,6 +166,10 @@ fun DocumentEditorScreen( } }, actions = { + PresenceIndicator( + participants = presenceParticipants, + modifier = Modifier.padding(end = 4.dp), + ) IconButton( onClick = onPickImage, enabled = !state.isUploadingImage && !state.isSaving, @@ -149,6 +181,12 @@ fun DocumentEditorScreen( Icon(Icons.Default.Image, contentDescription = "Insert image") } } + IconButton( + onClick = onOpenManageAccess, + modifier = Modifier.testTag(DocumentEditorTestTags.MANAGE_ACCESS), + ) { + Icon(Icons.Outlined.Group, contentDescription = "Manage access") + } IconButton( onClick = onOpenShare, modifier = Modifier.testTag(DocumentEditorTestTags.SHARE), @@ -204,7 +242,14 @@ fun DocumentEditorScreen( .imePadding() .padding(horizontal = 16.dp), ) { - if (state.errorMessage != null) { + if (state.hasConflict) { + ConflictBanner( + message = state.errorMessage + ?: "This document changed since you opened it.", + onReload = onReloadConflict, + onRetry = onRetrySave, + ) + } else if (state.errorMessage != null) { Text( text = state.errorMessage, color = MaterialTheme.colorScheme.error, @@ -216,6 +261,18 @@ fun DocumentEditorScreen( ) } + if (state.isQueuedOffline) { + Text( + text = "Saved offline. Changes will sync when you're back online.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .testTag(DocumentEditorTestTags.OFFLINE_HINT), + ) + } + OutlinedTextField( value = state.title, onValueChange = onTitleChange, @@ -252,6 +309,40 @@ fun DocumentEditorScreen( } } +/** A save-conflict banner offering Reload (take server copy) or Retry (overwrite). */ +@Composable +private fun ConflictBanner( + message: String, + onReload: () -> Unit, + onRetry: () -> Unit, +) { + Surface( + color = MaterialTheme.colorScheme.errorContainer, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + .testTag(DocumentEditorTestTags.CONFLICT_BANNER), + ) { + Column(Modifier.padding(12.dp)) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton( + onClick = onReload, + modifier = Modifier.testTag(DocumentEditorTestTags.CONFLICT_RELOAD), + ) { Text("Reload latest") } + TextButton( + onClick = onRetry, + modifier = Modifier.testTag(DocumentEditorTestTags.CONFLICT_RETRY), + ) { Text("Retry") } + } + } + } +} + @Preview(showBackground = true) @Composable private fun DocumentEditorScreenPreview() { diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt index 2cca16f..6e4dccb 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/editor/DocumentEditorViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.data.SaveOutcome import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate import com.interlinedlist.android.feature.documents.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -22,11 +23,17 @@ data class DocumentEditorUiState( val content: String = "", val isPublic: Boolean = false, val folderId: String? = null, + /** Server version token used for optimistic-concurrency on save (PATCH If-Match). */ + val version: Int? = null, val isLoading: Boolean = true, val isSaving: Boolean = false, val isUploadingImage: Boolean = false, val isPreview: Boolean = false, val hasUnsavedChanges: Boolean = false, + /** True when the last save was rejected because another writer changed the doc. */ + val hasConflict: Boolean = false, + /** True when the last save could not reach the server and was queued to sync later. */ + val isQueuedOffline: Boolean = false, val errorMessage: String? = null, val subscriptionRequired: Boolean = false, ) { @@ -65,6 +72,7 @@ class DocumentEditorViewModel @Inject constructor( content = cached.content ?: it.content, isPublic = cached.isPublic, folderId = cached.folderId, + version = cached.version ?: it.version, ) } } @@ -73,21 +81,26 @@ class DocumentEditorViewModel @Inject constructor( } /** Fetches the full document (with body) from the API. */ - fun refresh() { + fun refresh(discardLocalEdits: Boolean = false) { _uiState.update { it.copy(isLoading = true, errorMessage = null) } viewModelScope.launch { when (val result = repository.refreshDocument(documentId)) { is ApiResult.Success -> _uiState.update { - // Don't clobber in-progress edits with the server copy. - if (it.hasUnsavedChanges) { - it.copy(isLoading = false) + // Don't clobber in-progress edits with the server copy, unless the + // caller explicitly resolves a conflict by discarding them. + if (it.hasUnsavedChanges && !discardLocalEdits) { + it.copy(isLoading = false, version = result.data.version ?: it.version) } else { it.copy( title = result.data.title, content = result.data.content ?: "", isPublic = result.data.isPublic, folderId = result.data.folderId, + version = result.data.version ?: it.version, isLoading = false, + hasUnsavedChanges = false, + hasConflict = false, + isQueuedOffline = false, subscriptionRequired = false, ) } @@ -103,11 +116,18 @@ class DocumentEditorViewModel @Inject constructor( } } + /** Resolves a save conflict by reloading the latest server copy, discarding local edits. */ + fun reloadForConflict() = refresh(discardLocalEdits = true) + fun onTitleChange(value: String) = - _uiState.update { it.copy(title = value, hasUnsavedChanges = true, errorMessage = null) } + _uiState.update { + it.copy(title = value, hasUnsavedChanges = true, hasConflict = false, isQueuedOffline = false, errorMessage = null) + } fun onContentChange(value: String) = - _uiState.update { it.copy(content = value, hasUnsavedChanges = true, errorMessage = null) } + _uiState.update { + it.copy(content = value, hasUnsavedChanges = true, hasConflict = false, isQueuedOffline = false, errorMessage = null) + } fun togglePreview() = _uiState.update { it.copy(isPreview = !it.isPreview) } @@ -136,26 +156,53 @@ class DocumentEditorViewModel @Inject constructor( } } - /** Persists edits; invokes [onSaved] on success. */ + /** + * Persists edits via `PATCH` with the current version token so a concurrent + * writer's change is detected rather than clobbered. On success (or an offline + * queue) invokes [onSaved]; on a version conflict raises [DocumentEditorUiState.hasConflict] + * so the screen can offer reload/retry. + */ fun save(onSaved: () -> Unit = {}) { val state = _uiState.value if (!state.canSave) return - _uiState.update { it.copy(isSaving = true, errorMessage = null) } + _uiState.update { it.copy(isSaving = true, errorMessage = null, hasConflict = false, isQueuedOffline = false) } viewModelScope.launch { - val result = repository.updateDocument( + val outcome = repository.patchDocument( id = documentId, title = state.title.trim().ifBlank { "Untitled" }, content = state.content, isPublic = state.isPublic, folderId = state.folderId, + expectedVersion = state.version, ) - when (result) { - is ApiResult.Success -> { - _uiState.update { it.copy(isSaving = false, hasUnsavedChanges = false) } + when (outcome) { + is SaveOutcome.Success -> { + _uiState.update { + it.copy( + isSaving = false, + hasUnsavedChanges = false, + version = outcome.document.version ?: it.version, + ) + } onSaved() } - is ApiResult.Failure -> _uiState.update { - it.copy(isSaving = false, errorMessage = result.error.toUserMessage()) + is SaveOutcome.Queued -> { + // Persisted locally; the sync worker will push it. Treat as saved for UX. + _uiState.update { + it.copy(isSaving = false, hasUnsavedChanges = false, isQueuedOffline = true) + } + onSaved() + } + is SaveOutcome.Conflict -> _uiState.update { + it.copy( + isSaving = false, + hasConflict = true, + errorMessage = outcome.message + ?: "This document changed since you opened it. Reload to see the latest, or retry to overwrite.", + ) + } + is SaveOutcome.Error -> _uiState.update { + it.copy(isSaving = false, errorMessage = outcome.message ?: "Couldn't save. Please try again.") } } } diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModel.kt new file mode 100644 index 0000000..8de91c8 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModel.kt @@ -0,0 +1,82 @@ +package com.interlinedlist.android.feature.documents.ui.presence + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.Presence +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav argument key the presence indicator reads its document id from. */ +const val PRESENCE_DOCUMENT_ID_ARG = "documentId" + +/** UI state for the lightweight presence indicator. */ +data class DocumentPresenceUiState( + val participants: List = emptyList(), +) { + /** People here other than a self-entry, if the server includes one — capped for the avatar row. */ + val count: Int get() = participants.size +} + +/** + * Lightweight presence: while a document is open, sends a heartbeat on an interval + * (`POST /presence`) and exposes who else is here; on leave it stops and sends + * `DELETE /presence`. Full live-cursor CRDT is intentionally out of scope. + */ +@HiltViewModel +class DocumentPresenceViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val documentId: String = requireNotNull(savedStateHandle[PRESENCE_DOCUMENT_ID_ARG]) { + "DocumentPresenceViewModel requires a '$PRESENCE_DOCUMENT_ID_ARG' nav argument" + } + + private val _uiState = MutableStateFlow(DocumentPresenceUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var heartbeatJob: Job? = null + + /** Begins heartbeating (idempotent — a second call is ignored while active). */ + fun start() { + if (heartbeatJob?.isActive == true) return + heartbeatJob = viewModelScope.launch { + while (isActive) { + when (val result = repository.sendPresence(documentId)) { + is ApiResult.Success -> _uiState.update { it.copy(participants = result.data) } + is ApiResult.Failure -> Unit // Transient — the next beat retries. + } + delay(HEARTBEAT_INTERVAL_MS) + } + } + } + + /** Stops heartbeating and leaves the document. */ + fun stop() { + heartbeatJob?.cancel() + heartbeatJob = null + _uiState.update { it.copy(participants = emptyList()) } + viewModelScope.launch { repository.leavePresence(documentId) } + } + + override fun onCleared() { + super.onCleared() + if (heartbeatJob != null) stop() + } + + companion object { + /** How often the heartbeat is refreshed while a document is open. */ + const val HEARTBEAT_INTERVAL_MS = 20_000L + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/PresenceIndicator.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/PresenceIndicator.kt new file mode 100644 index 0000000..7d7aac5 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/presence/PresenceIndicator.kt @@ -0,0 +1,86 @@ +package com.interlinedlist.android.feature.documents.ui.presence + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.interlinedlist.android.feature.documents.domain.Presence + +object PresenceTestTags { + const val ROW = "presenceRow" + const val OVERFLOW = "presenceOverflow" + fun avatar(userId: String) = "presenceAvatar_$userId" +} + +/** + * A compact "N people here" avatar cluster for the editor's top bar. Shows up to + * [maxAvatars] overlapping avatars and a "+N" overflow chip; renders nothing when + * no one else is present. + */ +@Composable +fun PresenceIndicator( + participants: List, + modifier: Modifier = Modifier, + maxAvatars: Int = 3, +) { + if (participants.isEmpty()) return + val shown = participants.take(maxAvatars) + val overflow = participants.size - shown.size + Row( + modifier = modifier.testTag(PresenceTestTags.ROW), + verticalAlignment = Alignment.CenterVertically, + ) { + shown.forEachIndexed { index, person -> + PresenceAvatar( + initial = person.initial, + modifier = Modifier + .offset(x = (index * -8).dp) + .testTag(PresenceTestTags.avatar(person.userId)), + ) + } + if (overflow > 0) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .offset(x = (shown.size * -8).dp) + .size(28.dp) + .testTag(PresenceTestTags.OVERFLOW), + ) { + Box(Modifier.padding(2.dp), contentAlignment = Alignment.Center) { + Text("+$overflow", style = MaterialTheme.typography.labelSmall) + } + } + } + } +} + +@Composable +private fun PresenceAvatar(initial: String, modifier: Modifier = Modifier) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.tertiaryContainer, + modifier = modifier + .size(28.dp) + .border(1.dp, MaterialTheme.colorScheme.surface, CircleShape), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = initial, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt new file mode 100644 index 0000000..cb8bc68 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCollaboratorTest.kt @@ -0,0 +1,187 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDocumentsRepositoryCollaboratorTest { + + private lateinit var server: MockWebServer + private lateinit var api: DocumentsApi + private lateinit var repository: DefaultDocumentsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DocumentsApi::class.java) + repository = DefaultDocumentsRepository( + api, FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + ) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `getCollaborators parses the collaborators envelope with nested users`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "collaborators": [ + { "id": "c1", "userId": "u1", "role": "editor", + "user": { "id": "u1", "username": "ada", "displayName": "Ada" } }, + { "id": "c2", "userId": "u2", "role": "viewer", "username": "bob" } + ], + "pagination": { "total": 2 } + } + """.trimIndent(), + ), + ) + + val result = repository.getCollaborators("D1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val list = (result as ApiResult.Success).data + assertThat(list.map { it.userId }).containsExactly("u1", "u2").inOrder() + assertThat(list[0].role).isEqualTo(CollaboratorRole.EDITOR) + assertThat(list[0].label).isEqualTo("Ada") + assertThat(list[1].role).isEqualTo(CollaboratorRole.VIEWER) + assertThat(server.takeRequest().path).isEqualTo("/api/documents/D1/collaborators") + } + + @Test + fun `searchCollaboratorUsers passes the query and maps candidates`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "users": [ { "id": "u9", "username": "grace", "displayName": "Grace H", "email": "g@x.io" } ], + "total": 1 + } + """.trimIndent(), + ), + ) + + val result = repository.searchCollaboratorUsers("D1", "grace") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.single().username).isEqualTo("grace") + val recorded = server.takeRequest() + assertThat(recorded.path).contains("/api/documents/D1/collaborators/users") + assertThat(recorded.path).contains("search=grace") + assertThat(recorded.path).contains("excludeCollaborators=true") + } + + @Test + fun `inviteCollaborator posts userId and role`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "collaborator": { "id": "c3", "userId": "u3", "role": "admin" } }""", + ), + ) + + val result = repository.inviteCollaborator("D1", "u3", CollaboratorRole.ADMIN) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.role).isEqualTo(CollaboratorRole.ADMIN) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents/D1/collaborators") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"userId\":\"u3\"") + assertThat(body).contains("\"role\":\"admin\"") + } + + @Test + fun `updateCollaboratorRole PUTs the new role for the user`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.updateCollaboratorRole("D1", "u3", CollaboratorRole.EDITOR) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PUT") + assertThat(recorded.path).isEqualTo("/api/documents/D1/collaborators/u3") + assertThat(recorded.body.readUtf8()).contains("\"role\":\"editor\"") + } + + @Test + fun `removeCollaborator DELETEs the user`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.removeCollaborator("D1", "u3") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/documents/D1/collaborators/u3") + } + + @Test + fun `sendPresence posts a heartbeat and maps participants`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """ + { + "presences": [ + { "userId": "u1", "user": { "id": "u1", "username": "ada", "displayName": "Ada" } }, + { "userId": "u2", "username": "bob" } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.sendPresence("D1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.map { it.label }).containsExactly("Ada", "bob") + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents/D1/presence") + } + + @Test + fun `leavePresence DELETEs presence`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) + + val result = repository.leavePresence("D1") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/documents/D1/presence") + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt index f5b377e..a819b76 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryShareTest.kt @@ -49,7 +49,9 @@ class DefaultDocumentsRepositoryShareTest { .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() api = retrofit.create(DocumentsApi::class.java) - repository = DefaultDocumentsRepository(api, FakeDocumentDao(), FakeFolderDao(), json, dispatchers) + repository = DefaultDocumentsRepository( + api, FakeDocumentDao(), FakeFolderDao(), FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + ) } @After diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt new file mode 100644 index 0000000..c7708c0 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositorySyncTest.kt @@ -0,0 +1,253 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.local.SyncMetaEntity +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Delta-sync + PATCH-concurrency repository behaviour, exercised through the real + * Retrofit stack against a [MockWebServer]. No live network is touched. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDocumentsRepositorySyncTest { + + private lateinit var server: MockWebServer + private lateinit var api: DocumentsApi + private lateinit var documentDao: FakeDocumentDao + private lateinit var folderDao: FakeFolderDao + private lateinit var pendingDao: FakePendingOpDao + private lateinit var metaDao: FakeSyncMetaDao + private lateinit var repository: DefaultDocumentsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DocumentsApi::class.java) + documentDao = FakeDocumentDao() + folderDao = FakeFolderDao() + pendingDao = FakePendingOpDao() + metaDao = FakeSyncMetaDao() + repository = DefaultDocumentsRepository( + api, documentDao, folderDao, pendingDao, metaDao, json, dispatchers, + ) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `pullDelta upserts added and updated docs and folders and advances the cursor`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "folders": [ { "id": "f1", "name": "Work", "parentId": null } ], + "documents": [ + { "id": "d1", "title": "Report", "content": "body", "folderId": "f1", "version": 3 } + ], + "lastSyncAt": "2026-07-31T00:00:00.000Z" + } + """.trimIndent(), + ), + ) + + val result = repository.pullDelta() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + // First pull sends no cursor. + assertThat(server.takeRequest().path).isEqualTo("/api/documents/sync") + assertThat(folderDao.getFolder("f1")?.name).isEqualTo("Work") + val cached = documentDao.getDocument("d1") + assertThat(cached?.title).isEqualTo("Report") + assertThat(cached?.version).isEqualTo(3) + // Cursor persisted for the next pull. + assertThat(metaDao.get(SyncMetaEntity.KEY_CURSOR)).isEqualTo("2026-07-31T00:00:00.000Z") + } + + @Test + fun `pullDelta sends the persisted cursor on the next pull`() = runTest(testDispatcher) { + metaDao.put(SyncMetaEntity(SyncMetaEntity.KEY_CURSOR, "2026-01-01T00:00:00.000Z")) + server.enqueue( + MockResponse().setResponseCode(200) + .setBody("""{ "folders": [], "documents": [], "lastSyncAt": "2026-02-01T00:00:00.000Z" }"""), + ) + + repository.pullDelta() + + assertThat(server.takeRequest().path) + .isEqualTo("/api/documents/sync?lastSyncAt=2026-01-01T00%3A00%3A00.000Z") + } + + @Test + fun `pullDelta removes tombstoned documents and folders from the cache`() = + runTest(testDispatcher) { + // Seed a doc + folder locally, then pull a tombstone for each. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "folders": [ { "id": "f1", "name": "Work" } ], + "documents": [ { "id": "d1", "title": "Report", "version": 1 } ], + "lastSyncAt": "2026-07-31T00:00:00.000Z" + } + """.trimIndent(), + ), + ) + repository.pullDelta() + server.takeRequest() + assertThat(documentDao.getDocument("d1")).isNotNull() + + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "folders": [ { "id": "f1", "name": "Work", "deletedAt": "2026-07-31T01:00:00.000Z" } ], + "documents": [ { "id": "d1", "title": "Report", "deletedAt": "2026-07-31T01:00:00.000Z" } ], + "lastSyncAt": "2026-07-31T02:00:00.000Z" + } + """.trimIndent(), + ), + ) + val result = repository.pullDelta() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(documentDao.getDocument("d1")).isNull() + assertThat(folderDao.getFolder("f1")).isNull() + } + + @Test + fun `patchDocument sends the version as If-Match and reconciles on success`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "document": { "id": "d1", "title": "New", "content": "c2", "version": 5 } }""", + ), + ) + + val outcome = repository.patchDocument( + id = "d1", + title = "New", + content = "c2", + isPublic = false, + folderId = null, + expectedVersion = 4, + ) + + assertThat(outcome).isInstanceOf(SaveOutcome.Success::class.java) + assertThat((outcome as SaveOutcome.Success).document.version).isEqualTo(5) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("PATCH") + assertThat(recorded.path).isEqualTo("/api/documents/d1") + assertThat(recorded.getHeader("If-Match")).isEqualTo("4") + assertThat(documentDao.getDocument("d1")?.version).isEqualTo(5) + } + + @Test + fun `patchDocument returns Conflict on a 409 version mismatch`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(409) + .setBody("""{ "error": "Document was modified by someone else." }"""), + ) + + val outcome = repository.patchDocument( + id = "d1", title = "New", content = "c", isPublic = false, folderId = null, expectedVersion = 1, + ) + + assertThat(outcome).isInstanceOf(SaveOutcome.Conflict::class.java) + // A conflict must NOT be queued for retry — the local copy is stale. + assertThat(pendingDao.snapshot()).isEmpty() + } + + @Test + fun `patchDocument queues the edit on a network failure`() = runTest(testDispatcher) { + // No enqueue → socket closed → IOException → AppError.Network. + server.shutdown() + + val outcome = repository.patchDocument( + id = "d1", title = "Offline", content = "c", isPublic = false, folderId = null, expectedVersion = 2, + ) + + assertThat(outcome).isEqualTo(SaveOutcome.Queued) + val queued = pendingDao.snapshot().single() + assertThat(queued.documentId).isEqualTo("d1") + assertThat(queued.title).isEqualTo("Offline") + assertThat(queued.version).isEqualTo(2) + } + + @Test + fun `pushPendingOps posts queued update ops and clears them on success`() = + runTest(testDispatcher) { + // Queue an edit via an offline patch first. + server.shutdown() + repository.patchDocument( + id = "d1", title = "Queued", content = "c", isPublic = false, folderId = null, expectedVersion = 1, + ) + assertThat(pendingDao.snapshot()).hasSize(1) + + // New server for the push. + server = MockWebServer().also { it.start() } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DocumentsApi::class.java) + repository = DefaultDocumentsRepository( + api, documentDao, folderDao, pendingDao, metaDao, json, dispatchers, + ) + server.enqueue( + MockResponse().setResponseCode(200) + .setBody("""{ "folders": [], "documents": [], "lastSyncAt": "2026-07-31T00:00:00.000Z" }"""), + ) + + val result = repository.pushPendingOps() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents/sync") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"id\":\"d1\"") + assertThat(body).contains("\"op\":\"update\"") + assertThat(body).contains("\"title\":\"Queued\"") + // Cleared after a successful push. + assertThat(pendingDao.snapshot()).isEmpty() + } + + @Test + fun `pushPendingOps is a no-op with nothing queued`() = runTest(testDispatcher) { + val result = repository.pushPendingOps() + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.requestCount).isEqualTo(0) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt index 7260540..5c9b2c0 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryTest.kt @@ -51,7 +51,9 @@ class DefaultDocumentsRepositoryTest { api = retrofit.create(DocumentsApi::class.java) documentDao = FakeDocumentDao() folderDao = FakeFolderDao() - repository = DefaultDocumentsRepository(api, documentDao, folderDao, json, dispatchers) + repository = DefaultDocumentsRepository( + api, documentDao, folderDao, FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + ) } @After diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt index 24c4cd1..36ae138 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/FakeDaos.kt @@ -4,6 +4,10 @@ import com.interlinedlist.android.feature.documents.data.local.DocumentDao import com.interlinedlist.android.feature.documents.data.local.DocumentEntity import com.interlinedlist.android.feature.documents.data.local.FolderDao import com.interlinedlist.android.feature.documents.data.local.FolderEntity +import com.interlinedlist.android.feature.documents.data.local.PendingOpDao +import com.interlinedlist.android.feature.documents.data.local.PendingOpEntity +import com.interlinedlist.android.feature.documents.data.local.SyncMetaDao +import com.interlinedlist.android.feature.documents.data.local.SyncMetaEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -88,3 +92,45 @@ class FakeFolderDao : FolderDao { rows.value = emptyList() } } + +/** In-memory [PendingOpDao] mirroring the coalesce-by-documentId queue semantics. */ +class FakePendingOpDao : PendingOpDao { + private val rows = MutableStateFlow>(emptyList()) + + fun snapshot(): List = rows.value.sortedBy { it.queuedAt } + + override fun observeCount(): Flow = rows.map { it.size } + + override suspend fun all(): List = rows.value.sortedBy { it.queuedAt } + + override suspend fun upsert(op: PendingOpEntity) { + rows.value = rows.value.filterNot { it.documentId == op.documentId } + op + } + + override suspend fun deleteById(documentId: String) { + rows.value = rows.value.filterNot { it.documentId == documentId } + } + + override suspend fun deleteAllByIds(documentIds: List) { + rows.value = rows.value.filterNot { it.documentId in documentIds } + } + + override suspend fun clear() { + rows.value = emptyList() + } +} + +/** In-memory [SyncMetaDao] for the delta-sync cursor. */ +class FakeSyncMetaDao : SyncMetaDao { + private val rows = mutableMapOf() + + override suspend fun get(key: String): String? = rows[key] + + override suspend fun put(row: SyncMetaEntity) { + rows[row.key] = row.value + } + + override suspend fun clear(key: String) { + rows.remove(key) + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt index cfd8dc0..0395f3d 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentEditorViewModelTest.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.data.SaveOutcome import com.interlinedlist.android.feature.documents.ui.editor.DOCUMENT_ID_ARG import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorViewModel import kotlinx.coroutines.Dispatchers @@ -63,9 +64,9 @@ class DocumentEditorViewModelTest { } @Test - fun `save persists edits and clears the unsaved flag`() = runTest(dispatcher) { + fun `save persists edits via PATCH and clears the unsaved flag`() = runTest(dispatcher) { repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", title = "T", content = "orig")) - repo.updateResult = ApiResult.Success(testDocument("d1", title = "T", content = "edited")) + repo.patchOutcome = SaveOutcome.Success(testDocument("d1", title = "T", content = "edited")) val vm = viewModel() advanceUntilIdle() @@ -76,13 +77,13 @@ class DocumentEditorViewModelTest { assertThat(saved).isTrue() assertThat(vm.uiState.value.hasUnsavedChanges).isFalse() - assertThat(repo.lastUpdate?.content).isEqualTo("edited") + assertThat(repo.lastPatch?.content).isEqualTo("edited") } @Test fun `save failure surfaces an error and keeps the unsaved flag`() = runTest(dispatcher) { repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "orig")) - repo.updateResult = ApiResult.Failure(AppError.Server("boom")) + repo.patchOutcome = SaveOutcome.Error("InterlinedList is having trouble right now. Try again shortly.") val vm = viewModel() advanceUntilIdle() @@ -94,6 +95,61 @@ class DocumentEditorViewModelTest { assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() } + @Test + fun `save conflict surfaces a conflict state and keeps the unsaved edit`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "orig")) + repo.patchOutcome = SaveOutcome.Conflict("Modified elsewhere") + val vm = viewModel() + advanceUntilIdle() + + vm.onContentChange("my edit") + vm.save() + advanceUntilIdle() + + assertThat(vm.uiState.value.hasConflict).isTrue() + assertThat(vm.uiState.value.hasUnsavedChanges).isTrue() + assertThat(vm.uiState.value.isSaving).isFalse() + } + + @Test + fun `reloadForConflict pulls the latest and clears the conflict and local edits`() = + runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "orig")) + repo.patchOutcome = SaveOutcome.Conflict("Modified elsewhere") + val vm = viewModel() + advanceUntilIdle() + vm.onContentChange("my edit") + vm.save() + advanceUntilIdle() + assertThat(vm.uiState.value.hasConflict).isTrue() + + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "server latest")) + vm.reloadForConflict() + advanceUntilIdle() + + assertThat(vm.uiState.value.hasConflict).isFalse() + assertThat(vm.uiState.value.hasUnsavedChanges).isFalse() + assertThat(vm.uiState.value.content).isEqualTo("server latest") + } + + @Test + fun `save queued shows an offline hint but keeps the doc editable`() = runTest(dispatcher) { + repo.refreshDocumentResult = ApiResult.Success(testDocument("d1", content = "orig")) + repo.patchOutcome = SaveOutcome.Queued + val vm = viewModel() + advanceUntilIdle() + + vm.onContentChange("edited offline") + var saved = false + vm.save { saved = true } + advanceUntilIdle() + + // Queued counts as a local save: unsaved flag clears, onSaved fires, offline hint set. + assertThat(saved).isTrue() + assertThat(vm.uiState.value.isQueuedOffline).isTrue() + assertThat(vm.uiState.value.hasUnsavedChanges).isFalse() + } + @Test fun `delete invokes onDeleted on success`() = runTest(dispatcher) { repo.refreshDocumentResult = ApiResult.Success(testDocument("d1")) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt index 1bae091..1fd5ef4 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt @@ -3,6 +3,10 @@ package com.interlinedlist.android.feature.documents.ui import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.data.SaveOutcome +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole import com.interlinedlist.android.feature.documents.domain.Document import com.interlinedlist.android.feature.documents.domain.DocumentFolder import com.interlinedlist.android.feature.documents.domain.DocumentTemplate @@ -10,6 +14,7 @@ import com.interlinedlist.android.feature.documents.domain.FolderContents import com.interlinedlist.android.feature.documents.domain.FolderNode import com.interlinedlist.android.feature.documents.domain.FolderSummary import com.interlinedlist.android.feature.documents.domain.FolderTree +import com.interlinedlist.android.feature.documents.domain.Presence import com.interlinedlist.android.feature.documents.domain.ShareLink import com.interlinedlist.android.feature.documents.domain.ShareRole import com.interlinedlist.android.feature.documents.domain.SharedDocument @@ -49,6 +54,30 @@ class FakeDocumentsRepository : DocumentsRepository { var resolveSharedResult: ApiResult? = null var claimSharedResult: ApiResult = ApiResult.Success(Unit) + // Sync + collaboration + presence. + val pendingCount = MutableStateFlow(0) + var patchOutcome: SaveOutcome? = null + var pullDeltaResult: ApiResult = ApiResult.Success(Unit) + var pushPendingResult: ApiResult = ApiResult.Success(Unit) + var collaboratorsResult: ApiResult> = ApiResult.Success(emptyList()) + var searchUsersResult: ApiResult> = ApiResult.Success(emptyList()) + var inviteResult: ApiResult? = null + var updateRoleResult: ApiResult = ApiResult.Success(Unit) + var removeCollaboratorResult: ApiResult = ApiResult.Success(Unit) + var sendPresenceResult: ApiResult> = ApiResult.Success(emptyList()) + var leavePresenceResult: ApiResult = ApiResult.Success(Unit) + + var lastPatch: Update? = null + var lastInvite: Invite? = null + var lastRoleChange: RoleChange? = null + var lastRemovedUserId: String? = null + var lastSearchUsersQuery: String? = null + var sendPresenceCount = 0 + var leavePresenceCount = 0 + + data class Invite(val documentId: String, val userId: String, val role: CollaboratorRole) + data class RoleChange(val documentId: String, val userId: String, val role: CollaboratorRole) + var refreshTreeCount = 0 var lastCreate: Create? = null var lastUpdate: Update? = null @@ -86,6 +115,8 @@ class FakeDocumentsRepository : DocumentsRepository { override fun observeDocument(id: String) = documentFlow.map { it } + override fun observePendingCount() = pendingCount.map { it } + override suspend fun refreshTree(): ApiResult { refreshTreeCount++ return refreshTreeResult @@ -115,6 +146,18 @@ class FakeDocumentsRepository : DocumentsRepository { return updateResult ?: ApiResult.Failure(AppError.Unknown("not set")) } + override suspend fun patchDocument( + id: String, + title: String, + content: String, + isPublic: Boolean, + folderId: String?, + expectedVersion: Int?, + ): SaveOutcome { + lastPatch = Update(id, title, content, isPublic, folderId) + return patchOutcome ?: SaveOutcome.Success(testDocument(id, title, content)) + } + override suspend fun moveDocument(id: String, folderId: String?): ApiResult { lastMove = Move(id, folderId) return moveDocumentResult @@ -189,6 +232,56 @@ class FakeDocumentsRepository : DocumentsRepository { return claimSharedResult } + override suspend fun pullDelta(): ApiResult = pullDeltaResult + + override suspend fun pushPendingOps(): ApiResult = pushPendingResult + + override suspend fun getCollaborators(documentId: String): ApiResult> = + collaboratorsResult + + override suspend fun searchCollaboratorUsers( + documentId: String, + query: String, + ): ApiResult> { + lastSearchUsersQuery = query + return searchUsersResult + } + + override suspend fun inviteCollaborator( + documentId: String, + userId: String, + role: CollaboratorRole, + ): ApiResult { + lastInvite = Invite(documentId, userId, role) + return inviteResult ?: ApiResult.Success( + Collaborator(userId, role, null, userId, null, null), + ) + } + + override suspend fun updateCollaboratorRole( + documentId: String, + userId: String, + role: CollaboratorRole, + ): ApiResult { + lastRoleChange = RoleChange(documentId, userId, role) + return updateRoleResult + } + + override suspend fun removeCollaborator(documentId: String, userId: String): ApiResult { + lastRemovedUserId = userId + return removeCollaboratorResult + } + + override suspend fun sendPresence(documentId: String): ApiResult> { + sendPresenceCount++ + return sendPresenceResult + } + + override suspend fun leavePresence(documentId: String): ApiResult { + leavePresenceCount++ + return leavePresenceResult + } + private fun flatten(node: FolderNode): List = buildList { node.children.forEach { child -> add(FolderSummary(child.id, child.name, child.documents.size, child.children.size)) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModelTest.kt new file mode 100644 index 0000000..b2edff0 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/collaborators/DocumentCollaboratorsViewModelTest.kt @@ -0,0 +1,142 @@ +package com.interlinedlist.android.feature.documents.ui.collaborators + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.Collaborator +import com.interlinedlist.android.feature.documents.domain.CollaboratorCandidate +import com.interlinedlist.android.feature.documents.domain.CollaboratorRole +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentCollaboratorsViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun collaborator(userId: String, role: CollaboratorRole = CollaboratorRole.VIEWER) = + Collaborator(userId, role, "Name $userId", userId, null, null) + + private fun candidate(userId: String) = + CollaboratorCandidate(userId, "user-$userId", "Name $userId", null, null) + + private fun viewModel(repo: FakeDocumentsRepository) = + DocumentCollaboratorsViewModel( + repo, SavedStateHandle(mapOf(COLLABORATORS_DOCUMENT_ID_ARG to "D1")), + ) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads collaborators on init`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + collaboratorsResult = ApiResult.Success( + listOf(collaborator("u1", CollaboratorRole.ADMIN), collaborator("u2")), + ) + } + val vm = viewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.collaborators.map { it.userId }).containsExactly("u1", "u2").inOrder() + } + + @Test + fun `searchUsers passes the query and populates candidates`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + collaboratorsResult = ApiResult.Success(emptyList()) + searchUsersResult = ApiResult.Success(listOf(candidate("u9"))) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.onSearchQueryChange("grace") + vm.searchUsers() + advanceUntilIdle() + + assertThat(repo.lastSearchUsersQuery).isEqualTo("grace") + assertThat(vm.uiState.value.candidates.map { it.userId }).containsExactly("u9") + } + + @Test + fun `invite optimistically adds the collaborator with the chosen role`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + collaboratorsResult = ApiResult.Success(emptyList()) + inviteResult = ApiResult.Success(collaborator("u9", CollaboratorRole.EDITOR)) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.selectRole(CollaboratorRole.EDITOR) + vm.invite(candidate("u9")) + advanceUntilIdle() + + assertThat(repo.lastInvite?.userId).isEqualTo("u9") + assertThat(repo.lastInvite?.role).isEqualTo(CollaboratorRole.EDITOR) + assertThat(vm.uiState.value.collaborators.map { it.userId }).containsExactly("u9") + } + + @Test + fun `invite failure rolls back and surfaces an error`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + collaboratorsResult = ApiResult.Success(emptyList()) + inviteResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.invite(candidate("u9")) + advanceUntilIdle() + + assertThat(vm.uiState.value.collaborators).isEmpty() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `changeRole optimistically updates and rolls back on failure`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + collaboratorsResult = ApiResult.Success(listOf(collaborator("u1", CollaboratorRole.VIEWER))) + updateRoleResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.changeRole("u1", CollaboratorRole.ADMIN) + // Applied immediately (optimistic). + assertThat(vm.uiState.value.collaborators.single().role).isEqualTo(CollaboratorRole.ADMIN) + + advanceUntilIdle() + // Rolled back after the failure. + assertThat(vm.uiState.value.collaborators.single().role).isEqualTo(CollaboratorRole.VIEWER) + assertThat(vm.uiState.value.errorMessage).isNotNull() + } + + @Test + fun `revoke optimistically removes and rolls back on failure`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + collaboratorsResult = ApiResult.Success(listOf(collaborator("u1"), collaborator("u2"))) + removeCollaboratorResult = ApiResult.Failure(AppError.Server("boom")) + } + val vm = viewModel(repo) + advanceUntilIdle() + + vm.revoke("u2") + assertThat(vm.uiState.value.collaborators.map { it.userId }).containsExactly("u1") + + advanceUntilIdle() + assertThat(vm.uiState.value.collaborators.map { it.userId }).containsExactly("u1", "u2").inOrder() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModelTest.kt new file mode 100644 index 0000000..ffabf71 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/presence/DocumentPresenceViewModelTest.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.feature.documents.ui.presence + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.domain.Presence +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +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.Before +import org.junit.Test + +/** + * Presence is a long-running heartbeat loop, so these tests drive virtual time + * explicitly with [runCurrent]/[advanceTimeBy] and always [DocumentPresenceViewModel.stop] + * before the test ends — never `advanceUntilIdle()`, which would never settle while + * the loop is active. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentPresenceViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + private fun presence(userId: String) = Presence(userId, "Name $userId", userId, null) + + private fun viewModel(repo: FakeDocumentsRepository) = + DocumentPresenceViewModel( + repo, SavedStateHandle(mapOf(PRESENCE_DOCUMENT_ID_ARG to "D1")), + ) + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `start sends a heartbeat and exposes participants`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + sendPresenceResult = ApiResult.Success(listOf(presence("u1"), presence("u2"))) + } + val vm = viewModel(repo) + + vm.start() + runCurrent() + + assertThat(repo.sendPresenceCount).isAtLeast(1) + assertThat(vm.uiState.value.participants.map { it.userId }).containsExactly("u1", "u2") + + vm.stop() + runCurrent() + } + + @Test + fun `heartbeats repeat on the interval while open`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + sendPresenceResult = ApiResult.Success(listOf(presence("u1"))) + } + val vm = viewModel(repo) + + vm.start() + runCurrent() + val first = repo.sendPresenceCount + + advanceTimeBy(DocumentPresenceViewModel.HEARTBEAT_INTERVAL_MS + 100) + runCurrent() + + assertThat(repo.sendPresenceCount).isGreaterThan(first) + + vm.stop() + runCurrent() + } + + @Test + fun `stop sends a leave and halts further heartbeats`() = runTest(dispatcher) { + val repo = FakeDocumentsRepository().apply { + sendPresenceResult = ApiResult.Success(listOf(presence("u1"))) + } + val vm = viewModel(repo) + vm.start() + runCurrent() + + vm.stop() + runCurrent() + val afterStop = repo.sendPresenceCount + + assertThat(repo.leavePresenceCount).isEqualTo(1) + + advanceTimeBy(DocumentPresenceViewModel.HEARTBEAT_INTERVAL_MS * 3) + runCurrent() + // No more heartbeats after stop. + assertThat(repo.sendPresenceCount).isEqualTo(afterStop) + assertThat(vm.uiState.value.participants).isEmpty() + } +} From 9d22622c91ee2473c5bb11b600a72505adf9f120 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 16:21:58 -0700 Subject: [PATCH 19/25] feat(documents): completeness polish (Milestone M, documents slice) Create-document-in-folder (POST /api/documents/folders/{id}/documents; browser FAB auto-routes when inside a folder) and seed default templates (POST /api/documents/templates/seed-defaults) with a Templates screen. Also fixes the live getTemplates parse (templates arrive under the 'templates' key). 112 documents unit tests green; :app:assembleDebug SUCCESSFUL. Completes Milestone M. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../templates/DocumentTemplatesScreenTest.kt | 77 +++++ .../data/DefaultDocumentsRepository.kt | 38 +++ .../documents/data/DocumentsRepository.kt | 19 ++ .../documents/data/remote/DocumentsApi.kt | 12 + .../data/remote/dto/DocumentRequests.kt | 13 + .../data/remote/dto/DocumentResponses.kt | 21 +- .../ui/browser/DocumentsBrowserViewModel.kt | 27 +- .../ui/templates/DocumentTemplatesScreen.kt | 291 ++++++++++++++++++ .../templates/DocumentTemplatesViewModel.kt | 110 +++++++ ...aultDocumentsRepositoryCompletenessTest.kt | 198 ++++++++++++ .../ui/DocumentsBrowserViewModelTest.kt | 22 +- .../documents/ui/FakeDocumentsRepository.kt | 18 ++ .../DocumentTemplatesViewModelTest.kt | 148 +++++++++ 13 files changed, 977 insertions(+), 17 deletions(-) create mode 100644 feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreenTest.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreen.kt create mode 100644 feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModel.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt create mode 100644 feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModelTest.kt diff --git a/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreenTest.kt b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreenTest.kt new file mode 100644 index 0000000..af7e7c8 --- /dev/null +++ b/feature/documents/src/androidTest/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreenTest.kt @@ -0,0 +1,77 @@ +package com.interlinedlist.android.feature.documents.ui.templates + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DocumentTemplatesScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setContent( + state: DocumentTemplatesUiState, + onSeedDefaults: () -> Unit = {}, + onUseTemplate: (DocumentTemplate) -> Unit = {}, + ) { + composeRule.setContent { + InterlinedListTheme { + DocumentTemplatesScreen( + state = state, + onSeedDefaults = onSeedDefaults, + onUseTemplate = onUseTemplate, + onBack = {}, + ) + } + } + } + + @Test + fun emptyState_offersSeedDefaults_andInvokesCallback() { + var seeded = false + setContent( + state = DocumentTemplatesUiState(isLoading = false, templates = emptyList()), + onSeedDefaults = { seeded = true }, + ) + + composeRule.onNodeWithTag(DocumentTemplatesTestTags.EMPTY).assertIsDisplayed() + composeRule.onNodeWithTag(DocumentTemplatesTestTags.SEED_BUTTON).assertIsEnabled().performClick() + assert(seeded) + } + + @Test + fun templateRow_isRendered_andUsable() { + var usedId: String? = null + setContent( + state = DocumentTemplatesUiState( + isLoading = false, + templates = listOf(DocumentTemplate("t1", "Recipe", "Ingredients")), + ), + onUseTemplate = { usedId = it.id }, + ) + + composeRule.onNodeWithTag(DocumentTemplatesTestTags.row("t1")).assertIsDisplayed().performClick() + assert(usedId == "t1") + } + + @Test + fun subscriptionGate_isShown_whenRequired() { + setContent( + state = DocumentTemplatesUiState( + isLoading = false, + subscriptionRequired = true, + errorMessage = "Templates require an active subscription.", + ), + ) + composeRule.onNodeWithTag(DocumentTemplatesTestTags.SUBSCRIPTION_GATE).assertIsDisplayed() + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt index 55cd507..057eff6 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepository.kt @@ -19,6 +19,7 @@ import com.interlinedlist.android.feature.documents.data.mapper.toSharedDocument import com.interlinedlist.android.feature.documents.data.mapper.toTemplate import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.FromTemplateRequest @@ -166,6 +167,34 @@ class DefaultDocumentsRepository @Inject constructor( } } + override suspend fun createDocumentInFolder( + folderId: String, + title: String, + content: String, + isPublic: Boolean, + ): ApiResult = withContext(dispatchers.io) { + val result = safeApiCall(json) { + api.createFolderDocument( + folderId, + CreateFolderDocumentRequest(title = title, content = content, isPublic = isPublic), + ).documentOrSelf + } + when (result) { + is ApiResult.Success -> { + val dto = result.data + ?: return@withContext ApiResult.Failure( + AppError.Unknown("Document create returned no body"), + ) + // The endpoint files the doc in the folder; ensure the cached row agrees + // even if the response omitted (or differed on) the folderId. + val domain = dto.toDomain().copy(folderId = folderId) + documentDao.upsert(domain.toEntity(sortOrder = documentDao.maxSortOrder() + 1)) + ApiResult.Success(domain) + } + is ApiResult.Failure -> result + } + } + override suspend fun updateDocument( id: String, title: String, @@ -313,6 +342,15 @@ class DefaultDocumentsRepository @Inject constructor( .map { response -> response.documentsOrEmpty.map { it.toTemplate() } } } + override suspend fun seedDefaultTemplates(): ApiResult> = + withContext(dispatchers.io) { + when (val seed = safeApiCall(json) { api.seedDefaultTemplates() }) { + // Re-fetch so the surface shows the freshly seeded templates. + is ApiResult.Success -> getTemplates() + is ApiResult.Failure -> seed + } + } + override suspend fun createFromTemplate( templateId: String, targetFolderId: String?, diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt index dc04bd1..2c96447 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/DocumentsRepository.kt @@ -59,6 +59,18 @@ interface DocumentsRepository { folderId: String?, ): ApiResult + /** + * Creates a document directly inside [folderId] via + * `POST /api/documents/folders/{id}/documents` (a single call, no follow-up move), + * then caches it so the folder listing updates immediately. + */ + suspend fun createDocumentInFolder( + folderId: String, + title: String, + content: String, + isPublic: Boolean, + ): ApiResult + suspend fun updateDocument( id: String, title: String, @@ -110,6 +122,13 @@ interface DocumentsRepository { suspend fun getTemplates(): ApiResult> + /** + * Seeds the built-in default template documents for the user via + * `POST /api/documents/templates/seed-defaults`, then returns the refreshed + * template list so the surface can render the newly created templates. + */ + suspend fun seedDefaultTemplates(): ApiResult> + suspend fun createFromTemplate( templateId: String, targetFolderId: String?, diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt index 6c2a91d..8a4fe2d 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/DocumentsApi.kt @@ -4,6 +4,7 @@ import com.interlinedlist.android.feature.documents.data.remote.dto.Collaborator import com.interlinedlist.android.feature.documents.data.remote.dto.CollaboratorUsersResponse import com.interlinedlist.android.feature.documents.data.remote.dto.CollaboratorsResponse import com.interlinedlist.android.feature.documents.data.remote.dto.CreateDocumentRequest +import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderDocumentRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateFolderRequest import com.interlinedlist.android.feature.documents.data.remote.dto.CreateShareLinkRequest import com.interlinedlist.android.feature.documents.data.remote.dto.DocumentListResponse @@ -117,6 +118,13 @@ interface DocumentsApi { @GET("api/documents/folders/{id}/documents") suspend fun getFolderDocuments(@Path("id") folderId: String): DocumentListResponse + /** Creates a new document directly inside [folderId]. */ + @POST("api/documents/folders/{id}/documents") + suspend fun createFolderDocument( + @Path("id") folderId: String, + @Body body: CreateFolderDocumentRequest, + ): DocumentResponse + // --- Templates --------------------------------------------------------- @GET("api/documents/templates") @@ -125,6 +133,10 @@ interface DocumentsApi { @POST("api/documents/from-template") suspend fun createFromTemplate(@Body body: FromTemplateRequest): DocumentResponse + /** Seeds the built-in default template documents for the current user. */ + @POST("api/documents/templates/seed-defaults") + suspend fun seedDefaultTemplates() + // --- Sharing ----------------------------------------------------------- /** Existing public share links for a document. */ diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt index 440f732..7ecf2bb 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentRequests.kt @@ -10,6 +10,19 @@ data class CreateDocumentRequest( val isPublic: Boolean = false, ) +/** + * Body for `POST /api/documents/folders/{id}/documents` — create a document directly + * inside a folder. The folder is the path parameter, so the body only carries the + * document fields (the optional [relativePath] lets the server derive a file name). + */ +@Serializable +data class CreateFolderDocumentRequest( + val title: String, + val content: String, + val isPublic: Boolean = false, + val relativePath: String? = null, +) + /** Body for `PUT`/`PATCH /api/documents/{id}`. Null fields are left unchanged. */ @Serializable data class UpdateDocumentRequest( diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt index eb5c1f0..8ba551e 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/data/remote/dto/DocumentResponses.kt @@ -12,26 +12,31 @@ data class PaginationDto( ) /** - * `GET /api/documents` (and folder listings / search). Documents may arrive under - * `data` (the documented list envelope) or, in some responses, `documents`; both - * are accepted and merged by [documentsOrEmpty]. + * `GET /api/documents` (and folder listings / search / templates). Documents may + * arrive under `data` (the documented list envelope), `documents` (folder + root + * listings), or `templates` (the templates endpoint); all are accepted and resolved + * by [documentsOrEmpty]. */ @Serializable data class DocumentListResponse( val data: List? = null, val documents: List? = null, + val templates: List? = null, val pagination: PaginationDto? = null, ) { - val documentsOrEmpty: List get() = data ?: documents ?: emptyList() + val documentsOrEmpty: List + get() = data ?: documents ?: templates ?: emptyList() } /** - * A single document, returned either bare or wrapped in `{ "document": ... }`. - * [documentOrSelf] resolves whichever form the endpoint used. + * A single document, returned either bare, wrapped in `{ "document": ... }`, or + * wrapped in `{ "data": ... }` (the create endpoints). [documentOrSelf] resolves + * whichever form the endpoint used. */ @Serializable data class DocumentResponse( val document: DocumentDto? = null, + val data: DocumentDto? = null, val id: String? = null, val title: String? = null, val content: String? = null, @@ -44,9 +49,9 @@ data class DocumentResponse( val createdAt: String? = null, val version: Int? = null, ) { - /** The document payload, whether wrapped or inlined at the top level. */ + /** The document payload, whether wrapped (`document`/`data`) or inlined at the top level. */ val documentOrSelf: DocumentDto? - get() = document ?: id?.let { + get() = document ?: data ?: id?.let { DocumentDto( id = it, title = title, diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt index 21960b9..9056cc0 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserViewModel.kt @@ -120,16 +120,29 @@ class DocumentsBrowserViewModel @Inject constructor( // --- Document actions -------------------------------------------------- - /** Creates a document in this folder; invokes [onCreated] with its id to open it. */ + /** + * Creates a document in this folder; invokes [onCreated] with its id to open it. + * Inside a folder this hits the dedicated "create in folder" endpoint (a single + * call that files the doc directly); at the root it uses the plain create. + */ fun createDocument(title: String, onCreated: (String) -> Unit) { val trimmed = title.trim().ifBlank { "Untitled" } viewModelScope.launch { - val result = repository.createDocument( - title = trimmed, - content = "", - isPublic = false, - folderId = folderId, - ) + val result = if (folderId != null) { + repository.createDocumentInFolder( + folderId = folderId, + title = trimmed, + content = "", + isPublic = false, + ) + } else { + repository.createDocument( + title = trimmed, + content = "", + isPublic = false, + folderId = null, + ) + } when (result) { is ApiResult.Success -> onCreated(result.data.id) is ApiResult.Failure -> showError(result.error.toUserMessage()) diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreen.kt new file mode 100644 index 0000000..62b76a3 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesScreen.kt @@ -0,0 +1,291 @@ +package com.interlinedlist.android.feature.documents.ui.templates + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Description +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate + +/** Stable test tags for the templates surface. */ +object DocumentTemplatesTestTags { + const val LIST = "templatesList" + const val EMPTY = "templatesEmpty" + const val PROGRESS = "templatesProgress" + const val ERROR = "templatesError" + const val SEED_BUTTON = "templatesSeedButton" + const val SUBSCRIPTION_GATE = "templatesSubscriptionGate" + fun row(id: String) = "templateRow_$id" +} + +/** + * Hilt-wired templates route. Reads its optional target folder from the nav + * SavedStateHandle (see [TEMPLATES_TARGET_FOLDER_ARG]). [onOpenDocument] receives the + * id of a document freshly created from a template so the caller can open it. + */ +@Composable +fun DocumentTemplatesRoute( + onOpenDocument: (String) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: DocumentTemplatesViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + DocumentTemplatesScreen( + state = state, + onSeedDefaults = viewModel::seedDefaults, + onUseTemplate = { template -> viewModel.createFromTemplate(template, onOpenDocument) }, + onBack = onBack, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DocumentTemplatesScreen( + state: DocumentTemplatesUiState, + onSeedDefaults: () -> Unit, + onUseTemplate: (DocumentTemplate) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Templates") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + when { + state.subscriptionRequired -> SubscriptionGate( + message = state.errorMessage, + modifier = Modifier.padding(padding), + ) + + state.isLoading -> Box( + Modifier.fillMaxSize().padding(padding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(Modifier.testTag(DocumentTemplatesTestTags.PROGRESS)) + } + + else -> TemplatesContent( + state = state, + onSeedDefaults = onSeedDefaults, + onUseTemplate = onUseTemplate, + contentPadding = padding, + ) + } + } +} + +@Composable +private fun TemplatesContent( + state: DocumentTemplatesUiState, + onSeedDefaults: () -> Unit, + onUseTemplate: (DocumentTemplate) -> Unit, + contentPadding: PaddingValues, +) { + Column(Modifier.fillMaxSize().padding(contentPadding)) { + if (state.errorMessage != null && !state.subscriptionRequired) { + Text( + text = state.errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(DocumentTemplatesTestTags.ERROR), + ) + } + + if (state.templates.isEmpty()) { + EmptyTemplates( + canSeed = state.canSeedDefaults, + isSeeding = state.isSeeding, + onSeedDefaults = onSeedDefaults, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize().testTag(DocumentTemplatesTestTags.LIST), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(state.templates, key = { it.id }) { template -> + TemplateRow(template = template, onClick = { onUseTemplate(template) }) + } + } + } + } +} + +@Composable +private fun EmptyTemplates( + canSeed: Boolean, + isSeeding: Boolean, + onSeedDefaults: () -> Unit, +) { + Box( + Modifier.fillMaxSize().padding(24.dp).testTag(DocumentTemplatesTestTags.EMPTY), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "No templates yet", + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Seed the built-in defaults to start with ready-made documents.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Button( + onClick = onSeedDefaults, + enabled = canSeed, + modifier = Modifier.testTag(DocumentTemplatesTestTags.SEED_BUTTON), + ) { + if (isSeeding) { + CircularProgressIndicator( + Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Icon(Icons.Default.AutoAwesome, contentDescription = null) + Spacer(Modifier.size(8.dp)) + Text("Seed default templates") + } + } + } + } +} + +@Composable +private fun TemplateRow(template: DocumentTemplate, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag(DocumentTemplatesTestTags.row(template.id)) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + Icons.Default.Description, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Column(Modifier.weight(1f)) { + Text( + template.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (template.snippet.isNotBlank()) { + Text( + text = template.snippet, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun SubscriptionGate(message: String?, modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize().padding(24.dp).testTag(DocumentTemplatesTestTags.SUBSCRIPTION_GATE), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Subscriber feature", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = message ?: "Templates require an active subscription.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentTemplatesPreview() { + InterlinedListTheme { + DocumentTemplatesScreen( + state = DocumentTemplatesUiState( + isLoading = false, + templates = listOf( + DocumentTemplate("t1", "Recipe", "Ingredients and steps"), + DocumentTemplate("t2", "Social Media Campaign", "Plan your posts"), + ), + ), + onSeedDefaults = {}, + onUseTemplate = {}, + onBack = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun DocumentTemplatesEmptyPreview() { + InterlinedListTheme { + DocumentTemplatesScreen( + state = DocumentTemplatesUiState(isLoading = false, templates = emptyList()), + onSeedDefaults = {}, + onUseTemplate = {}, + onBack = {}, + ) + } +} diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModel.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModel.kt new file mode 100644 index 0000000..f79a439 --- /dev/null +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModel.kt @@ -0,0 +1,110 @@ +package com.interlinedlist.android.feature.documents.ui.templates + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.documents.data.DocumentsRepository +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.ui.common.isSubscriptionGate +import com.interlinedlist.android.feature.documents.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Optional nav argument: the folder a template-derived document should be created in + * (absent / the synthetic root == unfiled). The templates surface can be opened from + * a folder level to seed a document directly there. + */ +const val TEMPLATES_TARGET_FOLDER_ARG = "targetFolderId" + +/** UI state for the templates surface. */ +data class DocumentTemplatesUiState( + val templates: List = emptyList(), + val isLoading: Boolean = true, + val isSeeding: Boolean = false, + val errorMessage: String? = null, + val subscriptionRequired: Boolean = false, +) { + /** No templates yet (and nothing in flight) — offer seeding the defaults. */ + val canSeedDefaults: Boolean + get() = templates.isEmpty() && !isLoading && !isSeeding && !subscriptionRequired +} + +/** + * Drives the templates surface: lists the user's template documents, offers a + * "Seed default templates" action (auto-offered when the list is empty), and creates + * a new document from a chosen template. Seeding refreshes the list so the newly + * created templates render immediately. + */ +@HiltViewModel +class DocumentTemplatesViewModel @Inject constructor( + private val repository: DocumentsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + /** Where template-derived documents should land (null == root/unfiled). */ + private val targetFolderId: String? = + savedStateHandle.get(TEMPLATES_TARGET_FOLDER_ARG) + + private val _uiState = MutableStateFlow(DocumentTemplatesUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + applyTemplatesResult(repository.getTemplates()) { it.copy(isLoading = false) } + } + } + + /** Seeds the built-in default templates, then renders the refreshed list. */ + fun seedDefaults() { + if (_uiState.value.isSeeding) return + _uiState.update { it.copy(isSeeding = true, errorMessage = null) } + viewModelScope.launch { + applyTemplatesResult(repository.seedDefaultTemplates()) { it.copy(isSeeding = false) } + } + } + + /** Creates a document from [template]; invokes [onCreated] with its id to open it. */ + fun createFromTemplate(template: DocumentTemplate, onCreated: (String) -> Unit) { + viewModelScope.launch { + when (val result = repository.createFromTemplate(template.id, targetFolderId)) { + is ApiResult.Success -> onCreated(result.data.id) + is ApiResult.Failure -> _uiState.update { + it.copy(errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } + + private inline fun applyTemplatesResult( + result: ApiResult>, + crossinline finalize: (DocumentTemplatesUiState) -> DocumentTemplatesUiState, + ) { + when (result) { + is ApiResult.Success -> _uiState.update { + finalize(it.copy(templates = result.data, subscriptionRequired = false)) + } + is ApiResult.Failure -> _uiState.update { + finalize( + it.copy( + errorMessage = result.error.toUserMessage(), + subscriptionRequired = result.error.isSubscriptionGate, + ), + ) + } + } + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt new file mode 100644 index 0000000..b4c4633 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/data/DefaultDocumentsRepositoryCompletenessTest.kt @@ -0,0 +1,198 @@ +package com.interlinedlist.android.feature.documents.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.data.remote.DocumentsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Repository coverage for the documents completeness polish: creating a document + * directly inside a folder, and seeding the default templates (then refreshing). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultDocumentsRepositoryCompletenessTest { + + private lateinit var server: MockWebServer + private lateinit var api: DocumentsApi + private lateinit var documentDao: FakeDocumentDao + private lateinit var folderDao: FakeFolderDao + private lateinit var repository: DefaultDocumentsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(DocumentsApi::class.java) + documentDao = FakeDocumentDao() + folderDao = FakeFolderDao() + repository = DefaultDocumentsRepository( + api, documentDao, folderDao, FakePendingOpDao(), FakeSyncMetaDao(), json, dispatchers, + ) + } + + @After + fun tearDown() = server.shutdown() + + // --- Create document in folder ---------------------------------------- + + @Test + fun `createDocumentInFolder posts to the folder endpoint and caches the doc under that folder`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "document": { "id": "nd1", "title": "Notes", "content": "hello", "folderId": "f1" } }""", + ), + ) + + val result = repository.createDocumentInFolder( + folderId = "f1", + title = "Notes", + content = "hello", + isPublic = false, + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val created = (result as ApiResult.Success).data + assertThat(created.id).isEqualTo("nd1") + assertThat(created.folderId).isEqualTo("f1") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/documents/folders/f1/documents") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"title\":\"Notes\"") + assertThat(body).contains("\"content\":\"hello\"") + + // Only one call — no follow-up move — and the cache reflects the folder. + assertThat(server.requestCount).isEqualTo(1) + assertThat(documentDao.getDocument("nd1")?.folderId).isEqualTo("f1") + } + + @Test + fun `createDocumentInFolder forces the target folder even when the response omits it`() = + runTest(testDispatcher) { + // Server echoes a bare doc without folderId (create endpoints may wrap in data). + server.enqueue( + MockResponse().setResponseCode(201).setBody( + """{ "data": { "id": "nd2", "title": "Filed" } }""", + ), + ) + + val result = repository.createDocumentInFolder("f9", "Filed", "", isPublic = false) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.folderId).isEqualTo("f9") + assertThat(documentDao.getDocument("nd2")?.folderId).isEqualTo("f9") + } + + @Test + fun `createDocumentInFolder maps a 403 subscription error`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription." }"""), + ) + + val result = repository.createDocumentInFolder("f1", "T", "c", isPublic = false) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + } + + // --- Seed default templates ------------------------------------------- + + @Test + fun `seedDefaultTemplates posts to the seed endpoint then refreshes and parses templates`() = + runTest(testDispatcher) { + // 1) POST seed-defaults. + server.enqueue(MockResponse().setResponseCode(201).setBody("{}")) + // 2) GET templates (live shape uses a `templates` array). + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "folderCreated": true, + "templatesFolderId": "tf", + "templates": [ + { "id": "t1", "title": "Recipe" }, + { "id": "t2", "title": "Social Media Campaign" } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.seedDefaultTemplates() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.map { it.title }) + .containsExactly("Recipe", "Social Media Campaign") + + val seed = server.takeRequest() + assertThat(seed.method).isEqualTo("POST") + assertThat(seed.path).isEqualTo("/api/documents/templates/seed-defaults") + + val refresh = server.takeRequest() + assertThat(refresh.method).isEqualTo("GET") + assertThat(refresh.path).isEqualTo("/api/documents/templates") + } + + @Test + fun `seedDefaultTemplates surfaces the seed failure without refreshing`() = + runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("""{ "error": "This feature requires an active subscription." }"""), + ) + + val result = repository.seedDefaultTemplates() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.SubscriptionRequired::class.java) + // Only the seed POST was attempted — no templates GET followed. + assertThat(server.requestCount).isEqualTo(1) + } + + @Test + fun `getTemplates parses the live templates array key`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "templates": [ { "id": "t1", "title": "Recipe" } ] }""", + ), + ) + + val result = repository.getTemplates() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.single().title).isEqualTo("Recipe") + } +} diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt index dc8c6e6..fdb5378 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/DocumentsBrowserViewModelTest.kt @@ -100,7 +100,7 @@ class DocumentsBrowserViewModelTest { } @Test - fun `create document uses the current folder and invokes onCreated with the new id`() = + fun `create document in a folder uses the create-in-folder endpoint and opens the new doc`() = runTest(dispatcher) { repo.createResult = ApiResult.Success(testDocument("new-id", title = "Untitled", folderId = "f1")) @@ -112,9 +112,27 @@ class DocumentsBrowserViewModelTest { advanceUntilIdle() assertThat(createdId).isEqualTo("new-id") - assertThat(repo.lastCreate?.folderId).isEqualTo("f1") + // Routed through the dedicated create-in-folder call (not the plain create). + assertThat(repo.lastCreateInFolder?.folderId).isEqualTo("f1") + assertThat(repo.lastCreate).isNull() } + @Test + fun `create document at the root uses the plain create endpoint`() = runTest(dispatcher) { + repo.createResult = ApiResult.Success(testDocument("root-doc", title = "Untitled")) + + val vm = rootViewModel() + advanceUntilIdle() + + var createdId: String? = null + vm.createDocument(title = "Untitled") { createdId = it } + advanceUntilIdle() + + assertThat(createdId).isEqualTo("root-doc") + assertThat(repo.lastCreate?.folderId).isNull() + assertThat(repo.lastCreateInFolder).isNull() + } + @Test fun `move document delegates to the repository with the target folder`() = runTest(dispatcher) { val vm = rootViewModel() diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt index 1fd5ef4..a97c297 100644 --- a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/FakeDocumentsRepository.kt @@ -44,6 +44,7 @@ class FakeDocumentsRepository : DocumentsRepository { var moveFolderResult: ApiResult? = null var deleteFolderResult: ApiResult = ApiResult.Success(Unit) var templatesResult: ApiResult> = ApiResult.Success(emptyList()) + var seedTemplatesResult: ApiResult>? = null var fromTemplateResult: ApiResult? = null var searchResult: ApiResult> = ApiResult.Success(emptyList()) @@ -79,7 +80,9 @@ class FakeDocumentsRepository : DocumentsRepository { data class RoleChange(val documentId: String, val userId: String, val role: CollaboratorRole) var refreshTreeCount = 0 + var seedTemplatesCount = 0 var lastCreate: Create? = null + var lastCreateInFolder: Create? = null var lastUpdate: Update? = null var lastMove: Move? = null var lastDeletedDocId: String? = null @@ -135,6 +138,16 @@ class FakeDocumentsRepository : DocumentsRepository { return createResult ?: ApiResult.Failure(AppError.Unknown("not set")) } + override suspend fun createDocumentInFolder( + folderId: String, + title: String, + content: String, + isPublic: Boolean, + ): ApiResult { + lastCreateInFolder = Create(title, content, isPublic, folderId) + return createResult ?: ApiResult.Failure(AppError.Unknown("not set")) + } + override suspend fun updateDocument( id: String, title: String, @@ -195,6 +208,11 @@ class FakeDocumentsRepository : DocumentsRepository { override suspend fun getTemplates(): ApiResult> = templatesResult + override suspend fun seedDefaultTemplates(): ApiResult> { + seedTemplatesCount++ + return seedTemplatesResult ?: templatesResult + } + override suspend fun createFromTemplate(templateId: String, targetFolderId: String?): ApiResult = fromTemplateResult ?: ApiResult.Failure(AppError.Unknown("not set")) diff --git a/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModelTest.kt b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModelTest.kt new file mode 100644 index 0000000..f58f123 --- /dev/null +++ b/feature/documents/src/test/kotlin/com/interlinedlist/android/feature/documents/ui/templates/DocumentTemplatesViewModelTest.kt @@ -0,0 +1,148 @@ +package com.interlinedlist.android.feature.documents.ui.templates + +import androidx.lifecycle.SavedStateHandle +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.documents.domain.DocumentTemplate +import com.interlinedlist.android.feature.documents.ui.FakeDocumentsRepository +import com.interlinedlist.android.feature.documents.ui.testDocument +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DocumentTemplatesViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeDocumentsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeDocumentsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(targetFolderId: String? = null) = DocumentTemplatesViewModel( + repo, + SavedStateHandle( + buildMap { targetFolderId?.let { put(TEMPLATES_TARGET_FOLDER_ARG, it) } }, + ), + ) + + @Test + fun `initial load populates the templates list`() = runTest(dispatcher) { + repo.templatesResult = ApiResult.Success( + listOf(DocumentTemplate("t1", "Recipe", "Ingredients")), + ) + + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.templates.map { it.title }).containsExactly("Recipe") + assertThat(vm.uiState.value.canSeedDefaults).isFalse() + } + + @Test + fun `empty template list offers seeding the defaults`() = runTest(dispatcher) { + repo.templatesResult = ApiResult.Success(emptyList()) + + val vm = viewModel() + advanceUntilIdle() + + assertThat(vm.uiState.value.templates).isEmpty() + assertThat(vm.uiState.value.canSeedDefaults).isTrue() + } + + @Test + fun `seedDefaults seeds then populates the refreshed templates`() = runTest(dispatcher) { + repo.templatesResult = ApiResult.Success(emptyList()) + // After seeding, the refresh returns the newly created templates. + repo.seedTemplatesResult = ApiResult.Success( + listOf( + DocumentTemplate("t1", "Recipe", ""), + DocumentTemplate("t2", "Social Media Campaign", ""), + ), + ) + + val vm = viewModel() + advanceUntilIdle() + + vm.uiState.test { + // Starting point: empty, seed offered. + assertThat(awaitItem().templates).isEmpty() + + vm.seedDefaults() + + // Seeding in flight. + assertThat(awaitItem().isSeeding).isTrue() + // Populated with the seeded templates. + val done = awaitItem() + assertThat(done.isSeeding).isFalse() + assertThat(done.templates.map { it.title }) + .containsExactly("Recipe", "Social Media Campaign") + cancelAndIgnoreRemainingEvents() + } + assertThat(repo.seedTemplatesCount).isEqualTo(1) + } + + @Test + fun `seedDefaults surfaces a subscription gate on failure`() = runTest(dispatcher) { + repo.templatesResult = ApiResult.Success(emptyList()) + repo.seedTemplatesResult = ApiResult.Failure(AppError.SubscriptionRequired("Subscribe to seed templates")) + + val vm = viewModel() + advanceUntilIdle() + + vm.seedDefaults() + advanceUntilIdle() + + assertThat(vm.uiState.value.isSeeding).isFalse() + assertThat(vm.uiState.value.subscriptionRequired).isTrue() + assertThat(vm.uiState.value.errorMessage).isEqualTo("Subscribe to seed templates") + } + + @Test + fun `createFromTemplate opens the new document`() = runTest(dispatcher) { + repo.templatesResult = ApiResult.Success(listOf(DocumentTemplate("t1", "Recipe", ""))) + repo.fromTemplateResult = ApiResult.Success(testDocument("doc-from-t1", title = "Recipe")) + + val vm = viewModel(targetFolderId = "f1") + advanceUntilIdle() + + var openedId: String? = null + vm.createFromTemplate(DocumentTemplate("t1", "Recipe", "")) { openedId = it } + advanceUntilIdle() + + assertThat(openedId).isEqualTo("doc-from-t1") + } + + @Test + fun `createFromTemplate failure surfaces an error`() = runTest(dispatcher) { + repo.templatesResult = ApiResult.Success(listOf(DocumentTemplate("t1", "Recipe", ""))) + repo.fromTemplateResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = viewModel() + advanceUntilIdle() + + var openedId: String? = null + vm.createFromTemplate(DocumentTemplate("t1", "Recipe", "")) { openedId = it } + advanceUntilIdle() + + assertThat(openedId).isNull() + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + } +} From fc5c03993fab8e4fce6c42c5c317a072e52971a8 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 17:14:19 -0700 Subject: [PATCH 20/25] feat(billing): subscription upsell + Stripe checkout/portal (Milestone J) New thin :feature:billing module: UpsellScreen with Subscribe (POST /api/stripe/create-checkout-session) and Manage subscription (POST /api/stripe/create-portal-session), opening the returned Stripe URL via ACTION_VIEW (no new deps). 16 unit tests green. App-nav wiring (403-gate -> upsell + Account hub) deferred to the app-level pass. NOTE: shipping Stripe web checkout on Google Play needs a payments-policy decision (code works either way). Co-Authored-By: Claude Opus 4.8 (1M context) --- feature/billing/build.gradle.kts | 71 ++++++ .../src/androidTest/AndroidManifest.xml | 2 + .../feature/billing/ui/UpsellScreenTest.kt | 44 ++++ .../feature/billing/data/BillingRepository.kt | 24 ++ .../billing/data/DefaultBillingRepository.kt | 44 ++++ .../feature/billing/data/remote/BillingApi.kt | 31 +++ .../data/remote/dto/CheckoutSessionDto.kt | 51 ++++ .../feature/billing/di/BillingModule.kt | 33 +++ .../billing/navigation/BillingNavigation.kt | 30 +++ .../billing/ui/BillingErrorMessages.kt | 12 + .../feature/billing/ui/UpsellScreen.kt | 221 ++++++++++++++++++ .../feature/billing/ui/UpsellViewModel.kt | 72 ++++++ .../data/DefaultBillingRepositoryTest.kt | 146 ++++++++++++ .../remote/dto/StripeSessionResponseTest.kt | 45 ++++ .../billing/ui/FakeBillingRepository.kt | 28 +++ .../feature/billing/ui/UpsellViewModelTest.kt | 119 ++++++++++ settings.gradle.kts | 1 + 17 files changed, 974 insertions(+) create mode 100644 feature/billing/build.gradle.kts create mode 100644 feature/billing/src/androidTest/AndroidManifest.xml create mode 100644 feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt create mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt create mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt create mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt create mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt create mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt diff --git a/feature/billing/build.gradle.kts b/feature/billing/build.gradle.kts new file mode 100644 index 0000000..f0a3740 --- /dev/null +++ b/feature/billing/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.ksp) + alias(libs.plugins.hilt) +} + +android { + namespace = "com.interlinedlist.android.feature.billing" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { jvmTarget = "17" } +} + +dependencies { + implementation(project(":core:model")) + implementation(project(":core:common")) + implementation(project(":core:designsystem")) + implementation(project(":core:network")) + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material.icons.extended) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.activity.compose) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) + implementation(libs.androidx.hilt.navigation.compose) + implementation(libs.androidx.navigation.compose) + + implementation(libs.retrofit.core) + implementation(libs.kotlinx.serialization.json) + + // Unit tests + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) + testImplementation(libs.truth) + // Repository tests hit a MockWebServer through the real Retrofit stack. + testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.retrofit.core) + testImplementation(libs.retrofit.kotlinx.serialization) + testImplementation(libs.okhttp.core) + testImplementation(libs.kotlinx.serialization.json) + + // Instrumented / UI tests + androidTestImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.truth) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} diff --git a/feature/billing/src/androidTest/AndroidManifest.xml b/feature/billing/src/androidTest/AndroidManifest.xml new file mode 100644 index 0000000..b2d3ea1 --- /dev/null +++ b/feature/billing/src/androidTest/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt b/feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt new file mode 100644 index 0000000..e04e6e3 --- /dev/null +++ b/feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt @@ -0,0 +1,44 @@ +package com.interlinedlist.android.feature.billing.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UpsellScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun upsell_rendersSubscribeAndManage_andInvokesCallbacks() { + var subscribed = false + var managed = false + composeRule.setContent { + InterlinedListTheme { + UpsellScreen( + state = UpsellUiState(), + onSubscribe = { subscribed = true }, + onManage = { managed = true }, + onDismissError = {}, + onBack = {}, + ) + } + } + + composeRule.onNodeWithTag(UpsellTestTags.SUBSCRIBE).assertIsDisplayed() + composeRule.onNodeWithTag(UpsellTestTags.MANAGE).assertIsDisplayed() + + composeRule.onNodeWithTag(UpsellTestTags.SUBSCRIBE).performClick() + composeRule.onNodeWithTag(UpsellTestTags.MANAGE).performClick() + + assert(subscribed) + assert(managed) + } +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt new file mode 100644 index 0000000..e4769d2 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt @@ -0,0 +1,24 @@ +package com.interlinedlist.android.feature.billing.data + +import com.interlinedlist.android.core.common.result.ApiResult + +/** + * Data operations for subscription billing. Both calls mint a short-lived Stripe + * hosted session on the server and return its URL for the UI to open in a browser. + * Everything is a live, stateless request — there is no cache — so results come + * back as an [ApiResult] carrying the URL string. + */ +interface BillingRepository { + + /** + * Creates a Stripe Checkout session for the given [priceId] (null lets the + * server pick the default subscription price) and returns its hosted URL. + */ + suspend fun createCheckoutSession(priceId: String? = null): ApiResult + + /** + * Creates a Stripe customer-portal session and returns its hosted URL, where + * the user can manage or cancel an existing subscription. + */ + suspend fun createPortalSession(): ApiResult +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt new file mode 100644 index 0000000..65adde4 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt @@ -0,0 +1,44 @@ +package com.interlinedlist.android.feature.billing.data + +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.feature.billing.data.remote.BillingApi +import com.interlinedlist.android.feature.billing.data.remote.dto.CreateCheckoutSessionRequest +import com.interlinedlist.android.feature.billing.data.remote.dto.CreatePortalSessionRequest +import com.interlinedlist.android.feature.billing.data.remote.dto.StripeSessionResponse +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import javax.inject.Inject + +class DefaultBillingRepository @Inject constructor( + private val api: BillingApi, + private val json: Json, + private val dispatchers: DispatcherProvider, +) : BillingRepository { + + override suspend fun createCheckoutSession(priceId: String?): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.createCheckoutSession(CreateCheckoutSessionRequest(priceId)) } + .requireUrl() + } + + override suspend fun createPortalSession(): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { api.createPortalSession(CreatePortalSessionRequest()) } + .requireUrl() + } + + /** + * A 2xx with no URL is a contract violation, not a success — the UI has nothing + * to open — so it is folded into a failure the error mapper can render, rather + * than surfaced as an empty string. + */ + private fun ApiResult.requireUrl(): ApiResult = when (this) { + is ApiResult.Success -> data.resolvedUrl + ?.let { ApiResult.Success(it) } + ?: ApiResult.Failure(AppError.Server("The billing session did not return a URL.")) + is ApiResult.Failure -> this + } +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt new file mode 100644 index 0000000..dc0cc0e --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt @@ -0,0 +1,31 @@ +package com.interlinedlist.android.feature.billing.data.remote + +import com.interlinedlist.android.feature.billing.data.remote.dto.CreateCheckoutSessionRequest +import com.interlinedlist.android.feature.billing.data.remote.dto.CreatePortalSessionRequest +import com.interlinedlist.android.feature.billing.data.remote.dto.StripeSessionResponse +import retrofit2.http.Body +import retrofit2.http.POST + +/** + * Retrofit description of the Stripe billing endpoints. Provided from the shared, + * already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), so + * both calls are authed. + * + * Each endpoint mints a short-lived Stripe hosted session and returns its URL; the + * client opens that URL in a browser. No live sessions are created in tests — the + * repository is exercised against MockWebServer only. + */ +interface BillingApi { + + /** Creates a Stripe Checkout session and returns its hosted URL. */ + @POST("api/stripe/create-checkout-session") + suspend fun createCheckoutSession( + @Body body: CreateCheckoutSessionRequest, + ): StripeSessionResponse + + /** Creates a Stripe customer-portal session and returns its hosted URL. */ + @POST("api/stripe/create-portal-session") + suspend fun createPortalSession( + @Body body: CreatePortalSessionRequest, + ): StripeSessionResponse +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt new file mode 100644 index 0000000..76b2966 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt @@ -0,0 +1,51 @@ +package com.interlinedlist.android.feature.billing.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * Body for `POST /api/stripe/create-checkout-session` (OpenAPI: `{ priceId }`). + * + * [priceId] selects which Stripe Price the checkout is for. It is optional in the + * spec — the server falls back to the account's default subscription price when it + * is omitted — so it is nullable and, with the shared Json's `explicitNulls = false`, + * simply left out of the request body when null. + */ +@Serializable +data class CreateCheckoutSessionRequest( + val priceId: String? = null, +) + +/** + * Body for `POST /api/stripe/create-portal-session` (OpenAPI: `{ flow }`). + * + * [flow] optionally deep-links the customer portal to a specific flow (e.g. + * `subscription_cancel`). Omitted when null, landing the user on the portal home. + */ +@Serializable +data class CreatePortalSessionRequest( + val flow: String? = null, +) + +/** + * Response for both Stripe session endpoints. + * + * The OpenAPI spec does not model these response bodies, but Stripe's + * `checkout.sessions.create` / `billingPortal.sessions.create` return an object + * carrying a hosted `url`, and the web app redirects the browser to it. This DTO + * therefore reads [url] first and, to stay resilient to a minor key rename on the + * backend, falls back to a handful of common aliases via [resolvedUrl]. The shared + * Json is configured with `ignoreUnknownKeys`, so any extra Stripe fields (id, + * sessionId, etc.) decode without throwing. + */ +@Serializable +data class StripeSessionResponse( + val url: String? = null, + val checkoutUrl: String? = null, + val portalUrl: String? = null, + val sessionUrl: String? = null, +) { + /** The first non-blank URL field, or null if the server sent none. */ + val resolvedUrl: String? + get() = listOf(url, checkoutUrl, portalUrl, sessionUrl) + .firstOrNull { !it.isNullOrBlank() } +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt new file mode 100644 index 0000000..e388515 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt @@ -0,0 +1,33 @@ +package com.interlinedlist.android.feature.billing.di + +import com.interlinedlist.android.feature.billing.data.BillingRepository +import com.interlinedlist.android.feature.billing.data.DefaultBillingRepository +import com.interlinedlist.android.feature.billing.data.remote.BillingApi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +/** Binds the repository interface to its default implementation. */ +@Module +@InstallIn(SingletonComponent::class) +abstract class BillingRepositoryModule { + + @Binds + @Singleton + abstract fun bindBillingRepository(impl: DefaultBillingRepository): BillingRepository +} + +/** Provides this feature's API off the shared authed Retrofit. */ +@Module +@InstallIn(SingletonComponent::class) +object BillingDataModule { + + @Provides + @Singleton + fun provideBillingApi(retrofit: Retrofit): BillingApi = + retrofit.create(BillingApi::class.java) +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt new file mode 100644 index 0000000..7b271b0 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt @@ -0,0 +1,30 @@ +package com.interlinedlist.android.feature.billing.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.compose.composable +import com.interlinedlist.android.feature.billing.ui.UpsellRoute + +/** Route keys for the billing graph. */ +object BillingDestinations { + /** Subscription upsell — the graph's entry (and only) route. */ + const val UPSELL = "billing/upsell" +} + +/** Convenience navigation helper so callers don't hand-build route strings. */ +fun NavController.navigateToUpsell() = navigate(BillingDestinations.UPSELL) + +/** + * Registers the billing destinations into the host graph. + * + * The app wires this into its top-level NavHost (see the module's report for the + * exact snippet plus how to route here from a 403 subscription gate and the + * Account hub). [onBack] pops the current destination. + */ +fun NavGraphBuilder.billingGraph( + onBack: () -> Unit, +) { + composable(BillingDestinations.UPSELL) { + UpsellRoute(onBack = onBack) + } +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt new file mode 100644 index 0000000..6dc4409 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt @@ -0,0 +1,12 @@ +package com.interlinedlist.android.feature.billing.ui + +import com.interlinedlist.android.core.common.result.AppError + +/** Maps a normalised [AppError] to a concise, user-facing message for the billing UI. */ +fun AppError.toUserMessage(): String = when (this) { + is AppError.Network -> "No connection. Check your network and try again." + is AppError.Unauthorized -> message ?: "Please sign in again." + is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." + is AppError.Server -> "We couldn't start your billing session. Please try again shortly." + else -> message ?: "Something went wrong. Please try again." +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt new file mode 100644 index 0000000..3d2c686 --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt @@ -0,0 +1,221 @@ +package com.interlinedlist.android.feature.billing.ui + +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme + +/** Stable test tags so UI/instrumented tests can address the upsell controls. */ +object UpsellTestTags { + const val SUBSCRIBE = "upsellSubscribe" + const val MANAGE = "upsellManage" + const val ERROR = "upsellError" +} + +/** The benefit bullets shown on the upsell — kept here so the screen stays declarative. */ +private val benefits = listOf( + "Unlimited lists and data rows", + "Full CSV exports of your data", + "Priority access to new integrations", +) + +/** + * Hilt-wired entry point for the subscription upsell. Collects state and, when a + * Stripe session URL is ready, opens it in the browser with a plain + * `ACTION_VIEW` intent (no Custom Tabs dependency). + */ +@Composable +fun UpsellRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: UpsellViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + // One-shot: open each Stripe hosted URL as it becomes ready. + LaunchedEffect(Unit) { + viewModel.open.collect { event -> + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } + } + + UpsellScreen( + state = state, + onSubscribe = { viewModel.subscribe() }, + onManage = viewModel::manageSubscription, + onDismissError = viewModel::clearError, + onBack = onBack, + modifier = modifier, + ) +} + +/** Stateless upsell UI — easy to preview and to drive from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpsellScreen( + state: UpsellUiState, + onSubscribe: () -> Unit, + onManage: () -> Unit, + onDismissError: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(state.errorMessage) { + state.errorMessage?.let { + snackbarHostState.showSnackbar(it) + onDismissError() + } + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Upgrade") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { + SnackbarHost( + snackbarHostState, + modifier = Modifier.testTag(UpsellTestTags.ERROR), + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(horizontal = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Spacer(Modifier.size(8.dp)) + Text( + text = "Go Pro", + style = MaterialTheme.typography.headlineMedium, + ) + Text( + text = "Unlock the full InterlinedList experience with a subscription.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + benefits.forEach { benefit -> BenefitRow(benefit) } + } + + Spacer(Modifier.size(8.dp)) + + Button( + onClick = onSubscribe, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(UpsellTestTags.SUBSCRIBE), + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Subscribe") + } + } + + OutlinedButton( + onClick = onManage, + enabled = !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .testTag(UpsellTestTags.MANAGE), + ) { + Text("Manage subscription") + } + + Text( + text = "Already subscribed? Open the customer portal to update or cancel " + + "your plan.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun BenefitRow(text: String) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + Spacer(Modifier.size(12.dp)) + Text(text = text, style = MaterialTheme.typography.bodyLarge) + } +} + +@Preview(showBackground = true) +@Composable +private fun UpsellScreenPreview() { + InterlinedListTheme { + UpsellScreen( + state = UpsellUiState(), + onSubscribe = {}, + onManage = {}, + onDismissError = {}, + onBack = {}, + ) + } +} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt new file mode 100644 index 0000000..e666e2c --- /dev/null +++ b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt @@ -0,0 +1,72 @@ +package com.interlinedlist.android.feature.billing.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.billing.data.BillingRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * UI state for the upsell screen. [isLoading] disables both buttons while a + * session is being minted; [errorMessage] renders inline when a request fails. + */ +data class UpsellUiState( + val isLoading: Boolean = false, + val errorMessage: String? = null, +) + +/** + * A Stripe hosted URL ready to be opened in a browser — a one-shot event. Emitting + * the URL rather than launching it here keeps the ViewModel free of Android + * `Intent`/`Context` and therefore unit-testable. + */ +data class OpenUrl(val url: String) + +@HiltViewModel +class UpsellViewModel @Inject constructor( + private val repository: BillingRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(UpsellUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + // Buffered channel so a URL event survives brief config-change gaps. + private val _open = Channel(Channel.BUFFERED) + val open = _open.receiveAsFlow() + + /** Mints a Checkout session; on success emits its URL for the screen to open. */ + fun subscribe(priceId: String? = null) = launchSession { + repository.createCheckoutSession(priceId) + } + + /** Mints a customer-portal session; on success emits its URL to open. */ + fun manageSubscription() = launchSession { + repository.createPortalSession() + } + + private fun launchSession(request: suspend () -> ApiResult) { + if (_uiState.value.isLoading) return + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + when (val result = request()) { + is ApiResult.Success -> { + _uiState.update { it.copy(isLoading = false) } + _open.send(OpenUrl(result.data)) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt new file mode 100644 index 0000000..e020de8 --- /dev/null +++ b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt @@ -0,0 +1,146 @@ +package com.interlinedlist.android.feature.billing.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.billing.data.remote.BillingApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * Repository behaviour against a real HTTP stack (Retrofit + OkHttp) driven by + * MockWebServer. No live Stripe sessions are ever created — every response is a + * canned MockResponse. Verifies the checkout/portal URLs are surfaced, the right + * endpoints/bodies are hit, errors are mapped, and a URL-less 2xx degrades to a + * failure the UI can render. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultBillingRepositoryTest { + + private lateinit var server: MockWebServer + private lateinit var api: BillingApi + private lateinit var repository: DefaultBillingRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(BillingApi::class.java) + repository = DefaultBillingRepository(api, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `createCheckoutSession posts to the checkout endpoint and returns the url`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "url": "https://checkout.stripe.com/c/pay/cs_test_123" }"""), + ) + + val result = repository.createCheckoutSession("price_pro_monthly") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data) + .isEqualTo("https://checkout.stripe.com/c/pay/cs_test_123") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/stripe/create-checkout-session") + assertThat(request.body.readUtf8()).contains("price_pro_monthly") + } + + @Test + fun `createPortalSession posts to the portal endpoint and returns the url`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "url": "https://billing.stripe.com/p/session/bps_test_456" }"""), + ) + + val result = repository.createPortalSession() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data) + .isEqualTo("https://billing.stripe.com/p/session/bps_test_456") + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/stripe/create-portal-session") + } + + @Test + fun `createCheckoutSession maps a 401 to Unauthorized`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(401) + .setBody("""{ "error": "Not authenticated" }"""), + ) + + val result = repository.createCheckoutSession() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.Unauthorized::class.java) + } + + @Test + fun `createPortalSession maps a 500 to Server`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(500).setBody("boom")) + + val result = repository.createPortalSession() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.Server::class.java) + } + + @Test + fun `a successful response with no url degrades to a Server failure`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "id": "cs_test_789" }""")) + + val result = repository.createCheckoutSession() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error) + .isInstanceOf(AppError.Server::class.java) + } + + @Test + fun `checkout accepts a checkoutUrl alias when url is absent`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "checkoutUrl": "https://checkout.stripe.com/c/pay/alias" }"""), + ) + + val result = repository.createCheckoutSession() + + assertThat((result as ApiResult.Success).data) + .isEqualTo("https://checkout.stripe.com/c/pay/alias") + } +} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt new file mode 100644 index 0000000..8ec437c --- /dev/null +++ b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt @@ -0,0 +1,45 @@ +package com.interlinedlist.android.feature.billing.data.remote.dto + +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.json.Json +import org.junit.Test + +class StripeSessionResponseTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + + @Test + fun `resolvedUrl prefers the canonical url field`() { + val dto = json.decodeFromString( + StripeSessionResponse.serializer(), + """{ "url": "https://a", "checkoutUrl": "https://b" }""", + ) + assertThat(dto.resolvedUrl).isEqualTo("https://a") + } + + @Test + fun `resolvedUrl falls back to aliases when url is missing`() { + assertThat( + json.decodeFromString(StripeSessionResponse.serializer(), """{ "portalUrl": "https://p" }""") + .resolvedUrl, + ).isEqualTo("https://p") + } + + @Test + fun `resolvedUrl ignores blank values`() { + val dto = json.decodeFromString( + StripeSessionResponse.serializer(), + """{ "url": "", "sessionUrl": "https://s" }""", + ) + assertThat(dto.resolvedUrl).isEqualTo("https://s") + } + + @Test + fun `resolvedUrl is null when no url field is present`() { + val dto = json.decodeFromString( + StripeSessionResponse.serializer(), + """{ "id": "cs_test_1", "object": "checkout.session" }""", + ) + assertThat(dto.resolvedUrl).isNull() + } +} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt new file mode 100644 index 0000000..c1281b1 --- /dev/null +++ b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt @@ -0,0 +1,28 @@ +package com.interlinedlist.android.feature.billing.ui + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.billing.data.BillingRepository + +/** + * In-memory [BillingRepository] for ViewModel tests. Each operation returns its + * configured result and records the arguments it was called with, so tests can + * assert both the emitted effect and that the right request was made. + */ +class FakeBillingRepository : BillingRepository { + + var checkoutResult: ApiResult = ApiResult.Success("https://checkout.example/session") + var portalResult: ApiResult = ApiResult.Success("https://portal.example/session") + + val requestedPriceIds = mutableListOf() + var portalCalls = 0 + + override suspend fun createCheckoutSession(priceId: String?): ApiResult { + requestedPriceIds += priceId + return checkoutResult + } + + override suspend fun createPortalSession(): ApiResult { + portalCalls++ + return portalResult + } +} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt new file mode 100644 index 0000000..69ebb37 --- /dev/null +++ b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt @@ -0,0 +1,119 @@ +package com.interlinedlist.android.feature.billing.ui + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class UpsellViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeBillingRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeBillingRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `subscribe emits the checkout URL as an open effect`() = runTest(dispatcher) { + repo.checkoutResult = ApiResult.Success("https://checkout.stripe.example/abc") + val vm = UpsellViewModel(repo) + + vm.open.test { + vm.subscribe() + advanceUntilIdle() + + assertThat(awaitItem().url).isEqualTo("https://checkout.stripe.example/abc") + cancelAndIgnoreRemainingEvents() + } + assertThat(repo.requestedPriceIds).containsExactly(null as String?) + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.errorMessage).isNull() + } + + @Test + fun `manage subscription emits the portal URL as an open effect`() = runTest(dispatcher) { + repo.portalResult = ApiResult.Success("https://portal.stripe.example/xyz") + val vm = UpsellViewModel(repo) + + vm.open.test { + vm.manageSubscription() + advanceUntilIdle() + + assertThat(awaitItem().url).isEqualTo("https://portal.stripe.example/xyz") + cancelAndIgnoreRemainingEvents() + } + assertThat(repo.portalCalls).isEqualTo(1) + } + + @Test + fun `subscribe shows a loading spinner while the session is in flight`() = runTest(dispatcher) { + repo.checkoutResult = ApiResult.Success("https://checkout.example/s") + val vm = UpsellViewModel(repo) + + vm.uiState.test { + assertThat(awaitItem().isLoading).isFalse() // initial + + vm.subscribe() + assertThat(awaitItem().isLoading).isTrue() // in-flight + + advanceUntilIdle() + assertThat(awaitItem().isLoading).isFalse() // done + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `a second request is ignored while one is in flight`() = runTest(dispatcher) { + repo.checkoutResult = ApiResult.Success("https://checkout.example/s") + val vm = UpsellViewModel(repo) + + vm.subscribe() + vm.manageSubscription() // dropped: busy + advanceUntilIdle() + + assertThat(repo.requestedPriceIds).hasSize(1) + assertThat(repo.portalCalls).isEqualTo(0) + } + + @Test + fun `failed checkout surfaces a mapped error and emits no open effect`() = runTest(dispatcher) { + repo.checkoutResult = ApiResult.Failure(AppError.Network(null)) + val vm = UpsellViewModel(repo) + + vm.subscribe() + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoading).isFalse() + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + } + + @Test + fun `failed portal session surfaces a mapped error`() = runTest(dispatcher) { + repo.portalResult = ApiResult.Failure(AppError.Server(null)) + val vm = UpsellViewModel(repo) + + vm.manageSubscription() + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("We couldn't start your billing session. Please try again shortly.") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index e3a23ef..055b9ab 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,3 +43,4 @@ include(":feature:notifications") include(":feature:organizations") include(":feature:integrations") include(":feature:directmessages") +include(":feature:billing") From e1b8e70d736eb4bc7b0827a589f6cbb8bd4cb202 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 17:20:18 -0700 Subject: [PATCH 21/25] feat(integrations): GitHub integration (Milestone N) Adds a GitHub section to :feature:integrations: list connected repos, browse a repo's issues, create an issue (title/body + optional labels/assignees), and comment on an issue; graceful 'GitHub not linked' state (live: 400 {error:'GitHub account not linked'}). Endpoints: /api/github/repos, /issues (GET/POST), issue comments, repo assignees/labels. 33 integrations unit tests green (20 new). Hub GitHub entry gated behind a defaulted onOpenGitHub; app-nav wiring deferred to the app-level pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/IntegrationsScreensTest.kt | 84 +++ .../data/DefaultIntegrationsRepository.kt | 77 +++ .../integrations/data/GitHubNotLinked.kt | 14 + .../data/IntegrationsRepository.kt | 40 ++ .../integrations/data/mapper/GitHubMapper.kt | 56 ++ .../data/remote/IntegrationsApi.kt | 56 ++ .../integrations/data/remote/dto/GitHubDto.kt | 79 +++ .../feature/integrations/domain/GitHub.kt | 44 ++ .../integrations/ui/github/GitHubScreen.kt | 539 ++++++++++++++++++ .../integrations/ui/github/GitHubViewModel.kt | 186 ++++++ .../ui/hub/IntegrationsHubScreen.kt | 16 + ...DefaultIntegrationsRepositoryGitHubTest.kt | 265 +++++++++ .../ui/FakeIntegrationsRepository.kt | 69 +++ .../ui/github/GitHubViewModelTest.kt | 225 ++++++++ 14 files changed, 1750 insertions(+) create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/GitHubNotLinked.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/GitHubMapper.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/GitHubDto.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/GitHub.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubScreen.kt create mode 100644 feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModel.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryGitHubTest.kt create mode 100644 feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModelTest.kt diff --git a/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt b/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt index 463df1d..0a8dac6 100644 --- a/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt +++ b/feature/integrations/src/androidTest/kotlin/com/interlinedlist/android/feature/integrations/ui/IntegrationsScreensTest.kt @@ -8,12 +8,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsScreen import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsTestTags import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsUiState import com.interlinedlist.android.feature.integrations.ui.export.ExportScreen import com.interlinedlist.android.feature.integrations.ui.export.ExportTestTags import com.interlinedlist.android.feature.integrations.ui.export.ExportUiState +import com.interlinedlist.android.feature.integrations.ui.github.GitHubScreen +import com.interlinedlist.android.feature.integrations.ui.github.GitHubTestTags +import com.interlinedlist.android.feature.integrations.ui.github.GitHubUiState import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubScreen import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubTestTags import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsHubUiState @@ -92,4 +97,83 @@ class IntegrationsScreensTest { composeRule.onNodeWithTag(ConnectedAccountsTestTags.status(ConnectedAccount.Provider.BLUESKY)) .assertIsDisplayed() } + + @Test + fun github_repos_renderAndSelect() { + var selected: GitHubRepo? = null + val repo = GitHubRepo("adron", "hello", description = "sample") + composeRule.setContent { + InterlinedListTheme { + GitHubScreen( + state = GitHubUiState(isLoadingRepos = false, repos = listOf(repo)), + onBack = {}, + onRetryRepos = {}, + onSelectRepo = { selected = it }, + onClearRepo = {}, + onCreateIssue = { _, _, _, _ -> }, + onAddComment = { _, _ -> }, + onMessageShown = {}, + onCreateErrorShown = {}, + onCommentErrorShown = {}, + ) + } + } + + composeRule.onNodeWithTag(GitHubTestTags.REPO_LIST).assertIsDisplayed() + composeRule.onNodeWithTag(GitHubTestTags.repo("adron/hello")).assertIsDisplayed().performClick() + assert(selected == repo) + } + + @Test + fun github_notConnected_showsConnectPrompt() { + composeRule.setContent { + InterlinedListTheme { + GitHubScreen( + state = GitHubUiState(isLoadingRepos = false, notConnected = true), + onBack = {}, + onRetryRepos = {}, + onSelectRepo = {}, + onClearRepo = {}, + onCreateIssue = { _, _, _, _ -> }, + onAddComment = { _, _ -> }, + onMessageShown = {}, + onCreateErrorShown = {}, + onCommentErrorShown = {}, + ) + } + } + + composeRule.onNodeWithTag(GitHubTestTags.NOT_CONNECTED).assertIsDisplayed() + } + + @Test + fun github_issues_renderForSelectedRepo() { + val repo = GitHubRepo("adron", "hello") + composeRule.setContent { + InterlinedListTheme { + GitHubScreen( + state = GitHubUiState( + isLoadingRepos = false, + repos = listOf(repo), + selectedRepo = repo, + isLoadingIssues = false, + issues = listOf(GitHubIssue(number = 7, title = "Fix bug", labels = listOf("bug"))), + ), + onBack = {}, + onRetryRepos = {}, + onSelectRepo = {}, + onClearRepo = {}, + onCreateIssue = { _, _, _, _ -> }, + onAddComment = { _, _ -> }, + onMessageShown = {}, + onCreateErrorShown = {}, + onCommentErrorShown = {}, + ) + } + } + + composeRule.onNodeWithTag(GitHubTestTags.ISSUE_LIST).assertIsDisplayed() + composeRule.onNodeWithTag(GitHubTestTags.issue(7)).assertIsDisplayed() + composeRule.onNodeWithTag(GitHubTestTags.NEW_ISSUE_FAB).assertIsDisplayed() + } } diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt index 941574b..203d165 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepository.kt @@ -5,9 +5,17 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.map import com.interlinedlist.android.core.network.error.safeApiCall import com.interlinedlist.android.feature.integrations.data.mapper.toDomain +import com.interlinedlist.android.feature.integrations.data.mapper.toDomainOrNull import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi +import com.interlinedlist.android.feature.integrations.data.remote.dto.CreateCommentRequest +import com.interlinedlist.android.feature.integrations.data.remote.dto.CreateIssueRequest +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubIssueDto import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.GitHubAssignee +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubLabel +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo import com.interlinedlist.android.feature.integrations.domain.PlanLimits import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -59,4 +67,73 @@ class DefaultIntegrationsRepository @Inject constructor( withContext(dispatchers.io) { safeApiCall(json) { api.getLimits() }.map { it.toDomain() } } + + override suspend fun getGitHubRepos(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getGitHubRepos() } + .map { dtos -> dtos.mapNotNull { it.toDomainOrNull() } } + } + + override suspend fun getGitHubIssues(repo: String, state: String?): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getGitHubIssues(repo = repo, state = state) } + .map { dtos -> dtos.mapNotNull { it.toDomainOrNull() } } + } + + override suspend fun createGitHubIssue( + repo: String, + title: String, + body: String?, + labels: List, + assignees: List, + ): ApiResult = + withContext(dispatchers.io) { + val request = CreateIssueRequest( + repo = repo, + title = title, + body = body?.takeIf { it.isNotBlank() }, + // The API expects comma-separated strings; omit when empty. + labels = labels.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() }?.joinToString(","), + assignees = assignees.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() }?.joinToString(","), + ) + safeApiCall(json) { api.createGitHubIssue(request) } + .map { dto -> dto.toDomainOrFallback(title, body) } + } + + override suspend fun addGitHubIssueComment( + owner: String, + repo: String, + number: Int, + body: String, + ): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { + api.addGitHubIssueComment(owner, repo, number, CreateCommentRequest(body)) + }.map { it.close() } + } + + override suspend fun getGitHubAssignees(owner: String, repo: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getGitHubAssignees(owner, repo) } + .map { dtos -> dtos.mapNotNull { it.toDomainOrNull() } } + } + + override suspend fun getGitHubLabels(owner: String, repo: String): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { api.getGitHubLabels(owner, repo) } + .map { dtos -> dtos.mapNotNull { it.toDomainOrNull() } } + } } + +/** + * The create-issue response shape isn't modelled in the spec. If the returned DTO + * lacks a number (or the field names differ), fall back to a synthetic issue built + * from what was sent so the UI can still show the new issue optimistically. + */ +private fun GitHubIssueDto.toDomainOrFallback(title: String, body: String?): GitHubIssue = + toDomainOrNull() ?: GitHubIssue( + number = 0, + title = this.title?.takeIf { it.isNotBlank() } ?: title, + body = this.body?.takeIf { it.isNotBlank() } ?: body?.takeIf { it.isNotBlank() }, + state = "open", + ) diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/GitHubNotLinked.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/GitHubNotLinked.kt new file mode 100644 index 0000000..555ff0f --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/GitHubNotLinked.kt @@ -0,0 +1,14 @@ +package com.interlinedlist.android.feature.integrations.data + +import com.interlinedlist.android.core.common.result.AppError + +/** + * When GitHub isn't connected, every `/api/github/…` endpoint answers HTTP 400 + * with `{ "error": "GitHub account not linked" }`. `safeApiCall` doesn't map 400 + * to a dedicated type, so it arrives as [AppError.Unknown] carrying that message. + * This recognises it by message so the UI can show a "connect GitHub" state + * rather than a generic error. + */ +fun AppError.isGitHubNotLinked(): Boolean = + message?.contains("not linked", ignoreCase = true) == true || + message?.contains("not connected", ignoreCase = true) == true diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt index d57214e..c274f5b 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/IntegrationsRepository.kt @@ -3,6 +3,10 @@ package com.interlinedlist.android.feature.integrations.data import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.GitHubAssignee +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubLabel +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo import com.interlinedlist.android.feature.integrations.domain.PlanLimits import java.io.File @@ -24,4 +28,40 @@ interface IntegrationsRepository { /** Reads plan limits/usage, or a failure the UI can render inline. */ suspend fun getLimits(): ApiResult + + /** + * Lists the connected GitHub repositories. When GitHub isn't linked the API + * answers 400 "not linked"; that arrives as a failure whose error satisfies + * [isGitHubNotLinked], which the UI treats as a "connect GitHub" state. + */ + suspend fun getGitHubRepos(): ApiResult> + + /** Lists issues for [repo] ("owner/name"), filtered by [state] (open/closed/all). */ + suspend fun getGitHubIssues(repo: String, state: String? = null): ApiResult> + + /** + * Creates an issue on [repo] with [title]/[body] and optional [labels]/ + * [assignees], returning the created issue. + */ + suspend fun createGitHubIssue( + repo: String, + title: String, + body: String?, + labels: List = emptyList(), + assignees: List = emptyList(), + ): ApiResult + + /** Adds a [body] comment to issue [number] on [owner]/[repo]. */ + suspend fun addGitHubIssueComment( + owner: String, + repo: String, + number: Int, + body: String, + ): ApiResult + + /** Assignable users for [owner]/[repo], for the create-issue composer. */ + suspend fun getGitHubAssignees(owner: String, repo: String): ApiResult> + + /** Labels defined on [owner]/[repo], for the create-issue composer. */ + suspend fun getGitHubLabels(owner: String, repo: String): ApiResult> } diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/GitHubMapper.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/GitHubMapper.kt new file mode 100644 index 0000000..e965786 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/mapper/GitHubMapper.kt @@ -0,0 +1,56 @@ +package com.interlinedlist.android.feature.integrations.data.mapper + +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubAssigneeDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubIssueDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubLabelDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubRepoDto +import com.interlinedlist.android.feature.integrations.domain.GitHubAssignee +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubLabel +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo + +/** + * Maps the lenient GitHub DTOs into domain models. Repos that can't yield an + * owner/name pair (from a nested owner, a flattened `ownerLogin`, or `full_name`) + * are dropped rather than surfaced half-populated. Issues without a number are + * likewise dropped, since the number is the key the update/comment routes need. + */ + +/** Drops repos we can't fully identify (no owner or no name). */ +fun GitHubRepoDto.toDomainOrNull(): GitHubRepo? { + val owner = owner?.login?.takeIf { it.isNotBlank() } + ?: ownerLogin?.takeIf { it.isNotBlank() } + ?: fullName?.substringBefore('/', missingDelimiterValue = "")?.takeIf { it.isNotBlank() } + val repoName = name?.takeIf { it.isNotBlank() } + ?: fullName?.substringAfter('/', missingDelimiterValue = "")?.takeIf { it.isNotBlank() } + if (owner == null || repoName == null) return null + return GitHubRepo( + owner = owner, + name = repoName, + isPrivate = private ?: false, + description = description?.takeIf { it.isNotBlank() }, + ) +} + +/** Drops issues without a number (needed by the update/comment routes). */ +fun GitHubIssueDto.toDomainOrNull(): GitHubIssue? { + val issueNumber = number ?: return null + return GitHubIssue( + number = issueNumber, + title = title.orEmpty(), + body = body?.takeIf { it.isNotBlank() }, + state = state?.takeIf { it.isNotBlank() } ?: "open", + labels = labels.orEmpty().mapNotNull { it.name?.takeIf { n -> n.isNotBlank() } }, + assignees = assignees.orEmpty().mapNotNull { it.login?.takeIf { l -> l.isNotBlank() } }, + ) +} + +fun GitHubLabelDto.toDomainOrNull(): GitHubLabel? { + val labelName = name?.takeIf { it.isNotBlank() } ?: return null + return GitHubLabel(name = labelName, color = color?.takeIf { it.isNotBlank() }) +} + +fun GitHubAssigneeDto.toDomainOrNull(): GitHubAssignee? { + val login = login?.takeIf { it.isNotBlank() } ?: return null + return GitHubAssignee(login = login, avatarUrl = avatarUrl?.takeIf { it.isNotBlank() }) +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt index e1e1a8b..cc92947 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/IntegrationsApi.kt @@ -1,10 +1,19 @@ package com.interlinedlist.android.feature.integrations.data.remote import com.interlinedlist.android.feature.integrations.data.remote.dto.ConnectionStatusDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.CreateCommentRequest +import com.interlinedlist.android.feature.integrations.data.remote.dto.CreateIssueRequest +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubAssigneeDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubIssueDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubLabelDto +import com.interlinedlist.android.feature.integrations.data.remote.dto.GitHubRepoDto import com.interlinedlist.android.feature.integrations.data.remote.dto.LimitsDto import okhttp3.ResponseBody +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST import retrofit2.http.Path +import retrofit2.http.Query import retrofit2.http.Streaming /** @@ -32,4 +41,51 @@ interface IntegrationsApi { /** Plan limits/usage for the current user. */ @GET("api/limits") suspend fun getLimits(): LimitsDto + + // --- GitHub --- + // + // The bearer is auto-injected. When GitHub isn't linked these return HTTP 400 + // with { "error": "GitHub account not linked" }; the repository detects that + // and surfaces a graceful "connect GitHub" state instead of a hard error. + + /** Repositories the user has connected/authorised on GitHub. */ + @GET("api/github/repos") + suspend fun getGitHubRepos(): List + + /** + * Issues for a repo. [repo] is "owner/name"; [state] is "open" (default), + * "closed", or "all". Both are optional query params per the OpenAPI spec. + */ + @GET("api/github/issues") + suspend fun getGitHubIssues( + @Query("repo") repo: String, + @Query("state") state: String? = null, + ): List + + /** Creates an issue; body carries repo/title/body plus optional labels/assignees. */ + @POST("api/github/issues") + suspend fun createGitHubIssue(@Body request: CreateIssueRequest): GitHubIssueDto + + /** Adds a comment to an existing issue. */ + @POST("api/github/issues/{owner}/{repo}/{number}/comments") + suspend fun addGitHubIssueComment( + @Path("owner") owner: String, + @Path("repo") repo: String, + @Path("number") number: Int, + @Body request: CreateCommentRequest, + ): ResponseBody + + /** Assignable users for a repo, for the create-issue composer. */ + @GET("api/github/repos/{owner}/{repo}/assignees") + suspend fun getGitHubAssignees( + @Path("owner") owner: String, + @Path("repo") repo: String, + ): List + + /** Labels defined on a repo, for the create-issue composer. */ + @GET("api/github/repos/{owner}/{repo}/labels") + suspend fun getGitHubLabels( + @Path("owner") owner: String, + @Path("repo") repo: String, + ): List } diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/GitHubDto.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/GitHubDto.kt new file mode 100644 index 0000000..e46768d --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/data/remote/dto/GitHubDto.kt @@ -0,0 +1,79 @@ +package com.interlinedlist.android.feature.integrations.data.remote.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * DTOs for the `/api/github/…` endpoints. The InterlinedList API proxies the + * GitHub REST API, whose response bodies are not modelled in the OpenAPI spec, so + * these are deliberately lenient: every field is optional and unknown keys are + * ignored by the shared [kotlinx.serialization.json.Json] config. This tolerates + * both the full GitHub shapes (nested `owner`, label/assignee objects) and any + * flattened variants the proxy might emit. + */ + +/** + * A repository. GitHub returns `owner` as a nested object with a `login`; the + * proxy may also flatten it to a top-level `owner` string, so both are accepted. + * `full_name` ("owner/name") is used as a fallback to recover the owner/name pair. + */ +@Serializable +data class GitHubRepoDto( + val name: String? = null, + @SerialName("full_name") val fullName: String? = null, + val owner: GitHubOwnerDto? = null, + @SerialName("ownerLogin") val ownerLogin: String? = null, + val private: Boolean? = null, + val description: String? = null, +) + +@Serializable +data class GitHubOwnerDto( + val login: String? = null, +) + +/** + * An issue. `labels` and `assignees` come back as arrays of objects from GitHub + * (`{ name }` / `{ login }`); the mapper flattens them to plain strings. + */ +@Serializable +data class GitHubIssueDto( + val number: Int? = null, + val title: String? = null, + val body: String? = null, + val state: String? = null, + val labels: List? = null, + val assignees: List? = null, +) + +@Serializable +data class GitHubLabelDto( + val name: String? = null, + val color: String? = null, +) + +@Serializable +data class GitHubAssigneeDto( + val login: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, +) + +/** + * Body for `POST /api/github/issues`. `repo` is "owner/name"; `labels` and + * `assignees` are comma-separated strings (the GitHub convention the API expects), + * omitted when empty. + */ +@Serializable +data class CreateIssueRequest( + val repo: String, + val title: String, + val body: String? = null, + val labels: String? = null, + val assignees: String? = null, +) + +/** Body for `POST /api/github/issues/{owner}/{repo}/{number}/comments`. */ +@Serializable +data class CreateCommentRequest( + val body: String, +) diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/GitHub.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/GitHub.kt new file mode 100644 index 0000000..2bbace9 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/domain/GitHub.kt @@ -0,0 +1,44 @@ +package com.interlinedlist.android.feature.integrations.domain + +/** + * Domain models for the GitHub integration. The InterlinedList API proxies the + * GitHub REST API, so these mirror the fields the web app relies on (repos, + * issues, labels, assignees) while staying deliberately small — the module only + * lists repos/issues and creates an issue or comment. + */ + +/** A GitHub repository the user has connected/authorised. */ +data class GitHubRepo( + val owner: String, + val name: String, + val isPrivate: Boolean = false, + val description: String? = null, +) { + /** "owner/name", the form GitHub and the create-issue endpoint expect. */ + val fullName: String get() = "$owner/$name" +} + +/** A single GitHub issue on a repo. */ +data class GitHubIssue( + val number: Int, + val title: String, + val body: String? = null, + val state: String = "open", + val labels: List = emptyList(), + val assignees: List = emptyList(), +) { + val isOpen: Boolean get() = state.equals("open", ignoreCase = true) +} + +/** A label available on a repo, used when composing an issue. */ +data class GitHubLabel( + val name: String, + /** Hex colour without the leading '#', when GitHub supplies one. */ + val color: String? = null, +) + +/** A user who can be assigned to issues on a repo. */ +data class GitHubAssignee( + val login: String, + val avatarUrl: String? = null, +) diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubScreen.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubScreen.kt new file mode 100644 index 0000000..c527468 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubScreen.kt @@ -0,0 +1,539 @@ +package com.interlinedlist.android.feature.integrations.ui.github + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo + +/** Stable test tags so UI/instrumented tests can address the GitHub controls. */ +object GitHubTestTags { + const val REPO_LIST = "githubRepoList" + const val REPOS_PROGRESS = "githubReposProgress" + const val NOT_CONNECTED = "githubNotConnected" + const val EMPTY_REPOS = "githubEmptyRepos" + const val ISSUE_LIST = "githubIssueList" + const val ISSUES_PROGRESS = "githubIssuesProgress" + const val EMPTY_ISSUES = "githubEmptyIssues" + const val NEW_ISSUE_FAB = "githubNewIssueFab" + const val COMPOSER = "githubComposer" + const val COMPOSER_TITLE = "githubComposerTitle" + const val COMPOSER_BODY = "githubComposerBody" + const val COMPOSER_SUBMIT = "githubComposerSubmit" + fun repo(fullName: String) = "githubRepo_$fullName" + fun issue(number: Int) = "githubIssue_$number" +} + +/** + * Hilt-wired entry point for the GitHub section. Reached from the Integrations + * hub; lists connected repos, drills into a repo's issues, and lets the user + * create an issue or comment on one. + */ +@Composable +fun GitHubRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: GitHubViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + GitHubScreen( + state = state, + onBack = onBack, + onRetryRepos = viewModel::loadRepos, + onSelectRepo = viewModel::selectRepo, + onClearRepo = viewModel::clearSelectedRepo, + onCreateIssue = { title, body, labels, assignees -> + viewModel.createIssue(title, body, labels, assignees) + }, + onAddComment = viewModel::addComment, + onMessageShown = viewModel::clearMessage, + onCreateErrorShown = viewModel::clearCreateError, + onCommentErrorShown = viewModel::clearCommentError, + modifier = modifier, + ) +} + +/** Stateless GitHub UI — easy to preview and to drive from Compose tests. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GitHubScreen( + state: GitHubUiState, + onBack: () -> Unit, + onRetryRepos: () -> Unit, + onSelectRepo: (GitHubRepo) -> Unit, + onClearRepo: () -> Unit, + onCreateIssue: (title: String, body: String?, labels: List, assignees: List) -> Unit, + onAddComment: (GitHubIssue, String) -> Unit, + onMessageShown: () -> Unit, + onCreateErrorShown: () -> Unit, + onCommentErrorShown: () -> Unit, + modifier: Modifier = Modifier, +) { + val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(state.message) { + state.message?.let { snackbarHostState.showSnackbar(it); onMessageShown() } + } + LaunchedEffect(state.createError) { + state.createError?.let { snackbarHostState.showSnackbar(it); onCreateErrorShown() } + } + LaunchedEffect(state.commentError) { + state.commentError?.let { snackbarHostState.showSnackbar(it); onCommentErrorShown() } + } + + val selected = state.selectedRepo + var showComposer by remember { mutableStateOf(false) } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(selected?.fullName ?: "GitHub") }, + navigationIcon = { + IconButton(onClick = { if (selected != null) onClearRepo() else onBack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + floatingActionButton = { + if (selected != null && !state.isLoadingIssues) { + FloatingActionButton( + onClick = { showComposer = true }, + modifier = Modifier.testTag(GitHubTestTags.NEW_ISSUE_FAB), + ) { + Icon(Icons.Default.Add, contentDescription = "New issue") + } + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + selected == null -> ReposPane(state, onRetryRepos, onSelectRepo) + else -> IssuesPane(state, onAddComment) + } + } + + if (showComposer && selected != null) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet( + onDismissRequest = { showComposer = false }, + sheetState = sheetState, + modifier = Modifier.testTag(GitHubTestTags.COMPOSER), + ) { + IssueComposer( + state = state, + onSubmit = { title, body, labels, assignees -> + onCreateIssue(title, body, labels, assignees) + showComposer = false + }, + onCancel = { showComposer = false }, + ) + } + } + } +} + +@Composable +private fun ReposPane( + state: GitHubUiState, + onRetry: () -> Unit, + onSelect: (GitHubRepo) -> Unit, +) { + when { + state.isLoadingRepos -> Centered { + CircularProgressIndicator(Modifier.testTag(GitHubTestTags.REPOS_PROGRESS)) + } + state.notConnected -> Centered { + EmptyState( + testTag = GitHubTestTags.NOT_CONNECTED, + title = "Connect GitHub", + message = "Link your GitHub account on the InterlinedList website to browse " + + "repositories and manage issues here.", + ) + } + state.reposError != null -> Centered { + EmptyState( + testTag = "githubReposError", + title = "Couldn't load repositories", + message = state.reposError, + action = "Retry" to onRetry, + ) + } + state.repos.isEmpty() -> Centered { + EmptyState( + testTag = GitHubTestTags.EMPTY_REPOS, + title = "No repositories", + message = "GitHub is connected, but no repositories are available yet.", + ) + } + else -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .testTag(GitHubTestTags.REPO_LIST), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + items(state.repos, key = { it.fullName }) { repo -> RepoRow(repo, onSelect) } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RepoRow(repo: GitHubRepo, onSelect: (GitHubRepo) -> Unit) { + Card( + onClick = { onSelect(repo) }, + modifier = Modifier.fillMaxWidth().testTag(GitHubTestTags.repo(repo.fullName)), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(repo.fullName, style = MaterialTheme.typography.titleMedium) + if (repo.isPrivate) { + Spacer(Modifier.size(6.dp)) + Icon( + Icons.Default.Lock, + contentDescription = "Private", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp), + ) + } + } + repo.description?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) + } + } +} + +@Composable +private fun IssuesPane( + state: GitHubUiState, + onAddComment: (GitHubIssue, String) -> Unit, +) { + when { + state.isLoadingIssues -> Centered { + CircularProgressIndicator(Modifier.testTag(GitHubTestTags.ISSUES_PROGRESS)) + } + state.issuesError != null -> Centered { + EmptyState( + testTag = "githubIssuesError", + title = "Couldn't load issues", + message = state.issuesError, + ) + } + state.issues.isEmpty() -> Centered { + EmptyState( + testTag = GitHubTestTags.EMPTY_ISSUES, + title = "No issues", + message = "This repository has no issues yet. Tap + to create one.", + ) + } + else -> LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .testTag(GitHubTestTags.ISSUE_LIST), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp), + ) { + items(state.issues, key = { it.number }) { issue -> + IssueRow( + issue = issue, + isCommenting = state.commentingOn == issue.number, + onAddComment = { body -> onAddComment(issue, body) }, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun IssueRow( + issue: GitHubIssue, + isCommenting: Boolean, + onAddComment: (String) -> Unit, +) { + var comment by remember { mutableStateOf("") } + var showComment by remember { mutableStateOf(false) } + + Card(modifier = Modifier.fillMaxWidth().testTag(GitHubTestTags.issue(issue.number))) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text( + text = "#${issue.number} · ${issue.title}", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = if (issue.isOpen) "Open" else "Closed", + style = MaterialTheme.typography.labelSmall, + color = if (issue.isOpen) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (issue.labels.isNotEmpty()) { + Spacer(Modifier.size(8.dp)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + issue.labels.forEach { label -> + AssistChip(onClick = {}, label = { Text(label) }) + } + } + } + issue.body?.let { + Spacer(Modifier.size(6.dp)) + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.size(8.dp)) + if (showComment) { + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Comment") }, + modifier = Modifier.fillMaxWidth(), + enabled = !isCommenting, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { showComment = false }) { Text("Cancel") } + OutlinedButton( + onClick = { onAddComment(comment); comment = "" }, + enabled = comment.isNotBlank() && !isCommenting, + ) { + if (isCommenting) { + CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + Text("Comment") + } + } + } + } else { + TextButton(onClick = { showComment = true }) { Text("Add comment") } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun IssueComposer( + state: GitHubUiState, + onSubmit: (title: String, body: String?, labels: List, assignees: List) -> Unit, + onCancel: () -> Unit, +) { + var title by remember { mutableStateOf("") } + var body by remember { mutableStateOf("") } + val selectedLabels = remember { mutableStateOf(emptySet()) } + val selectedAssignees = remember { mutableStateOf(emptySet()) } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("New issue", style = MaterialTheme.typography.titleLarge) + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag(GitHubTestTags.COMPOSER_TITLE), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text("Description") }, + modifier = Modifier.fillMaxWidth().testTag(GitHubTestTags.COMPOSER_BODY), + ) + if (state.labels.isNotEmpty()) { + Text("Labels", style = MaterialTheme.typography.labelLarge) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.labels.forEach { label -> + val checked = label.name in selectedLabels.value + FilterChip( + selected = checked, + onClick = { + selectedLabels.value = selectedLabels.value.toggle(label.name) + }, + label = { Text(label.name) }, + ) + } + } + } + if (state.assignees.isNotEmpty()) { + Text("Assignees", style = MaterialTheme.typography.labelLarge) + FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + state.assignees.forEach { assignee -> + val checked = assignee.login in selectedAssignees.value + FilterChip( + selected = checked, + onClick = { + selectedAssignees.value = selectedAssignees.value.toggle(assignee.login) + }, + label = { Text(assignee.login) }, + ) + } + } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = onCancel) { Text("Cancel") } + OutlinedButton( + onClick = { + onSubmit( + title, + body.ifBlank { null }, + selectedLabels.value.toList(), + selectedAssignees.value.toList(), + ) + }, + enabled = title.isNotBlank() && !state.isCreatingIssue, + modifier = Modifier.testTag(GitHubTestTags.COMPOSER_SUBMIT), + ) { + Text("Create issue") + } + } + } +} + +private fun Set.toggle(value: String): Set = + if (value in this) this - value else this + value + +@Composable +private fun Centered(content: @Composable () -> Unit) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { content() } +} + +@Composable +private fun EmptyState( + testTag: String, + title: String, + message: String, + action: Pair Unit>? = null, +) { + Column( + modifier = Modifier.fillMaxWidth().testTag(testTag), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + action?.let { (label, onClick) -> + OutlinedButton(onClick = onClick) { Text(label) } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun GitHubReposPreview() { + InterlinedListTheme { + GitHubScreen( + state = GitHubUiState( + isLoadingRepos = false, + repos = listOf( + GitHubRepo("adron", "interlinedlist-android", description = "The app"), + GitHubRepo("adron", "notes", isPrivate = true), + ), + ), + onBack = {}, + onRetryRepos = {}, + onSelectRepo = {}, + onClearRepo = {}, + onCreateIssue = { _, _, _, _ -> }, + onAddComment = { _, _ -> }, + onMessageShown = {}, + onCreateErrorShown = {}, + onCommentErrorShown = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun GitHubNotConnectedPreview() { + InterlinedListTheme { + GitHubScreen( + state = GitHubUiState(isLoadingRepos = false, notConnected = true), + onBack = {}, + onRetryRepos = {}, + onSelectRepo = {}, + onClearRepo = {}, + onCreateIssue = { _, _, _, _ -> }, + onAddComment = { _, _ -> }, + onMessageShown = {}, + onCreateErrorShown = {}, + onCommentErrorShown = {}, + ) + } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModel.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModel.kt new file mode 100644 index 0000000..a761e88 --- /dev/null +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModel.kt @@ -0,0 +1,186 @@ +package com.interlinedlist.android.feature.integrations.ui.github + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository +import com.interlinedlist.android.feature.integrations.data.isGitHubNotLinked +import com.interlinedlist.android.feature.integrations.domain.GitHubAssignee +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubLabel +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo +import com.interlinedlist.android.feature.integrations.ui.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * UI state for the GitHub section. Modelled as one flat state so the screen can + * render three panes off it: the repo list, the selected repo's issues, and the + * create-issue composer. + * + * [notConnected] is the graceful "connect GitHub" state, set when the API reports + * the account isn't linked; the screen shows a connect prompt instead of an error. + */ +data class GitHubUiState( + val isLoadingRepos: Boolean = true, + val notConnected: Boolean = false, + val repos: List = emptyList(), + val reposError: String? = null, + + val selectedRepo: GitHubRepo? = null, + val isLoadingIssues: Boolean = false, + val issues: List = emptyList(), + val issuesError: String? = null, + + // Composer context for the selected repo. + val labels: List = emptyList(), + val assignees: List = emptyList(), + + val isCreatingIssue: Boolean = false, + val createError: String? = null, + + val commentingOn: Int? = null, + val commentError: String? = null, + /** One-shot user-facing confirmations (issue created, comment added). */ + val message: String? = null, +) + +@HiltViewModel +class GitHubViewModel @Inject constructor( + private val repository: IntegrationsRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(GitHubUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { loadRepos() } + + fun loadRepos() { + _uiState.update { it.copy(isLoadingRepos = true, reposError = null, notConnected = false) } + viewModelScope.launch { + when (val result = repository.getGitHubRepos()) { + is ApiResult.Success -> _uiState.update { + it.copy(isLoadingRepos = false, repos = result.data) + } + is ApiResult.Failure -> _uiState.update { + if (result.error.isGitHubNotLinked()) { + it.copy(isLoadingRepos = false, notConnected = true, repos = emptyList()) + } else { + it.copy(isLoadingRepos = false, reposError = result.error.toUserMessage()) + } + } + } + } + } + + /** Opens a repo and loads its issues plus its labels/assignees for the composer. */ + fun selectRepo(repo: GitHubRepo) { + _uiState.update { + it.copy( + selectedRepo = repo, + isLoadingIssues = true, + issues = emptyList(), + issuesError = null, + labels = emptyList(), + assignees = emptyList(), + createError = null, + ) + } + viewModelScope.launch { + when (val result = repository.getGitHubIssues(repo.fullName)) { + is ApiResult.Success -> _uiState.update { it.copy(isLoadingIssues = false, issues = result.data) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingIssues = false, issuesError = result.error.toUserMessage()) + } + } + } + // Composer context is best-effort; failures leave the pickers empty. + viewModelScope.launch { + val labels = (repository.getGitHubLabels(repo.owner, repo.name) as? ApiResult.Success)?.data.orEmpty() + val assignees = (repository.getGitHubAssignees(repo.owner, repo.name) as? ApiResult.Success)?.data.orEmpty() + _uiState.update { it.copy(labels = labels, assignees = assignees) } + } + } + + /** Returns to the repo list. */ + fun clearSelectedRepo() { + _uiState.update { + it.copy( + selectedRepo = null, + issues = emptyList(), + issuesError = null, + labels = emptyList(), + assignees = emptyList(), + ) + } + } + + fun refreshIssues() { + val repo = _uiState.value.selectedRepo ?: return + _uiState.update { it.copy(isLoadingIssues = true, issuesError = null) } + viewModelScope.launch { + when (val result = repository.getGitHubIssues(repo.fullName)) { + is ApiResult.Success -> _uiState.update { it.copy(isLoadingIssues = false, issues = result.data) } + is ApiResult.Failure -> _uiState.update { + it.copy(isLoadingIssues = false, issuesError = result.error.toUserMessage()) + } + } + } + } + + /** + * Creates an issue on the selected repo. On success the new issue is prepended + * to the list optimistically (in addition to being persisted server-side), so + * the user sees it immediately without waiting for a refresh. + */ + fun createIssue( + title: String, + body: String?, + labels: List = emptyList(), + assignees: List = emptyList(), + ) { + val repo = _uiState.value.selectedRepo ?: return + if (title.isBlank() || _uiState.value.isCreatingIssue) return + _uiState.update { it.copy(isCreatingIssue = true, createError = null) } + viewModelScope.launch { + when (val result = repository.createGitHubIssue(repo.fullName, title.trim(), body, labels, assignees)) { + is ApiResult.Success -> _uiState.update { + it.copy( + isCreatingIssue = false, + issues = listOf(result.data) + it.issues, + message = "Issue created", + ) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isCreatingIssue = false, createError = result.error.toUserMessage()) + } + } + } + } + + /** Adds a comment to [issue] on the selected repo. */ + fun addComment(issue: GitHubIssue, body: String) { + val repo = _uiState.value.selectedRepo ?: return + if (body.isBlank() || _uiState.value.commentingOn != null) return + _uiState.update { it.copy(commentingOn = issue.number, commentError = null) } + viewModelScope.launch { + when (val result = repository.addGitHubIssueComment(repo.owner, repo.name, issue.number, body.trim())) { + is ApiResult.Success -> _uiState.update { + it.copy(commentingOn = null, message = "Comment added") + } + is ApiResult.Failure -> _uiState.update { + it.copy(commentingOn = null, commentError = result.error.toUserMessage()) + } + } + } + } + + fun clearMessage() = _uiState.update { it.copy(message = null) } + fun clearCreateError() = _uiState.update { it.copy(createError = null) } + fun clearCommentError() = _uiState.update { it.copy(commentError = null) } +} diff --git a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt index 491cfa8..869e20d 100644 --- a/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt +++ b/feature/integrations/src/main/kotlin/com/interlinedlist/android/feature/integrations/ui/hub/IntegrationsHubScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Link import androidx.compose.material3.Card @@ -41,6 +42,7 @@ import com.interlinedlist.android.feature.integrations.domain.PlanLimits object IntegrationsHubTestTags { const val EXPORT = "hubExport" const val ACCOUNTS = "hubAccounts" + const val GITHUB = "hubGitHub" const val LIMITS = "hubLimits" } @@ -52,12 +54,15 @@ object IntegrationsHubTestTags { * @param onBack pop back to the Account hub. * @param onOpenExport navigate to the export sub-route (see ExportRoute). * @param onOpenConnectedAccounts navigate to the connected-accounts sub-route. + * @param onOpenGitHub navigate to the GitHub sub-route (see GitHubRoute). Defaulted + * to a no-op so existing app-level nav that hasn't wired it yet still compiles. */ @Composable fun IntegrationsRoute( onBack: () -> Unit, onOpenExport: () -> Unit, onOpenConnectedAccounts: () -> Unit, + onOpenGitHub: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: IntegrationsHubViewModel = hiltViewModel(), ) { @@ -67,6 +72,7 @@ fun IntegrationsRoute( onBack = onBack, onOpenExport = onOpenExport, onOpenConnectedAccounts = onOpenConnectedAccounts, + onOpenGitHub = onOpenGitHub, modifier = modifier, ) } @@ -79,6 +85,7 @@ fun IntegrationsHubScreen( onBack: () -> Unit, onOpenExport: () -> Unit, onOpenConnectedAccounts: () -> Unit, + onOpenGitHub: () -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -120,6 +127,15 @@ fun IntegrationsHubScreen( testTag = IntegrationsHubTestTags.ACCOUNTS, ) } + item { + HubEntry( + icon = Icons.Default.BugReport, + title = "GitHub", + subtitle = "Browse connected repos and manage issues.", + onClick = onOpenGitHub, + testTag = IntegrationsHubTestTags.GITHUB, + ) + } state.limits?.takeIf { it.limits.isNotEmpty() }?.let { limits -> item { LimitsCard(limits) } } diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryGitHubTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryGitHubTest.kt new file mode 100644 index 0000000..94dc7b5 --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/data/DefaultIntegrationsRepositoryGitHubTest.kt @@ -0,0 +1,265 @@ +package com.interlinedlist.android.feature.integrations.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.integrations.data.remote.IntegrationsApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import java.io.File +import java.nio.file.Files + +/** + * GitHub repository behaviour against a real Retrofit/OkHttp stack over + * MockWebServer. Covers parsing repos/issues/labels/assignees, the create-issue + * and add-comment request shapes, and the graceful "GitHub not linked" state the + * live API returns (HTTP 400 with { "error": "GitHub account not linked" }). + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultIntegrationsRepositoryGitHubTest { + + private lateinit var server: MockWebServer + private lateinit var repository: DefaultIntegrationsRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val dispatcher = StandardTestDispatcher() + + private val testDispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher get() = dispatcher + override val default: CoroutineDispatcher get() = dispatcher + override val main: CoroutineDispatcher get() = dispatcher + } + + @Before + fun setUp() { + server = MockWebServer().also { it.start() } + val api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(IntegrationsApi::class.java) + val tempDir = Files.createTempDirectory("gh-test").toFile() + val fileStore = object : ExportFileStore { + override fun exportsDir(): File = File(tempDir, "exports") + } + repository = DefaultIntegrationsRepository(api, fileStore, json, testDispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `getGitHubRepos parses nested owner and full_name into domain repos`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + [ + { "name": "hello", "owner": { "login": "adron" }, "private": false, "description": "hi" }, + { "full_name": "octocat/spoon-knife", "private": true } + ] + """.trimIndent(), + ), + ) + + val result = repository.getGitHubRepos() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val repos = (result as ApiResult.Success).data + assertThat(repos).hasSize(2) + assertThat(repos[0].owner).isEqualTo("adron") + assertThat(repos[0].name).isEqualTo("hello") + assertThat(repos[0].fullName).isEqualTo("adron/hello") + assertThat(repos[0].isPrivate).isFalse() + // full_name-only repo is recovered. + assertThat(repos[1].owner).isEqualTo("octocat") + assertThat(repos[1].name).isEqualTo("spoon-knife") + assertThat(repos[1].isPrivate).isTrue() + assertThat(server.takeRequest().path).isEqualTo("/api/github/repos") + } + + @Test + fun `getGitHubRepos maps the not-linked 400 to a failure the UI recognises`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(400) + .setBody("""{ "error": "GitHub account not linked" }"""), + ) + + val result = repository.getGitHubRepos() + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.isGitHubNotLinked()).isTrue() + } + + @Test + fun `getGitHubRepos surfaces an empty list when GitHub is connected but has no repos`() = + runTest(dispatcher) { + server.enqueue(MockResponse().setBody("[]")) + + val result = repository.getGitHubRepos() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEmpty() + } + + @Test + fun `getGitHubIssues sends repo and state params and flattens labels and assignees`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """ + [ + { + "number": 7, + "title": "Fix bug", + "body": "It broke", + "state": "open", + "labels": [ { "name": "bug", "color": "d73a4a" }, { "name": "p1" } ], + "assignees": [ { "login": "adron" } ] + }, + { "title": "no number, dropped" } + ] + """.trimIndent(), + ), + ) + + val result = repository.getGitHubIssues(repo = "adron/hello", state = "open") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val issues = (result as ApiResult.Success).data + // The numberless entry is dropped. + assertThat(issues).hasSize(1) + assertThat(issues[0].number).isEqualTo(7) + assertThat(issues[0].title).isEqualTo("Fix bug") + assertThat(issues[0].labels).containsExactly("bug", "p1").inOrder() + assertThat(issues[0].assignees).containsExactly("adron") + assertThat(issues[0].isOpen).isTrue() + + val request = server.takeRequest() + assertThat(request.requestUrl!!.queryParameter("repo")).isEqualTo("adron/hello") + assertThat(request.requestUrl!!.queryParameter("state")).isEqualTo("open") + } + + @Test + fun `createGitHubIssue posts repo title body and comma-joined labels and assignees`() = + runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "number": 12, "title": "New issue", "state": "open" }"""), + ) + + val result = repository.createGitHubIssue( + repo = "adron/hello", + title = "New issue", + body = "Please fix", + labels = listOf("bug", "p1"), + assignees = listOf("adron"), + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data.number).isEqualTo(12) + + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/github/issues") + val sent = json.parseToJsonElementMap(request.body.readUtf8()) + assertThat(sent["repo"]).isEqualTo("adron/hello") + assertThat(sent["title"]).isEqualTo("New issue") + assertThat(sent["body"]).isEqualTo("Please fix") + assertThat(sent["labels"]).isEqualTo("bug,p1") + assertThat(sent["assignees"]).isEqualTo("adron") + } + + @Test + fun `createGitHubIssue omits empty labels and assignees from the body`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setResponseCode(201) + .setBody("""{ "number": 1, "title": "Bare" }"""), + ) + + repository.createGitHubIssue(repo = "a/b", title = "Bare", body = null) + + val sent = json.parseToJsonElementMap(server.takeRequest().body.readUtf8()) + assertThat(sent.containsKey("labels")).isFalse() + assertThat(sent.containsKey("assignees")).isFalse() + assertThat(sent.containsKey("body")).isFalse() + } + + @Test + fun `createGitHubIssue falls back to a synthetic issue when the response lacks a number`() = + runTest(dispatcher) { + // Some proxies wrap the issue; if we can't read a number, keep the UI optimistic. + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "ok": true }""")) + + val result = repository.createGitHubIssue(repo = "a/b", title = "Ghost", body = "b") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val issue = (result as ApiResult.Success).data + assertThat(issue.title).isEqualTo("Ghost") + assertThat(issue.body).isEqualTo("b") + } + + @Test + fun `addGitHubIssueComment posts the body to the comment path`() = runTest(dispatcher) { + server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "id": 1 }""")) + + val result = repository.addGitHubIssueComment( + owner = "adron", + repo = "hello", + number = 7, + body = "Thanks!", + ) + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("POST") + assertThat(request.path).isEqualTo("/api/github/issues/adron/hello/7/comments") + assertThat(json.parseToJsonElementMap(request.body.readUtf8())["body"]).isEqualTo("Thanks!") + } + + @Test + fun `getGitHubLabels and getGitHubAssignees parse into domain models`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """[ { "name": "bug", "color": "d73a4a" }, { "name": "docs" }, { "color": "nope" } ]""", + ), + ) + server.enqueue( + MockResponse().setBody( + """[ { "login": "adron", "avatar_url": "http://x/y.png" }, { "avatar_url": "z" } ]""", + ), + ) + + val labels = repository.getGitHubLabels("adron", "hello") + val assignees = repository.getGitHubAssignees("adron", "hello") + + assertThat((labels as ApiResult.Success).data.map { it.name }) + .containsExactly("bug", "docs").inOrder() + // The label with no name is dropped. + assertThat((assignees as ApiResult.Success).data.map { it.login }).containsExactly("adron") + + assertThat(server.takeRequest().path).isEqualTo("/api/github/repos/adron/hello/labels") + assertThat(server.takeRequest().path).isEqualTo("/api/github/repos/adron/hello/assignees") + } +} + +/** Parses a flat JSON object of string values for assertion convenience. */ +private fun Json.parseToJsonElementMap(body: String): Map { + val obj = parseToJsonElement(body) + return (obj as kotlinx.serialization.json.JsonObject).mapValues { (_, v) -> + (v as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull + } +} + +private val kotlinx.serialization.json.JsonPrimitive.contentOrNull: String? + get() = if (this is kotlinx.serialization.json.JsonNull) null else content diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt index b9fd84e..67e7ba5 100644 --- a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/FakeIntegrationsRepository.kt @@ -5,6 +5,10 @@ import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.feature.integrations.data.IntegrationsRepository import com.interlinedlist.android.feature.integrations.domain.ConnectedAccount import com.interlinedlist.android.feature.integrations.domain.ExportType +import com.interlinedlist.android.feature.integrations.domain.GitHubAssignee +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubLabel +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo import com.interlinedlist.android.feature.integrations.domain.PlanLimits import java.io.File @@ -19,6 +23,34 @@ class FakeIntegrationsRepository : IntegrationsRepository { var limitsResult: ApiResult = ApiResult.Failure(AppError.Unknown("not set")) + // --- GitHub --- + var reposResult: ApiResult> = ApiResult.Success(emptyList()) + var reposCalls = 0 + + /** Issues keyed by repo full name ("owner/name"); defaults to [defaultIssuesResult]. */ + val issuesByRepo = mutableMapOf>>() + var defaultIssuesResult: ApiResult> = ApiResult.Success(emptyList()) + val issuesRequested = mutableListOf>() + + var createIssueResult: ApiResult = ApiResult.Failure(AppError.Unknown("not set")) + val createdIssues = mutableListOf() + + var addCommentResult: ApiResult = ApiResult.Success(Unit) + val addedComments = mutableListOf() + + var assigneesResult: ApiResult> = ApiResult.Success(emptyList()) + var labelsResult: ApiResult> = ApiResult.Success(emptyList()) + + data class CreatedIssue( + val repo: String, + val title: String, + val body: String?, + val labels: List, + val assignees: List, + ) + + data class AddedComment(val owner: String, val repo: String, val number: Int, val body: String) + override suspend fun downloadExport(type: ExportType): ApiResult { exportedTypes.add(type) return exportResult @@ -30,4 +62,41 @@ class FakeIntegrationsRepository : IntegrationsRepository { } override suspend fun getLimits(): ApiResult = limitsResult + + override suspend fun getGitHubRepos(): ApiResult> { + reposCalls++ + return reposResult + } + + override suspend fun getGitHubIssues(repo: String, state: String?): ApiResult> { + issuesRequested.add(repo to state) + return issuesByRepo[repo] ?: defaultIssuesResult + } + + override suspend fun createGitHubIssue( + repo: String, + title: String, + body: String?, + labels: List, + assignees: List, + ): ApiResult { + createdIssues.add(CreatedIssue(repo, title, body, labels, assignees)) + return createIssueResult + } + + override suspend fun addGitHubIssueComment( + owner: String, + repo: String, + number: Int, + body: String, + ): ApiResult { + addedComments.add(AddedComment(owner, repo, number, body)) + return addCommentResult + } + + override suspend fun getGitHubAssignees(owner: String, repo: String): ApiResult> = + assigneesResult + + override suspend fun getGitHubLabels(owner: String, repo: String): ApiResult> = + labelsResult } diff --git a/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModelTest.kt b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModelTest.kt new file mode 100644 index 0000000..4cf89db --- /dev/null +++ b/feature/integrations/src/test/kotlin/com/interlinedlist/android/feature/integrations/ui/github/GitHubViewModelTest.kt @@ -0,0 +1,225 @@ +package com.interlinedlist.android.feature.integrations.ui.github + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.integrations.domain.GitHubIssue +import com.interlinedlist.android.feature.integrations.domain.GitHubLabel +import com.interlinedlist.android.feature.integrations.domain.GitHubRepo +import com.interlinedlist.android.feature.integrations.ui.FakeIntegrationsRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GitHubViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeIntegrationsRepository + + private val hello = GitHubRepo(owner = "adron", name = "hello") + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeIntegrationsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads repos on init and clears loading`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + + val vm = GitHubViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isLoadingRepos).isFalse() + assertThat(vm.uiState.value.repos).containsExactly(hello) + assertThat(vm.uiState.value.notConnected).isFalse() + assertThat(repo.reposCalls).isEqualTo(1) + } + + @Test + fun `not-linked failure sets the connect state rather than an error`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Failure(AppError.Unknown("GitHub account not linked")) + + val vm = GitHubViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.notConnected).isTrue() + assertThat(vm.uiState.value.reposError).isNull() + assertThat(vm.uiState.value.repos).isEmpty() + } + + @Test + fun `a generic repos failure surfaces a mapped error message`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Failure(AppError.Network(null)) + + val vm = GitHubViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.notConnected).isFalse() + assertThat(vm.uiState.value.reposError) + .isEqualTo("No connection. Check your network and try again.") + } + + @Test + fun `empty repos is a success with no error`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(emptyList()) + + val vm = GitHubViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.repos).isEmpty() + assertThat(vm.uiState.value.reposError).isNull() + assertThat(vm.uiState.value.notConnected).isFalse() + } + + @Test + fun `selecting a repo loads its issues, labels and assignees`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + repo.issuesByRepo["adron/hello"] = ApiResult.Success( + listOf(GitHubIssue(number = 1, title = "First")), + ) + repo.labelsResult = ApiResult.Success(listOf(GitHubLabel("bug"))) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + + vm.selectRepo(hello) + advanceUntilIdle() + + assertThat(vm.uiState.value.selectedRepo).isEqualTo(hello) + assertThat(vm.uiState.value.isLoadingIssues).isFalse() + assertThat(vm.uiState.value.issues).hasSize(1) + assertThat(vm.uiState.value.labels.map { it.name }).containsExactly("bug") + assertThat(repo.issuesRequested).contains("adron/hello" to null) + } + + @Test + fun `selecting a repo with no issues shows an empty list without error`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + repo.issuesByRepo["adron/hello"] = ApiResult.Success(emptyList()) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + + vm.selectRepo(hello) + advanceUntilIdle() + + assertThat(vm.uiState.value.issues).isEmpty() + assertThat(vm.uiState.value.issuesError).isNull() + } + + @Test + fun `creating an issue optimistically prepends it and confirms`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + repo.issuesByRepo["adron/hello"] = ApiResult.Success( + listOf(GitHubIssue(number = 1, title = "Old")), + ) + repo.createIssueResult = ApiResult.Success(GitHubIssue(number = 2, title = "New")) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + vm.selectRepo(hello) + advanceUntilIdle() + + vm.createIssue(title = "New", body = "b", labels = listOf("bug"), assignees = listOf("adron")) + advanceUntilIdle() + + assertThat(vm.uiState.value.isCreatingIssue).isFalse() + // New issue is at the head of the list. + assertThat(vm.uiState.value.issues.map { it.number }).containsExactly(2, 1).inOrder() + assertThat(vm.uiState.value.message).isEqualTo("Issue created") + // The right repo/title/labels/assignees were sent. + val created = repo.createdIssues.single() + assertThat(created.repo).isEqualTo("adron/hello") + assertThat(created.title).isEqualTo("New") + assertThat(created.labels).containsExactly("bug") + assertThat(created.assignees).containsExactly("adron") + } + + @Test + fun `a blank title does not call the repository`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + vm.selectRepo(hello) + advanceUntilIdle() + + vm.createIssue(title = " ", body = null) + advanceUntilIdle() + + assertThat(repo.createdIssues).isEmpty() + } + + @Test + fun `a failed create surfaces a mapped error and leaves the list unchanged`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + repo.issuesByRepo["adron/hello"] = ApiResult.Success( + listOf(GitHubIssue(number = 1, title = "Old")), + ) + repo.createIssueResult = ApiResult.Failure(AppError.Server(null)) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + vm.selectRepo(hello) + advanceUntilIdle() + + vm.createIssue(title = "New", body = null) + advanceUntilIdle() + + assertThat(vm.uiState.value.createError) + .isEqualTo("InterlinedList is having trouble right now. Try again shortly.") + assertThat(vm.uiState.value.issues.map { it.number }).containsExactly(1) + } + + @Test + fun `adding a comment sends it and confirms`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + repo.issuesByRepo["adron/hello"] = ApiResult.Success( + listOf(GitHubIssue(number = 5, title = "Bug")), + ) + repo.addCommentResult = ApiResult.Success(Unit) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + vm.selectRepo(hello) + advanceUntilIdle() + + vm.addComment(GitHubIssue(number = 5, title = "Bug"), "Looking into it") + advanceUntilIdle() + + assertThat(vm.uiState.value.message).isEqualTo("Comment added") + val added = repo.addedComments.single() + assertThat(added.owner).isEqualTo("adron") + assertThat(added.repo).isEqualTo("hello") + assertThat(added.number).isEqualTo(5) + assertThat(added.body).isEqualTo("Looking into it") + } + + @Test + fun `state transitions expose the in-flight create flag`() = runTest(dispatcher) { + repo.reposResult = ApiResult.Success(listOf(hello)) + repo.issuesByRepo["adron/hello"] = ApiResult.Success(emptyList()) + repo.createIssueResult = ApiResult.Success(GitHubIssue(number = 9, title = "X")) + val vm = GitHubViewModel(repo) + advanceUntilIdle() + vm.selectRepo(hello) + advanceUntilIdle() + + vm.uiState.test { + assertThat(awaitItem().isCreatingIssue).isFalse() // current + vm.createIssue(title = "X", body = null) + assertThat(awaitItem().isCreatingIssue).isTrue() // in-flight + advanceUntilIdle() + assertThat(awaitItem().isCreatingIssue).isFalse() // done + cancelAndIgnoreRemainingEvents() + } + } +} From 55dd6ee105e40007a241ca4a6120ba6026dfb83a Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 17:21:11 -0700 Subject: [PATCH 22/25] feat(profile): user moderation - block/mute/report (Milestone D, profile-side) Overflow menu on the other-user profile (Block/Mute/Report with confirm + reason) and a 'Blocked & muted' management screen in the Account hub. Endpoints: /api/users/{username}/block|mute|report (+ status GETs), /api/user/blocks|mutes. Report body confirmed {reason, detail?}. 150 profile unit tests green (27 new). The message-feed moderation menu stays deferred (touches :feature:messages). Nav wiring deferred to the app-level pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../profile/ui/ModerationScreensTest.kt | 100 +++++++ .../profile/data/DefaultProfileRepository.kt | 59 ++++ .../feature/profile/data/ProfileRepository.kt | 40 +++ .../profile/data/mapper/ModerationMappers.kt | 20 ++ .../feature/profile/data/remote/ProfileApi.kt | 52 ++++ .../data/remote/dto/ModerationResponses.kt | 85 ++++++ .../feature/profile/domain/Moderation.kt | 38 +++ .../profile/ui/account/BlockedMutedScreen.kt | 259 ++++++++++++++++ .../ui/account/BlockedMutedViewModel.kt | 134 +++++++++ .../profile/ui/profile/ProfileScreen.kt | 14 + .../profile/ui/profile/ProfileViewModel.kt | 11 + .../profile/ui/profile/UserProfileScreen.kt | 276 ++++++++++++++++++ .../ui/profile/UserProfileViewModel.kt | 96 ++++++ .../DefaultProfileRepositoryModerationTest.kt | 237 +++++++++++++++ .../profile/ui/BlockedMutedViewModelTest.kt | 116 ++++++++ .../profile/ui/FakeProfileRepository.kt | 85 ++++++ .../profile/ui/UserProfileModerationTest.kt | 159 ++++++++++ 17 files changed, 1781 insertions(+) create mode 100644 feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ModerationScreensTest.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ModerationMappers.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ModerationResponses.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/Moderation.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedScreen.kt create mode 100644 feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedViewModel.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryModerationTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/BlockedMutedViewModelTest.kt create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileModerationTest.kt diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ModerationScreensTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ModerationScreensTest.kt new file mode 100644 index 0000000..4f47f4f --- /dev/null +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/ModerationScreensTest.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.CustomerStatus +import com.interlinedlist.android.feature.profile.domain.ModeratedUser +import com.interlinedlist.android.feature.profile.domain.ModerationStatus +import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.ui.account.BlockedMutedScreen +import com.interlinedlist.android.feature.profile.ui.account.BlockedMutedTestTags +import com.interlinedlist.android.feature.profile.ui.account.BlockedMutedUiState +import com.interlinedlist.android.feature.profile.ui.profile.ProfileModerationTestTags +import com.interlinedlist.android.feature.profile.ui.profile.ProfileUiState +import com.interlinedlist.android.feature.profile.ui.profile.UserProfileScreen +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ModerationScreensTest { + + @get:Rule + val composeRule = createComposeRule() + + private val blocked = listOf(ModeratedUser("b1", "spammer", "Spam Bot", null)) + private val muted = listOf(ModeratedUser("m1", "noisy", "Very Loud", null)) + + @Test + fun blockedMuted_rendersBothSections() { + composeRule.setContent { + InterlinedListTheme { + BlockedMutedScreen( + state = BlockedMutedUiState(blocked = blocked, muted = muted, isLoading = false), + onUnblock = {}, + onUnmute = {}, + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(BlockedMutedTestTags.LIST).assertIsDisplayed() + composeRule.onNodeWithTag(BlockedMutedTestTags.blockedRow("spammer")).assertIsDisplayed() + composeRule.onNodeWithTag(BlockedMutedTestTags.mutedRow("noisy")).assertIsDisplayed() + } + + @Test + fun blockedMuted_unblockInvokesCallback() { + var unblocked: String? = null + composeRule.setContent { + InterlinedListTheme { + BlockedMutedScreen( + state = BlockedMutedUiState(blocked = blocked, muted = muted, isLoading = false), + onUnblock = { unblocked = it }, + onUnmute = {}, + onBack = {}, + onRetry = {}, + ) + } + } + + composeRule.onNodeWithTag(BlockedMutedTestTags.unblock("spammer")).performClick() + assert(unblocked == "spammer") + } + + @Test + fun profileOverflow_showsModerationActions() { + composeRule.setContent { + InterlinedListTheme { + UserProfileScreen( + state = ProfileUiState( + user = ProfileUser( + id = "u2", + username = "ada", + displayName = "Ada Lovelace", + avatarUrl = null, + bio = null, + customerStatus = CustomerStatus.FREE, + isCurrentUser = false, + ), + isLoading = false, + moderationStatus = ModerationStatus(), + ), + onBack = {}, + onRetry = {}, + ) + } + } + + // The overflow menu is offered for another user; opening it reveals the actions. + composeRule.onNodeWithTag(ProfileModerationTestTags.OVERFLOW).performClick() + composeRule.onNodeWithTag(ProfileModerationTestTags.MENU_BLOCK).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileModerationTestTags.MENU_MUTE).assertIsDisplayed() + composeRule.onNodeWithTag(ProfileModerationTestTags.MENU_REPORT).assertIsDisplayed() + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt index b180e8c..ac30066 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt @@ -12,6 +12,7 @@ import com.interlinedlist.android.feature.profile.data.mapper.toFollowCounts import com.interlinedlist.android.feature.profile.data.mapper.toFollowStatus import com.interlinedlist.android.feature.profile.data.mapper.toFollowUser import com.interlinedlist.android.feature.profile.data.mapper.toFollowUserOrNull +import com.interlinedlist.android.feature.profile.data.mapper.toModeratedUserOrNull import com.interlinedlist.android.feature.profile.data.mapper.toMutualConnections import com.interlinedlist.android.feature.profile.data.mapper.toProfileUser import com.interlinedlist.android.feature.profile.data.mapper.toPublicDocumentDetail @@ -25,14 +26,18 @@ import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlR import com.interlinedlist.android.feature.profile.data.remote.dto.ChangeEmailRequest import com.interlinedlist.android.feature.profile.data.remote.dto.DeleteAccountRequest import com.interlinedlist.android.feature.profile.data.remote.dto.ProfileUserDto +import com.interlinedlist.android.feature.profile.data.remote.dto.ReportUserRequest import com.interlinedlist.android.feature.profile.data.remote.dto.UpdateProfileRequest import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.LinkedIdentity import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.domain.ModeratedUser +import com.interlinedlist.android.feature.profile.domain.ModerationStatus import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.ReportReason import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary import com.interlinedlist.android.feature.profile.domain.PublicListDetail @@ -298,6 +303,60 @@ class DefaultProfileRepository @Inject constructor( safeApiCall(json) { api.deleteAccount(DeleteAccountRequest(username = username, email = email)) } } + // --- Moderation (block / mute / report; read-only lists, nothing cached) --- + + override suspend fun getBlockedUsers(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getBlocks(limit = LIST_LIMIT).usersOrEmpty.mapNotNull { it.toModeratedUserOrNull() } + } + } + + override suspend fun getMutedUsers(): ApiResult> = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getMutes(limit = LIST_LIMIT).usersOrEmpty.mapNotNull { it.toModeratedUserOrNull() } + } + } + + override suspend fun getModerationStatus(username: String): ApiResult = + withContext(dispatchers.io) { + // Two endpoints back the single status; combine them, failing fast if either does. + when (val block = safeApiCall(json) { api.getBlockStatus(username).blocked }) { + is ApiResult.Success -> when (val mute = safeApiCall(json) { api.getMuteStatus(username).muted }) { + is ApiResult.Success -> + ApiResult.Success(ModerationStatus(isBlocked = block.data, isMuted = mute.data)) + is ApiResult.Failure -> mute + } + is ApiResult.Failure -> block + } + } + + override suspend fun blockUser(username: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.blockUser(username) } } + + override suspend fun unblockUser(username: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.unblockUser(username) } } + + override suspend fun muteUser(username: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.muteUser(username) } } + + override suspend fun unmuteUser(username: String): ApiResult = + withContext(dispatchers.io) { safeApiCall(json) { api.unmuteUser(username) } } + + override suspend fun reportUser( + username: String, + reason: ReportReason, + detail: String?, + ): ApiResult = withContext(dispatchers.io) { + safeApiCall(json) { + api.reportUser( + username, + ReportUserRequest(reason = reason.apiValue, detail = detail?.trim()?.takeIf { it.isNotBlank() }), + ) + } + } + /** Caches [dto] as the current user, clearing the flag from any stale row first. */ private suspend fun cacheCurrentUser(dto: ProfileUserDto): ProfileUser { val domain = dto.toProfileUser(isCurrentUser = true) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt index 6dc8aa5..e328136 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt @@ -6,8 +6,11 @@ import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.LinkedIdentity import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.domain.ModeratedUser +import com.interlinedlist.android.feature.profile.domain.ModerationStatus import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.ReportReason import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary import com.interlinedlist.android.feature.profile.domain.PublicListDetail @@ -146,4 +149,41 @@ interface ProfileRepository { * the account's [username] and [email]. On success the caller signs the user out. */ suspend fun deleteAccount(username: String, email: String): ApiResult + + // --- Moderation (block / mute / report) --- + // Read-only lists; nothing is cached in Room (YAGNI). + + /** The current user's blocked users via `GET /api/user/blocks`. */ + suspend fun getBlockedUsers(): ApiResult> + + /** The current user's muted users via `GET /api/user/mutes`. */ + suspend fun getMutedUsers(): ApiResult> + + /** + * The current user's blocked/muted relationship to [username], combining + * `GET /api/users/{username}/block` and `.../mute`. + */ + suspend fun getModerationStatus(username: String): ApiResult + + /** Blocks [username] via `POST /api/users/{username}/block`. */ + suspend fun blockUser(username: String): ApiResult + + /** Unblocks [username] via `DELETE /api/users/{username}/block`. */ + suspend fun unblockUser(username: String): ApiResult + + /** Mutes [username] via `POST /api/users/{username}/mute`. */ + suspend fun muteUser(username: String): ApiResult + + /** Unmutes [username] via `DELETE /api/users/{username}/mute`. */ + suspend fun unmuteUser(username: String): ApiResult + + /** + * Reports [username] with a [reason] and optional free-text [detail] via + * `POST /api/users/{username}/report`. + */ + suspend fun reportUser( + username: String, + reason: ReportReason, + detail: String?, + ): ApiResult } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ModerationMappers.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ModerationMappers.kt new file mode 100644 index 0000000..182ac23 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/mapper/ModerationMappers.kt @@ -0,0 +1,20 @@ +package com.interlinedlist.android.feature.profile.data.mapper + +import com.interlinedlist.android.feature.profile.data.remote.dto.ModeratedUserDto +import com.interlinedlist.android.feature.profile.domain.ModeratedUser + +/** + * Maps a blocks/mutes list entry to a domain [ModeratedUser], dropping entries with no + * resolvable user (missing id or username), since a row can neither render nor be + * un-blocked / un-muted without a username. + */ +fun ModeratedUserDto.toModeratedUserOrNull(): ModeratedUser? { + val dto = userOrSelf ?: return null + if (dto.id.isBlank() || dto.username.isBlank()) return null + return ModeratedUser( + id = dto.id, + username = dto.username, + displayName = dto.displayName, + avatarUrl = dto.avatarOrNull, + ) +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt index 7c77b21..a3110e9 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/ProfileApi.kt @@ -2,8 +2,13 @@ package com.interlinedlist.android.feature.profile.data.remote import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarFromUrlRequest import com.interlinedlist.android.feature.profile.data.remote.dto.AvatarResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.BlockStatusResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.BlocksResponse import com.interlinedlist.android.feature.profile.data.remote.dto.ChangeEmailRequest import com.interlinedlist.android.feature.profile.data.remote.dto.DeleteAccountRequest +import com.interlinedlist.android.feature.profile.data.remote.dto.MuteStatusResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.MutesResponse +import com.interlinedlist.android.feature.profile.data.remote.dto.ReportUserRequest import com.interlinedlist.android.feature.profile.data.remote.dto.FollowCountsResponse import com.interlinedlist.android.feature.profile.data.remote.dto.FollowListResponse import com.interlinedlist.android.feature.profile.data.remote.dto.FollowRequestsResponse @@ -200,4 +205,51 @@ interface ProfileApi { /** Deletes the current user's account (requires the username + email to confirm). */ @POST("api/user/delete") suspend fun deleteAccount(@Body body: DeleteAccountRequest) + + // --- Moderation (block / mute / report) --- + + /** The users the current user has blocked (`{ "blockedUsers": [...], "pagination": {...} }`). */ + @GET("api/user/blocks") + suspend fun getBlocks( + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): BlocksResponse + + /** The users the current user has muted (`{ "mutedUsers": [...], "pagination": {...} }`). */ + @GET("api/user/mutes") + suspend fun getMutes( + @Query("limit") limit: Int? = null, + @Query("offset") offset: Int? = null, + ): MutesResponse + + /** Whether the current user is blocking [username] (`{ "blocked": true }`). */ + @GET("api/users/{username}/block") + suspend fun getBlockStatus(@Path("username") username: String): BlockStatusResponse + + /** Whether the current user is muting [username] (`{ "muted": true }`). */ + @GET("api/users/{username}/mute") + suspend fun getMuteStatus(@Path("username") username: String): MuteStatusResponse + + /** Blocks [username]. */ + @POST("api/users/{username}/block") + suspend fun blockUser(@Path("username") username: String) + + /** Unblocks [username]. */ + @DELETE("api/users/{username}/block") + suspend fun unblockUser(@Path("username") username: String) + + /** Mutes [username]. */ + @POST("api/users/{username}/mute") + suspend fun muteUser(@Path("username") username: String) + + /** Unmutes [username]. */ + @DELETE("api/users/{username}/mute") + suspend fun unmuteUser(@Path("username") username: String) + + /** Reports [username] with a reason and optional free-text detail. */ + @POST("api/users/{username}/report") + suspend fun reportUser( + @Path("username") username: String, + @Body body: ReportUserRequest, + ) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ModerationResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ModerationResponses.kt new file mode 100644 index 0000000..33f6b54 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ModerationResponses.kt @@ -0,0 +1,85 @@ +package com.interlinedlist.android.feature.profile.data.remote.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/user/blocks` → `{ "blockedUsers": [ ... ], "pagination": { ... } }` + * (list shape verified live 2026-07-31). The generic `data`/`users` envelopes are + * tolerated too in case the server ever switches keys. + */ +@Serializable +data class BlocksResponse( + val blockedUsers: List? = null, + val users: List? = null, + val data: List? = null, +) { + val usersOrEmpty: List get() = blockedUsers ?: users ?: data ?: emptyList() +} + +/** + * `GET /api/user/mutes` → `{ "mutedUsers": [ ... ], "pagination": { ... } }` + * (list shape verified live 2026-07-31). + */ +@Serializable +data class MutesResponse( + val mutedUsers: List? = null, + val users: List? = null, + val data: List? = null, +) { + val usersOrEmpty: List get() = mutedUsers ?: users ?: data ?: emptyList() +} + +/** + * A user entry inside a blocks / mutes list. The inner user object shape is not modelled + * in the OpenAPI spec (and both lists were empty for the probe account), so this tolerates + * either a nested `user` object or fields inlined at the top level, mirroring how + * [FollowRequestDto] handles the follow-requests list. + */ +@Serializable +data class ModeratedUserDto( + val user: ProfileUserDto? = null, + val id: String? = null, + val userId: String? = null, + val username: String? = null, + val displayName: String? = null, + val avatarUrl: String? = null, + val avatar: String? = null, +) { + /** The moderated user, whether nested under `user` or inlined at the top level. */ + val userOrSelf: ProfileUserDto? + get() = user ?: (id ?: userId)?.let { resolvedId -> + ProfileUserDto( + id = resolvedId, + username = username ?: "", + displayName = displayName, + avatarUrl = avatarUrl, + avatar = avatar, + ) + } +} + +/** + * `GET /api/users/{username}/block` → `{ "blocked": true }` (shape verified live 2026-07-31). + */ +@Serializable +data class BlockStatusResponse( + val blocked: Boolean = false, +) + +/** + * `GET /api/users/{username}/mute` → `{ "muted": true }` (shape verified live 2026-07-31). + */ +@Serializable +data class MuteStatusResponse( + val muted: Boolean = false, +) + +/** + * Body for `POST /api/users/{username}/report`. The OpenAPI spec models `reason` and an + * optional free-text `detail` (singular — confirmed from the spec, not `details`). + */ +@Serializable +data class ReportUserRequest( + val reason: String, + val detail: String? = null, +) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/Moderation.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/Moderation.kt new file mode 100644 index 0000000..1bbf7a9 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/Moderation.kt @@ -0,0 +1,38 @@ +package com.interlinedlist.android.feature.profile.domain + +/** + * A user entry in the current user's blocked or muted list (Milestone D). Carries just + * enough to render a row and to un-block / un-mute by [username] (the mutation endpoints + * key on the username, mirroring the block/mute POST/DELETE routes). + */ +data class ModeratedUser( + val id: String, + val username: String, + val displayName: String?, + val avatarUrl: String?, +) { + /** The best label to show for the user: display name if set, else the @username. */ + val displayLabel: String + get() = displayName?.takeIf { it.isNotBlank() } ?: "@$username" +} + +/** + * The current user's moderation relationship to another user, read alongside the + * other-user profile so its overflow menu can reflect the blocked / muted state. + */ +data class ModerationStatus( + val isBlocked: Boolean = false, + val isMuted: Boolean = false, +) + +/** + * The canned reasons offered when reporting a user. The wire value ([apiValue]) is sent + * as the report body's `reason`; the [label] is what the picker shows. + */ +enum class ReportReason(val apiValue: String, val label: String) { + SPAM("spam", "Spam"), + HARASSMENT("harassment", "Harassment or bullying"), + IMPERSONATION("impersonation", "Impersonation"), + INAPPROPRIATE("inappropriate", "Inappropriate content"), + OTHER("other", "Something else"), +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedScreen.kt new file mode 100644 index 0000000..89e8122 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedScreen.kt @@ -0,0 +1,259 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.profile.domain.ModeratedUser + +/** Stable test tags for the "Blocked & muted" management screen. */ +object BlockedMutedTestTags { + const val LIST = "blockedMutedList" + const val EMPTY = "blockedMutedEmpty" + const val PROGRESS = "blockedMutedProgress" + const val ERROR = "blockedMutedError" + const val BACK = "blockedMutedBack" + const val BLOCKED_HEADER = "blockedMutedBlockedHeader" + const val MUTED_HEADER = "blockedMutedMutedHeader" + fun blockedRow(username: String) = "blockedRow_$username" + fun mutedRow(username: String) = "mutedRow_$username" + fun unblock(username: String) = "unblock_$username" + fun unmute(username: String) = "unmute_$username" +} + +/** + * The "Blocked & muted" management screen (route `account/blocked-muted`), reached from the + * Account hub. Lists the current user's blocked and muted users; each row can be un-blocked + * or un-muted, removed optimistically and rolled back on failure. + * + * @param onBack pop back to the account hub. + */ +@Composable +fun BlockedMutedRoute( + onBack: () -> Unit, + modifier: Modifier = Modifier, + viewModel: BlockedMutedViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + BlockedMutedScreen( + state = state, + onUnblock = viewModel::unblock, + onUnmute = viewModel::unmute, + onBack = onBack, + onRetry = viewModel::refresh, + modifier = modifier, + ) +} + +/** Stateless "Blocked & muted" UI. */ +@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) +@Composable +fun BlockedMutedScreen( + state: BlockedMutedUiState, + onUnblock: (String) -> Unit, + onUnmute: (String) -> Unit, + onBack: () -> Unit, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Blocked & muted") }, + navigationIcon = { + IconButton(onClick = onBack, modifier = Modifier.testTag(BlockedMutedTestTags.BACK)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.isLoading -> CircularProgressIndicator( + Modifier.align(Alignment.Center).testTag(BlockedMutedTestTags.PROGRESS), + ) + + state.errorMessage != null && state.blocked.isEmpty() && state.muted.isEmpty() -> ErrorState( + message = state.errorMessage, + onRetry = onRetry, + tag = BlockedMutedTestTags.ERROR, + ) + + state.isEmpty -> Text( + text = "You haven't blocked or muted anyone.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp) + .testTag(BlockedMutedTestTags.EMPTY), + ) + + else -> LazyColumn( + modifier = Modifier.fillMaxSize().testTag(BlockedMutedTestTags.LIST), + ) { + if (state.blocked.isNotEmpty()) { + item(key = "blocked-header") { + SectionHeader("Blocked", BlockedMutedTestTags.BLOCKED_HEADER) + } + items(state.blocked, key = { "blocked-${it.username}" }) { user -> + ModeratedRow( + user = user, + actionLabel = "Unblock", + rowTag = BlockedMutedTestTags.blockedRow(user.username), + actionTag = BlockedMutedTestTags.unblock(user.username), + inProgress = user.username in state.pendingIds, + onAction = { onUnblock(user.username) }, + ) + HorizontalDivider() + } + } + if (state.muted.isNotEmpty()) { + item(key = "muted-header") { + SectionHeader("Muted", BlockedMutedTestTags.MUTED_HEADER) + } + items(state.muted, key = { "muted-${it.username}" }) { user -> + ModeratedRow( + user = user, + actionLabel = "Unmute", + rowTag = BlockedMutedTestTags.mutedRow(user.username), + actionTag = BlockedMutedTestTags.unmute(user.username), + inProgress = user.username in state.pendingIds, + onAction = { onUnmute(user.username) }, + ) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun SectionHeader(label: String, tag: String) { + Text( + text = label, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .testTag(tag), + ) +} + +@Composable +private fun ModeratedRow( + user: ModeratedUser, + actionLabel: String, + rowTag: String, + actionTag: String, + inProgress: Boolean, + onAction: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .testTag(rowTag) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + text = user.displayLabel, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "@${user.username}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (inProgress) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + OutlinedButton(onClick = onAction, modifier = Modifier.testTag(actionTag)) { + Text(actionLabel) + } + } + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit, tag: String) { + Column( + Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.testTag(tag), + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { Text("Retry") } + } +} + +@Preview(showBackground = true) +@Composable +private fun BlockedMutedScreenPreview() { + InterlinedListTheme { + BlockedMutedScreen( + state = BlockedMutedUiState( + blocked = listOf( + ModeratedUser("b1", "spammer", "Spam Bot", null), + ), + muted = listOf( + ModeratedUser("m1", "noisy", "Very Loud", null), + ), + isLoading = false, + ), + onUnblock = {}, + onUnmute = {}, + onBack = {}, + onRetry = {}, + ) + } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedViewModel.kt new file mode 100644 index 0000000..7f25795 --- /dev/null +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/BlockedMutedViewModel.kt @@ -0,0 +1,134 @@ +package com.interlinedlist.android.feature.profile.ui.account + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.profile.data.ProfileRepository +import com.interlinedlist.android.feature.profile.domain.ModeratedUser +import com.interlinedlist.android.feature.profile.ui.common.toUserMessage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** UI state for the "Blocked & muted" management screen. */ +data class BlockedMutedUiState( + val blocked: List = emptyList(), + val muted: List = emptyList(), + val isLoading: Boolean = true, + // Usernames with an un-block / un-mute in flight, so their row can show progress + // and repeat taps are deduped. + val pendingIds: Set = emptySet(), + val errorMessage: String? = null, +) { + /** A load finished with nothing on either list and no error. */ + val isEmpty: Boolean + get() = blocked.isEmpty() && muted.isEmpty() && !isLoading && errorMessage == null +} + +/** + * Drives the "Blocked & muted" management screen (route `account/blocked-muted`). Loads + * both lists via `GET /api/user/blocks` and `GET /api/user/mutes`, and un-blocks / + * un-mutes by username, removing the row optimistically and rolling it back on failure — + * mirroring the Active Sessions screen's revoke flow. + */ +@HiltViewModel +class BlockedMutedViewModel @Inject constructor( + private val repository: ProfileRepository, +) : ViewModel() { + + private val _uiState = MutableStateFlow(BlockedMutedUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + refresh() + } + + fun refresh() { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val blocked = repository.getBlockedUsers() + val muted = repository.getMutedUsers() + // Surface the first failure; either list failing means an incomplete screen. + val error = (blocked as? ApiResult.Failure)?.error + ?: (muted as? ApiResult.Failure)?.error + if (error != null) { + _uiState.update { it.copy(isLoading = false, errorMessage = error.toUserMessage()) } + return@launch + } + _uiState.update { + it.copy( + blocked = (blocked as ApiResult.Success).data, + muted = (muted as ApiResult.Success).data, + isLoading = false, + ) + } + } + } + + /** Un-blocks [username], removing the row optimistically and rolling back on failure. */ + fun unblock(username: String) { + val state = _uiState.value + val target = state.blocked.firstOrNull { it.username == username } ?: return + if (username in state.pendingIds) return + + _uiState.update { + it.copy( + blocked = it.blocked.filterNot { u -> u.username == username }, + pendingIds = it.pendingIds + username, + errorMessage = null, + ) + } + viewModelScope.launch { + when (val result = repository.unblockUser(username)) { + is ApiResult.Success -> _uiState.update { it.copy(pendingIds = it.pendingIds - username) } + is ApiResult.Failure -> rollback(target, isBlocked = true, error = result.error.toUserMessage()) + } + } + } + + /** Un-mutes [username], removing the row optimistically and rolling back on failure. */ + fun unmute(username: String) { + val state = _uiState.value + val target = state.muted.firstOrNull { it.username == username } ?: return + if (username in state.pendingIds) return + + _uiState.update { + it.copy( + muted = it.muted.filterNot { u -> u.username == username }, + pendingIds = it.pendingIds + username, + errorMessage = null, + ) + } + viewModelScope.launch { + when (val result = repository.unmuteUser(username)) { + is ApiResult.Success -> _uiState.update { it.copy(pendingIds = it.pendingIds - username) } + is ApiResult.Failure -> rollback(target, isBlocked = false, error = result.error.toUserMessage()) + } + } + } + + /** Restores an optimistically-removed row to the end of its list and surfaces the error. */ + private fun rollback(user: ModeratedUser, isBlocked: Boolean, error: String) { + _uiState.update { + if (isBlocked) { + it.copy( + blocked = it.blocked + user, + pendingIds = it.pendingIds - user.username, + errorMessage = error, + ) + } else { + it.copy( + muted = it.muted + user, + pendingIds = it.pendingIds - user.username, + errorMessage = error, + ) + } + } + } + + fun clearError() = _uiState.update { it.copy(errorMessage = null) } +} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt index 88eabd7..4b16768 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.Block import androidx.compose.material.icons.filled.Business import androidx.compose.material.icons.filled.Devices import androidx.compose.material.icons.filled.Edit @@ -59,6 +60,7 @@ object AccountMenuTestTags { const val SEARCH_USERS = "accountMenuSearchUsers" const val SESSIONS = "accountMenuSessions" const val CONNECTED_ACCOUNTS = "accountMenuConnectedAccounts" + const val BLOCKED_MUTED = "accountMenuBlockedMuted" const val ACCOUNT_SETTINGS = "accountMenuAccountSettings" } @@ -81,6 +83,7 @@ object AccountMenuTestTags { * @param onOpenIntegrations navigate to the integrations module. * @param onOpenSessions navigate to the Active Sessions screen (within this module). * @param onOpenConnectedAccounts navigate to the Connected Accounts screen (within this module). + * @param onOpenBlockedMuted navigate to the "Blocked & muted" screen (within this module). * @param onOpenAccountSettings navigate to the Account settings screen (within this module). * @param onSignOut invoked after the caller performs sign-out; the profile module does * not own session state, so the app wires this to the auth logout + navigation. @@ -99,6 +102,9 @@ fun ProfileRoute( onOpenConnectedAccounts: () -> Unit, onOpenAccountSettings: () -> Unit, onSignOut: () -> Unit, + // Defaulted so existing app nav wiring compiles unchanged; wire this to the + // `account/blocked-muted` route to enable the Blocked & muted screen (Milestone D). + onOpenBlockedMuted: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), ) { @@ -117,6 +123,7 @@ fun ProfileRoute( onOpenIntegrations = onOpenIntegrations, onOpenSessions = onOpenSessions, onOpenConnectedAccounts = onOpenConnectedAccounts, + onOpenBlockedMuted = onOpenBlockedMuted, onOpenAccountSettings = onOpenAccountSettings, onSignOut = onSignOut, onRetry = viewModel::refresh, @@ -142,6 +149,7 @@ fun ProfileScreen( onOpenAccountSettings: () -> Unit, onSignOut: () -> Unit, onRetry: () -> Unit, + onOpenBlockedMuted: () -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -226,6 +234,12 @@ fun ProfileScreen( onClick = onOpenConnectedAccounts, tag = AccountMenuTestTags.CONNECTED_ACCOUNTS, ) + AccountMenuRow( + icon = Icons.Default.Block, + label = "Blocked & muted", + onClick = onOpenBlockedMuted, + tag = AccountMenuTestTags.BLOCKED_MUTED, + ) AccountMenuRow( icon = Icons.Default.ManageAccounts, label = "Account settings", diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt index 151178f..43623a0 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileViewModel.kt @@ -6,6 +6,7 @@ import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.data.ProfileRepository import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.ModerationStatus import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.ui.common.toUserMessage @@ -36,12 +37,22 @@ data class ProfileUiState( val selectedTab: ProfileContentTab = ProfileContentTab.POSTS, val content: PublicContentState = PublicContentState(), val mutualConnections: MutualConnections? = null, + // Moderation — populated only on another user's profile (Milestone D). Drives the + // overflow menu's blocked/muted state; [isModerationActionInProgress] disables it + // while a block/mute/report is in flight. + val moderationStatus: ModerationStatus = ModerationStatus(), + val isModerationActionInProgress: Boolean = false, + // A one-shot flag set after a successful report so the UI can confirm and dismiss. + val reportSubmitted: Boolean = false, ) { /** No cached user and not loading — nothing to render yet. */ val isEmpty: Boolean get() = user == null && !isLoading /** Whether a follow/unfollow affordance should be shown (another user, status known). */ val canFollow: Boolean get() = followStatus != FollowStatus.SELF + + /** Whether the moderation overflow menu should be offered (another user, not yourself). */ + val canModerate: Boolean get() = user != null && !user.isCurrentUser } /** diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt index be0e1fc..27e211b 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileScreen.kt @@ -10,26 +10,42 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material.icons.automirrored.filled.VolumeOff +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Flag import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag @@ -42,11 +58,13 @@ import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.core.model.CustomerStatus import com.interlinedlist.android.feature.profile.domain.FollowCounts import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.ModerationStatus import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary import com.interlinedlist.android.feature.profile.domain.PublicListSummary import com.interlinedlist.android.feature.profile.domain.PublicPost +import com.interlinedlist.android.feature.profile.domain.ReportReason import com.interlinedlist.android.feature.profile.ui.account.relativeTime /** Stable test tags for the other-user profile's content tabs. */ @@ -63,6 +81,24 @@ object ProfileContentTestTags { fun documentRow(id: String) = "profileDocumentRow_$id" } +/** Stable test tags for the other-user profile's moderation overflow menu (Milestone D). */ +object ProfileModerationTestTags { + const val OVERFLOW = "profileModerationOverflow" + const val MENU = "profileModerationMenu" + const val MENU_BLOCK = "profileModerationMenuBlock" + const val MENU_MUTE = "profileModerationMenuMute" + const val MENU_REPORT = "profileModerationMenuReport" + const val BLOCK_DIALOG = "profileModerationBlockDialog" + const val BLOCK_CONFIRM = "profileModerationBlockConfirm" + const val MUTE_DIALOG = "profileModerationMuteDialog" + const val MUTE_CONFIRM = "profileModerationMuteConfirm" + const val REPORT_DIALOG = "profileModerationReportDialog" + const val REPORT_CONFIRM = "profileModerationReportConfirm" + const val REPORT_DETAIL = "profileModerationReportDetail" + const val REPORT_SUBMITTED = "profileModerationReportSubmitted" + fun reportReason(reason: ReportReason) = "profileModerationReason_${reason.name}" +} + /** * Another user's public profile, reached by drilling down from search (route * `profile/{username}`). Shows the profile header, a mutual-connections indicator, @@ -98,6 +134,10 @@ fun UserProfileRoute( onOpenFollowing = { state.user?.username?.let(onOpenFollowing) }, onOpenList = { listId -> state.user?.username?.let { onOpenList(it, listId) } }, onOpenDocument = onOpenDocument, + onToggleBlock = viewModel::toggleBlock, + onToggleMute = viewModel::toggleMute, + onReport = viewModel::report, + onAcknowledgeReport = viewModel::acknowledgeReport, modifier = modifier, ) } @@ -115,8 +155,15 @@ fun UserProfileScreen( onOpenFollowing: () -> Unit = {}, onOpenList: (String) -> Unit = {}, onOpenDocument: (String) -> Unit = {}, + onToggleBlock: () -> Unit = {}, + onToggleMute: () -> Unit = {}, + onReport: (ReportReason, String?) -> Unit = { _, _ -> }, + onAcknowledgeReport: () -> Unit = {}, modifier: Modifier = Modifier, ) { + // Which moderation dialog (if any) is currently up. + var moderationDialog by remember { mutableStateOf(ModerationDialog.NONE) } + Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -127,6 +174,17 @@ fun UserProfileScreen( Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, + actions = { + if (state.canModerate) { + ModerationOverflowMenu( + status = state.moderationStatus, + enabled = !state.isModerationActionInProgress, + onBlock = { moderationDialog = ModerationDialog.BLOCK }, + onMute = { moderationDialog = ModerationDialog.MUTE }, + onReport = { moderationDialog = ModerationDialog.REPORT }, + ) + } + }, ) }, ) { padding -> @@ -188,9 +246,227 @@ fun UserProfileScreen( } } } + + val label = state.user?.displayLabel ?: "this user" + when (moderationDialog) { + ModerationDialog.BLOCK -> ModerationConfirmDialog( + title = if (state.moderationStatus.isBlocked) "Unblock $label?" else "Block $label?", + body = if (state.moderationStatus.isBlocked) { + "They'll be able to see your profile and interact with you again." + } else { + "They won't be able to see your profile or interact with you." + }, + confirmLabel = if (state.moderationStatus.isBlocked) "Unblock" else "Block", + dialogTag = ProfileModerationTestTags.BLOCK_DIALOG, + confirmTag = ProfileModerationTestTags.BLOCK_CONFIRM, + onConfirm = { + onToggleBlock() + moderationDialog = ModerationDialog.NONE + }, + onDismiss = { moderationDialog = ModerationDialog.NONE }, + ) + + ModerationDialog.MUTE -> ModerationConfirmDialog( + title = if (state.moderationStatus.isMuted) "Unmute $label?" else "Mute $label?", + body = if (state.moderationStatus.isMuted) { + "You'll start seeing their activity again." + } else { + "You won't see their activity, but they won't be notified." + }, + confirmLabel = if (state.moderationStatus.isMuted) "Unmute" else "Mute", + dialogTag = ProfileModerationTestTags.MUTE_DIALOG, + confirmTag = ProfileModerationTestTags.MUTE_CONFIRM, + onConfirm = { + onToggleMute() + moderationDialog = ModerationDialog.NONE + }, + onDismiss = { moderationDialog = ModerationDialog.NONE }, + ) + + ModerationDialog.REPORT -> ReportDialog( + targetLabel = label, + onSubmit = { reason, detail -> + onReport(reason, detail) + moderationDialog = ModerationDialog.NONE + }, + onDismiss = { moderationDialog = ModerationDialog.NONE }, + ) + + ModerationDialog.NONE -> Unit + } + + if (state.reportSubmitted) { + ReportSubmittedDialog(onDismiss = onAcknowledgeReport) + } } } +/** Which moderation dialog is showing over the profile. */ +private enum class ModerationDialog { NONE, BLOCK, MUTE, REPORT } + +/** The three-dot overflow menu offering Block / Mute / Report on another user's profile. */ +@Composable +private fun ModerationOverflowMenu( + status: ModerationStatus, + enabled: Boolean, + onBlock: () -> Unit, + onMute: () -> Unit, + onReport: () -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box(Modifier.wrapContentSize(Alignment.TopEnd)) { + IconButton( + onClick = { expanded = true }, + enabled = enabled, + modifier = Modifier.testTag(ProfileModerationTestTags.OVERFLOW), + ) { + Icon(Icons.Default.MoreVert, contentDescription = "More options") + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.testTag(ProfileModerationTestTags.MENU), + ) { + DropdownMenuItem( + text = { Text(if (status.isBlocked) "Unblock" else "Block") }, + leadingIcon = { Icon(Icons.Default.Block, contentDescription = null) }, + onClick = { + expanded = false + onBlock() + }, + modifier = Modifier.testTag(ProfileModerationTestTags.MENU_BLOCK), + ) + DropdownMenuItem( + text = { Text(if (status.isMuted) "Unmute" else "Mute") }, + leadingIcon = { Icon(Icons.AutoMirrored.Filled.VolumeOff, contentDescription = null) }, + onClick = { + expanded = false + onMute() + }, + modifier = Modifier.testTag(ProfileModerationTestTags.MENU_MUTE), + ) + DropdownMenuItem( + text = { Text("Report") }, + leadingIcon = { Icon(Icons.Default.Flag, contentDescription = null) }, + onClick = { + expanded = false + onReport() + }, + modifier = Modifier.testTag(ProfileModerationTestTags.MENU_REPORT), + ) + } + } +} + +/** A generic confirm dialog for the destructive block/mute toggles. */ +@Composable +private fun ModerationConfirmDialog( + title: String, + body: String, + confirmLabel: String, + dialogTag: String, + confirmTag: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(dialogTag), + title = { Text(title) }, + text = { Text(body) }, + confirmButton = { + TextButton(onClick = onConfirm, modifier = Modifier.testTag(confirmTag)) { + Text(confirmLabel, color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** The report dialog: pick a reason (required) and add optional detail. */ +@Composable +private fun ReportDialog( + targetLabel: String, + onSubmit: (ReportReason, String?) -> Unit, + onDismiss: () -> Unit, +) { + var selectedReason by remember { mutableStateOf(null) } + var detail by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(ProfileModerationTestTags.REPORT_DIALOG), + title = { Text("Report $targetLabel") }, + text = { + Column { + Text( + text = "Why are you reporting this user?", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + ReportReason.entries.forEach { reason -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = selectedReason == reason, + onClick = { selectedReason = reason }, + ) + .testTag(ProfileModerationTestTags.reportReason(reason)) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selectedReason == reason, + onClick = { selectedReason = reason }, + ) + Spacer(Modifier.width(8.dp)) + Text(reason.label, style = MaterialTheme.typography.bodyLarge) + } + } + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = detail, + onValueChange = { detail = it }, + label = { Text("Add detail (optional)") }, + modifier = Modifier + .fillMaxWidth() + .testTag(ProfileModerationTestTags.REPORT_DETAIL), + ) + } + }, + confirmButton = { + TextButton( + onClick = { selectedReason?.let { onSubmit(it, detail.ifBlank { null }) } }, + enabled = selectedReason != null, + modifier = Modifier.testTag(ProfileModerationTestTags.REPORT_CONFIRM), + ) { + Text("Submit report") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** A brief confirmation shown after a report is successfully submitted. */ +@Composable +private fun ReportSubmittedDialog(onDismiss: () -> Unit) { + AlertDialog( + onDismissRequest = onDismiss, + modifier = Modifier.testTag(ProfileModerationTestTags.REPORT_SUBMITTED), + title = { Text("Report submitted") }, + text = { Text("Thanks — our team will review this report.") }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Done") } + }, + ) +} + /** Renders the selected tab's rows as LazyColumn items (loading / error / empty / content). */ private fun androidx.compose.foundation.lazy.LazyListScope.contentTabItems( state: ProfileUiState, diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt index 718d1fd..ab967bc 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/UserProfileViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.feature.profile.data.ProfileRepository import com.interlinedlist.android.feature.profile.domain.FollowStatus +import com.interlinedlist.android.feature.profile.domain.ReportReason import com.interlinedlist.android.feature.profile.ui.common.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -60,6 +61,7 @@ class UserProfileViewModel @Inject constructor( _uiState.update { it.copy(user = result.data, isLoading = false) } loadFollow(result.data.id, result.data.isCurrentUser) loadMutual(result.data.id, result.data.isCurrentUser) + loadModerationStatus(result.data.isCurrentUser) // Load the initially-selected tab now that the username is confirmed. loadTab(_uiState.value.selectedTab) } @@ -159,6 +161,100 @@ class UserProfileViewModel @Inject constructor( } } + /** Loads the blocked/muted status for the viewed user (not fetched for your own profile). */ + private fun loadModerationStatus(isCurrentUser: Boolean) { + if (isCurrentUser) return + viewModelScope.launch { + val result = repository.getModerationStatus(username) + if (result is ApiResult.Success) { + _uiState.update { it.copy(moderationStatus = result.data) } + } + } + } + + /** + * Blocks or unblocks the viewed user, optimistically flipping the blocked flag and + * rolling it back on failure. Deduped while any moderation action is in flight. + */ + fun toggleBlock() { + val state = _uiState.value + if (!state.canModerate || state.isModerationActionInProgress) return + val wasBlocked = state.moderationStatus.isBlocked + + _uiState.update { + it.copy( + moderationStatus = it.moderationStatus.copy(isBlocked = !wasBlocked), + isModerationActionInProgress = true, + errorMessage = null, + ) + } + viewModelScope.launch { + val result = if (wasBlocked) repository.unblockUser(username) else repository.blockUser(username) + applyModerationResult(result) { it.copy(isBlocked = wasBlocked) } + } + } + + /** + * Mutes or unmutes the viewed user, optimistically flipping the muted flag and rolling + * it back on failure. Deduped while any moderation action is in flight. + */ + fun toggleMute() { + val state = _uiState.value + if (!state.canModerate || state.isModerationActionInProgress) return + val wasMuted = state.moderationStatus.isMuted + + _uiState.update { + it.copy( + moderationStatus = it.moderationStatus.copy(isMuted = !wasMuted), + isModerationActionInProgress = true, + errorMessage = null, + ) + } + viewModelScope.launch { + val result = if (wasMuted) repository.unmuteUser(username) else repository.muteUser(username) + applyModerationResult(result) { it.copy(isMuted = wasMuted) } + } + } + + /** Reports the viewed user with a [reason] and optional free-text [detail]. */ + fun report(reason: ReportReason, detail: String?) { + val state = _uiState.value + if (!state.canModerate || state.isModerationActionInProgress) return + _uiState.update { it.copy(isModerationActionInProgress = true, errorMessage = null) } + viewModelScope.launch { + when (val result = repository.reportUser(username, reason, detail)) { + is ApiResult.Success -> _uiState.update { + it.copy(isModerationActionInProgress = false, reportSubmitted = true) + } + is ApiResult.Failure -> _uiState.update { + it.copy(isModerationActionInProgress = false, errorMessage = result.error.toUserMessage()) + } + } + } + } + + fun acknowledgeReport() = _uiState.update { it.copy(reportSubmitted = false) } + + /** + * Clears the in-flight flag on a block/mute result; on failure it rolls the optimistic + * status change back via [rollback] and surfaces the mapped error. + */ + private fun applyModerationResult( + result: ApiResult, + rollback: (com.interlinedlist.android.feature.profile.domain.ModerationStatus) -> com.interlinedlist.android.feature.profile.domain.ModerationStatus, + ) { + when (result) { + is ApiResult.Success -> _uiState.update { it.copy(isModerationActionInProgress = false) } + is ApiResult.Failure -> _uiState.update { + it.copy( + moderationStatus = rollback(it.moderationStatus), + isModerationActionInProgress = false, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + /** Toggles the follow relationship, optimistically flipping the button state. */ fun toggleFollow() { val state = _uiState.value diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryModerationTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryModerationTest.kt new file mode 100644 index 0000000..999cc88 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryModerationTest.kt @@ -0,0 +1,237 @@ +package com.interlinedlist.android.feature.profile.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.data.remote.ProfileApi +import com.interlinedlist.android.feature.profile.domain.ReportReason +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** MockWebServer coverage for the Milestone D moderation endpoints. */ +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultProfileRepositoryModerationTest { + + private lateinit var server: MockWebServer + private lateinit var api: ProfileApi + private lateinit var dao: FakeProfileDao + private lateinit var repository: DefaultProfileRepository + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } + private val testDispatcher = StandardTestDispatcher() + private val dispatchers = object : DispatcherProvider { + override val io: CoroutineDispatcher = testDispatcher + override val default: CoroutineDispatcher = testDispatcher + override val main: CoroutineDispatcher = testDispatcher + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(ProfileApi::class.java) + dao = FakeProfileDao() + repository = DefaultProfileRepository(api, dao, json, dispatchers) + } + + @After + fun tearDown() = server.shutdown() + + @Test + fun `getBlockedUsers parses the blockedUsers envelope`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "blockedUsers": [ + { "id": "u2", "username": "ada", "displayName": "Ada Lovelace", "avatar": "https://cdn/ada.png" }, + { "id": "u3", "username": "grace" } + ], + "pagination": { "total": 2, "limit": 20, "offset": 0, "hasMore": false } + } + """.trimIndent(), + ), + ) + + val result = repository.getBlockedUsers() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val users = (result as ApiResult.Success).data + assertThat(users.map { it.username }).containsExactly("ada", "grace").inOrder() + assertThat(users.first().avatarUrl).isEqualTo("https://cdn/ada.png") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("GET") + assertThat(recorded.path).startsWith("/api/user/blocks") + } + + @Test + fun `getBlockedUsers tolerates a nested user object shape`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """ + { + "blockedUsers": [ + { "user": { "id": "u2", "username": "ada", "displayName": "Ada" } } + ] + } + """.trimIndent(), + ), + ) + + val result = repository.getBlockedUsers() + + val users = (result as ApiResult.Success).data + assertThat(users).hasSize(1) + assertThat(users.first().username).isEqualTo("ada") + } + + @Test + fun `getBlockedUsers drops entries with no resolvable user`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "blockedUsers": [ { "displayName": "orphan" }, { "id": "u9", "username": "ok" } ] }""", + ), + ) + + val users = (repository.getBlockedUsers() as ApiResult.Success).data + assertThat(users.map { it.username }).containsExactly("ok") + } + + @Test + fun `getMutedUsers parses the mutedUsers envelope`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "mutedUsers": [ { "id": "u5", "username": "noisy" } ], "pagination": { "total": 1 } }""", + ), + ) + + val users = (repository.getMutedUsers() as ApiResult.Success).data + assertThat(users.map { it.username }).containsExactly("noisy") + assertThat(server.takeRequest().path).startsWith("/api/user/mutes") + } + + @Test + fun `getModerationStatus combines the block and mute status endpoints`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "blocked": true }""")) + server.enqueue(MockResponse().setResponseCode(200).setBody("""{ "muted": false }""")) + + val result = repository.getModerationStatus("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val status = (result as ApiResult.Success).data + assertThat(status.isBlocked).isTrue() + assertThat(status.isMuted).isFalse() + + assertThat(server.takeRequest().path).isEqualTo("/api/users/ada/block") + assertThat(server.takeRequest().path).isEqualTo("/api/users/ada/mute") + } + + @Test + fun `getModerationStatus fails fast when the block status call fails`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{ "error": "no user" }""")) + + val result = repository.getModerationStatus("ghost") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error).isInstanceOf(AppError.NotFound::class.java) + } + + @Test + fun `blockUser posts to the block endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.blockUser("ada") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/users/ada/block") + } + + @Test + fun `unblockUser deletes the block`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200)) + + repository.unblockUser("ada") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/users/ada/block") + } + + @Test + fun `muteUser posts to the mute endpoint`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + repository.muteUser("ada") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/users/ada/mute") + } + + @Test + fun `unmuteUser deletes the mute`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(200)) + + repository.unmuteUser("ada") + + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("DELETE") + assertThat(recorded.path).isEqualTo("/api/users/ada/mute") + } + + @Test + fun `reportUser posts the reason and detail body`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + val result = repository.reportUser("ada", ReportReason.HARASSMENT, "They keep messaging me.") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val recorded = server.takeRequest() + assertThat(recorded.method).isEqualTo("POST") + assertThat(recorded.path).isEqualTo("/api/users/ada/report") + val body = recorded.body.readUtf8() + assertThat(body).contains("\"reason\":\"harassment\"") + assertThat(body).contains("\"detail\":\"They keep messaging me.\"") + } + + @Test + fun `reportUser omits a blank detail`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(201)) + + repository.reportUser("ada", ReportReason.SPAM, " ") + + val body = server.takeRequest().body.readUtf8() + assertThat(body).contains("\"reason\":\"spam\"") + assertThat(body).doesNotContain("detail") + } + + @Test + fun `reportUser maps a 400 to a failure`() = runTest(testDispatcher) { + server.enqueue(MockResponse().setResponseCode(400).setBody("""{ "error": "bad reason" }""")) + + val result = repository.reportUser("ada", ReportReason.OTHER, null) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/BlockedMutedViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/BlockedMutedViewModelTest.kt new file mode 100644 index 0000000..336a9ee --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/BlockedMutedViewModelTest.kt @@ -0,0 +1,116 @@ +package com.interlinedlist.android.feature.profile.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.ui.account.BlockedMutedViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BlockedMutedViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun `loads both blocked and muted lists`() = runTest(dispatcher) { + repo.blockedUsersResult = ApiResult.Success( + listOf(testModeratedUser(id = "b1", username = "ada"), testModeratedUser(id = "b2", username = "grace")), + ) + repo.mutedUsersResult = ApiResult.Success(listOf(testModeratedUser(id = "m1", username = "noisy"))) + + val vm = BlockedMutedViewModel(repo) + advanceUntilIdle() + + assertThat(repo.blockedUsersCount).isEqualTo(1) + assertThat(repo.mutedUsersCount).isEqualTo(1) + assertThat(vm.uiState.value.blocked.map { it.username }).containsExactly("ada", "grace").inOrder() + assertThat(vm.uiState.value.muted.map { it.username }).containsExactly("noisy") + assertThat(vm.uiState.value.isLoading).isFalse() + } + + @Test + fun `empty lists flag isEmpty`() = runTest(dispatcher) { + repo.blockedUsersResult = ApiResult.Success(emptyList()) + repo.mutedUsersResult = ApiResult.Success(emptyList()) + + val vm = BlockedMutedViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.isEmpty).isTrue() + } + + @Test + fun `unblock removes the row optimistically and calls the repository`() = runTest(dispatcher) { + repo.blockedUsersResult = ApiResult.Success( + listOf(testModeratedUser(username = "ada"), testModeratedUser(id = "b2", username = "grace")), + ) + val vm = BlockedMutedViewModel(repo) + advanceUntilIdle() + + vm.unblock("ada") + advanceUntilIdle() + + assertThat(repo.unblockedUsername).isEqualTo("ada") + assertThat(vm.uiState.value.blocked.map { it.username }).containsExactly("grace") + assertThat(vm.uiState.value.pendingIds).isEmpty() + } + + @Test + fun `unblock rolls the row back and surfaces an error on failure`() = runTest(dispatcher) { + repo.blockedUsersResult = ApiResult.Success(listOf(testModeratedUser(username = "ada"))) + repo.unblockResult = ApiResult.Failure(AppError.Server("boom")) + val vm = BlockedMutedViewModel(repo) + advanceUntilIdle() + + vm.unblock("ada") + advanceUntilIdle() + + assertThat(vm.uiState.value.blocked.map { it.username }).containsExactly("ada") + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.pendingIds).isEmpty() + } + + @Test + fun `unmute removes the row optimistically`() = runTest(dispatcher) { + repo.mutedUsersResult = ApiResult.Success( + listOf(testModeratedUser(id = "m1", username = "noisy"), testModeratedUser(id = "m2", username = "loud")), + ) + val vm = BlockedMutedViewModel(repo) + advanceUntilIdle() + + vm.unmute("noisy") + advanceUntilIdle() + + assertThat(repo.unmutedUsername).isEqualTo("noisy") + assertThat(vm.uiState.value.muted.map { it.username }).containsExactly("loud") + } + + @Test + fun `a load failure surfaces a mapped error`() = runTest(dispatcher) { + repo.blockedUsersResult = ApiResult.Failure(AppError.Network("offline")) + + val vm = BlockedMutedViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.errorMessage).isEqualTo("No connection. Check your network and try again.") + } +} diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt index 80e05c7..ca04e5d 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt @@ -9,8 +9,11 @@ import com.interlinedlist.android.feature.profile.domain.FollowStatus import com.interlinedlist.android.feature.profile.domain.FollowUser import com.interlinedlist.android.feature.profile.domain.LinkedIdentity import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.domain.ModeratedUser +import com.interlinedlist.android.feature.profile.domain.ModerationStatus import com.interlinedlist.android.feature.profile.domain.MutualConnections import com.interlinedlist.android.feature.profile.domain.ProfileUser +import com.interlinedlist.android.feature.profile.domain.ReportReason import com.interlinedlist.android.feature.profile.domain.PublicDocumentDetail import com.interlinedlist.android.feature.profile.domain.PublicDocumentSummary import com.interlinedlist.android.feature.profile.domain.PublicListDetail @@ -254,6 +257,80 @@ class FakeProfileRepository : ProfileRepository { deleteAccountArgs = username to email return deleteAccountResult } + + // --- Moderation (block / mute / report) --- + + var blockedUsersResult: ApiResult> = ApiResult.Success(emptyList()) + var mutedUsersResult: ApiResult> = ApiResult.Success(emptyList()) + var moderationStatusResult: ApiResult = ApiResult.Success(ModerationStatus()) + var blockResult: ApiResult = ApiResult.Success(Unit) + var unblockResult: ApiResult = ApiResult.Success(Unit) + var muteResult: ApiResult = ApiResult.Success(Unit) + var unmuteResult: ApiResult = ApiResult.Success(Unit) + var reportResult: ApiResult = ApiResult.Success(Unit) + + var blockedUsersCount = 0 + var mutedUsersCount = 0 + var moderationStatusUsername: String? = null + var blockedUsername: String? = null + var unblockedUsername: String? = null + var mutedUsername: String? = null + var unmutedUsername: String? = null + var blockCount = 0 + var unblockCount = 0 + var muteCount = 0 + var unmuteCount = 0 + var reportArgs: Triple? = null + var reportCount = 0 + + override suspend fun getBlockedUsers(): ApiResult> { + blockedUsersCount++ + return blockedUsersResult + } + + override suspend fun getMutedUsers(): ApiResult> { + mutedUsersCount++ + return mutedUsersResult + } + + override suspend fun getModerationStatus(username: String): ApiResult { + moderationStatusUsername = username + return moderationStatusResult + } + + override suspend fun blockUser(username: String): ApiResult { + blockedUsername = username + blockCount++ + return blockResult + } + + override suspend fun unblockUser(username: String): ApiResult { + unblockedUsername = username + unblockCount++ + return unblockResult + } + + override suspend fun muteUser(username: String): ApiResult { + mutedUsername = username + muteCount++ + return muteResult + } + + override suspend fun unmuteUser(username: String): ApiResult { + unmutedUsername = username + unmuteCount++ + return unmuteResult + } + + override suspend fun reportUser( + username: String, + reason: ReportReason, + detail: String?, + ): ApiResult { + reportArgs = Triple(username, reason, detail) + reportCount++ + return reportResult + } } /** Shorthand for building a follow-list/request user in tests. */ @@ -290,6 +367,14 @@ fun testSearchResult( displayName: String? = "User $id", ) = UserSearchResult(id = id, username = username, displayName = displayName, avatarUrl = null) +/** Shorthand for building a blocked/muted user row in tests. */ +fun testModeratedUser( + id: String = "m1", + username: String = "ada", + displayName: String? = "Ada Lovelace", + avatarUrl: String? = null, +) = ModeratedUser(id = id, username = username, displayName = displayName, avatarUrl = avatarUrl) + /** Shorthand for building a login session in tests. */ fun testSession( id: String = "s1", diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileModerationTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileModerationTest.kt new file mode 100644 index 0000000..f06997c --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/UserProfileModerationTest.kt @@ -0,0 +1,159 @@ +package com.interlinedlist.android.feature.profile.ui + +import androidx.lifecycle.SavedStateHandle +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.profile.domain.ModerationStatus +import com.interlinedlist.android.feature.profile.domain.ReportReason +import com.interlinedlist.android.feature.profile.ui.profile.PROFILE_USERNAME_ARG +import com.interlinedlist.android.feature.profile.ui.profile.UserProfileViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** Moderation (block / mute / report) coverage for [UserProfileViewModel]. */ +@OptIn(ExperimentalCoroutinesApi::class) +class UserProfileModerationTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeProfileRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeProfileRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(username: String = "ada") = + UserProfileViewModel(repo, SavedStateHandle(mapOf(PROFILE_USERNAME_ARG to username))) + + private fun otherUser(username: String = "ada") = + testUser(id = "u2", username = username, isCurrentUser = false) + + @Test + fun `loads moderation status for another user`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus(isBlocked = true, isMuted = false)) + + val vm = viewModel() + advanceUntilIdle() + + assertThat(repo.moderationStatusUsername).isEqualTo("ada") + assertThat(vm.uiState.value.moderationStatus.isBlocked).isTrue() + assertThat(vm.uiState.value.canModerate).isTrue() + } + + @Test + fun `own profile is not moderatable and skips the status call`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(testUser(id = "u1", username = "me", isCurrentUser = true)) + + val vm = viewModel("me") + advanceUntilIdle() + + assertThat(repo.moderationStatusUsername).isNull() + assertThat(vm.uiState.value.canModerate).isFalse() + } + + @Test + fun `block flips the flag optimistically and calls the repository`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus()) + val vm = viewModel() + advanceUntilIdle() + + vm.toggleBlock() + advanceUntilIdle() + + assertThat(repo.blockedUsername).isEqualTo("ada") + assertThat(repo.blockCount).isEqualTo(1) + assertThat(vm.uiState.value.moderationStatus.isBlocked).isTrue() + assertThat(vm.uiState.value.isModerationActionInProgress).isFalse() + } + + @Test + fun `block rolls back the flag and surfaces an error on failure`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus()) + repo.blockResult = ApiResult.Failure(AppError.Server("boom")) + val vm = viewModel() + advanceUntilIdle() + + vm.toggleBlock() + advanceUntilIdle() + + assertThat(vm.uiState.value.moderationStatus.isBlocked).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + assertThat(vm.uiState.value.isModerationActionInProgress).isFalse() + } + + @Test + fun `block on an already-blocked user unblocks`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus(isBlocked = true)) + val vm = viewModel() + advanceUntilIdle() + + vm.toggleBlock() + advanceUntilIdle() + + assertThat(repo.unblockedUsername).isEqualTo("ada") + assertThat(repo.blockCount).isEqualTo(0) + assertThat(vm.uiState.value.moderationStatus.isBlocked).isFalse() + } + + @Test + fun `mute flips the flag optimistically and calls the repository`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus()) + val vm = viewModel() + advanceUntilIdle() + + vm.toggleMute() + advanceUntilIdle() + + assertThat(repo.mutedUsername).isEqualTo("ada") + assertThat(vm.uiState.value.moderationStatus.isMuted).isTrue() + } + + @Test + fun `report calls the repository with reason and detail and flags success`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus()) + val vm = viewModel() + advanceUntilIdle() + + vm.report(ReportReason.HARASSMENT, "They keep messaging me.") + advanceUntilIdle() + + assertThat(repo.reportArgs) + .isEqualTo(Triple("ada", ReportReason.HARASSMENT, "They keep messaging me.")) + assertThat(vm.uiState.value.reportSubmitted).isTrue() + assertThat(vm.uiState.value.isModerationActionInProgress).isFalse() + } + + @Test + fun `report surfaces an error on failure`() = runTest(dispatcher) { + repo.refreshUserResult = ApiResult.Success(otherUser()) + repo.moderationStatusResult = ApiResult.Success(ModerationStatus()) + repo.reportResult = ApiResult.Failure(AppError.Network("offline")) + val vm = viewModel() + advanceUntilIdle() + + vm.report(ReportReason.SPAM, null) + advanceUntilIdle() + + assertThat(vm.uiState.value.reportSubmitted).isFalse() + assertThat(vm.uiState.value.errorMessage).isNotNull() + } +} From 8f7864e06320a0f2f27cc8c741851d5640bfb8d9 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 17:29:06 -0700 Subject: [PATCH 23/25] feat(app): wire D moderation, J billing, N GitHub into nav + deep-links Adds account/blocked-muted (BlockedMutedRoute), integrations/github (GitHubRoute), billingGraph + a 'Subscription' Account-hub row (onOpenUpgrade -> upsell), and app-manifest deep-link intent-filters for lists/documents shared/{token} + reset-password/verify-email (https autoVerify + interlinedlist:// scheme). :app:assembleDebug SUCCESSFUL. (Folder-browser/templates routes remain registered-but-unreached; WorkManager bootstrap still deferred to #17.) Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 30 ++++++++++++++++++- .../navigation/InterlinedListNavHost.kt | 20 +++++++++++++ .../profile/ui/profile/ProfileScreen.kt | 14 +++++++++ 4 files changed, 64 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2868454..bcc2df2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(project(":feature:lists")) implementation(project(":feature:messages")) implementation(project(":feature:directmessages")) + implementation(project(":feature:billing")) implementation(project(":feature:documents")) implementation(project(":feature:profile")) implementation(project(":feature:notifications")) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b05bc92..759d7c7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ - + @@ -20,6 +21,33 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index bd900f1..f2b7d3a 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -29,6 +29,9 @@ import androidx.navigation.navDeepLink import androidx.navigation.navigation import com.interlinedlist.android.feature.auth.nav.AuthRoutes import com.interlinedlist.android.feature.auth.nav.authGraph +import com.interlinedlist.android.feature.billing.navigation.BillingDestinations +import com.interlinedlist.android.feature.billing.navigation.billingGraph +import com.interlinedlist.android.feature.billing.navigation.navigateToUpsell import com.interlinedlist.android.feature.directmessages.navigation.DirectMessagesDestinations import com.interlinedlist.android.feature.directmessages.navigation.directMessagesGraph import com.interlinedlist.android.feature.directmessages.navigation.navigateToDmThread @@ -41,6 +44,7 @@ import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute +import com.interlinedlist.android.feature.integrations.ui.github.GitHubRoute import com.interlinedlist.android.feature.integrations.ui.hub.IntegrationsRoute import com.interlinedlist.android.feature.lists.ui.connections.ConnectionsRoute import com.interlinedlist.android.feature.lists.ui.detail.ListDetailRoute @@ -59,6 +63,7 @@ import com.interlinedlist.android.feature.notifications.ui.NotificationsRoute import com.interlinedlist.android.feature.organizations.ui.detail.OrganizationDetailRoute import com.interlinedlist.android.feature.organizations.ui.list.OrganizationsRoute import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsRoute +import com.interlinedlist.android.feature.profile.ui.account.BlockedMutedRoute import com.interlinedlist.android.feature.profile.ui.account.ConnectedAccountsRoute as ProfileConnectedAccountsRoute import com.interlinedlist.android.feature.profile.ui.account.SessionsRoute import com.interlinedlist.android.feature.profile.ui.edit.EditProfileRoute @@ -123,6 +128,7 @@ object Routes { // Account & security (Milestone K), reached from the Account hub. const val ACCOUNT_SESSIONS = "account/sessions" const val ACCOUNT_CONNECTED = "account/connected-accounts" + const val ACCOUNT_BLOCKED_MUTED = "account/blocked-muted" const val ACCOUNT_SETTINGS = "account/settings" // Notifications / organizations / integrations (reached from the Account hub). @@ -133,6 +139,7 @@ object Routes { const val INTEGRATIONS = "integrations" const val INTEGRATIONS_EXPORT = "integrations/export" const val INTEGRATIONS_ACCOUNTS = "integrations/accounts" + const val INTEGRATIONS_GITHUB = "integrations/github" fun listDetail(id: String) = "lists/$id" fun listSchema(id: String) = "lists/$id/schema" @@ -404,10 +411,15 @@ private fun MainShell(onLoggedOut: () -> Unit) { onOpenIntegrations = { tabNav.navigate(Routes.INTEGRATIONS) }, onOpenSessions = { tabNav.navigate(Routes.ACCOUNT_SESSIONS) }, onOpenConnectedAccounts = { tabNav.navigate(Routes.ACCOUNT_CONNECTED) }, + onOpenBlockedMuted = { tabNav.navigate(Routes.ACCOUNT_BLOCKED_MUTED) }, + onOpenUpgrade = { tabNav.navigateToUpsell() }, onOpenAccountSettings = { tabNav.navigate(Routes.ACCOUNT_SETTINGS) }, onSignOut = { logoutViewModel.logout(onLoggedOut) }, ) } + composable(Routes.ACCOUNT_BLOCKED_MUTED) { + BlockedMutedRoute(onBack = { tabNav.popBackStack() }) + } composable(Routes.ACCOUNT_SESSIONS) { SessionsRoute(onBack = { tabNav.popBackStack() }) } @@ -520,6 +532,7 @@ private fun MainShell(onLoggedOut: () -> Unit) { onBack = { tabNav.popBackStack() }, onOpenExport = { tabNav.navigate(Routes.INTEGRATIONS_EXPORT) }, onOpenConnectedAccounts = { tabNav.navigate(Routes.INTEGRATIONS_ACCOUNTS) }, + onOpenGitHub = { tabNav.navigate(Routes.INTEGRATIONS_GITHUB) }, ) } composable(Routes.INTEGRATIONS_EXPORT) { @@ -528,6 +541,13 @@ private fun MainShell(onLoggedOut: () -> Unit) { composable(Routes.INTEGRATIONS_ACCOUNTS) { ConnectedAccountsRoute(onBack = { tabNav.popBackStack() }) } + composable(Routes.INTEGRATIONS_GITHUB) { + GitHubRoute(onBack = { tabNav.popBackStack() }) + } + + // ---- Billing / subscription upsell (Milestone J) ---- + // Reached from the Account hub's "Subscription" row (BillingDestinations.UPSELL). + billingGraph(onBack = { tabNav.popBackStack() }) } } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt index 4b16768..4ff8a2d 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Star import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -61,6 +62,7 @@ object AccountMenuTestTags { const val SESSIONS = "accountMenuSessions" const val CONNECTED_ACCOUNTS = "accountMenuConnectedAccounts" const val BLOCKED_MUTED = "accountMenuBlockedMuted" + const val UPGRADE = "accountMenuUpgrade" const val ACCOUNT_SETTINGS = "accountMenuAccountSettings" } @@ -84,6 +86,7 @@ object AccountMenuTestTags { * @param onOpenSessions navigate to the Active Sessions screen (within this module). * @param onOpenConnectedAccounts navigate to the Connected Accounts screen (within this module). * @param onOpenBlockedMuted navigate to the "Blocked & muted" screen (within this module). + * @param onOpenUpgrade navigate to the subscription upsell (the billing module). * @param onOpenAccountSettings navigate to the Account settings screen (within this module). * @param onSignOut invoked after the caller performs sign-out; the profile module does * not own session state, so the app wires this to the auth logout + navigation. @@ -105,6 +108,9 @@ fun ProfileRoute( // Defaulted so existing app nav wiring compiles unchanged; wire this to the // `account/blocked-muted` route to enable the Blocked & muted screen (Milestone D). onOpenBlockedMuted: () -> Unit = {}, + // Defaulted so existing app nav wiring compiles unchanged; wire this to the + // billing upsell route to enable the subscription upgrade flow (Milestone J). + onOpenUpgrade: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), ) { @@ -124,6 +130,7 @@ fun ProfileRoute( onOpenSessions = onOpenSessions, onOpenConnectedAccounts = onOpenConnectedAccounts, onOpenBlockedMuted = onOpenBlockedMuted, + onOpenUpgrade = onOpenUpgrade, onOpenAccountSettings = onOpenAccountSettings, onSignOut = onSignOut, onRetry = viewModel::refresh, @@ -150,6 +157,7 @@ fun ProfileScreen( onSignOut: () -> Unit, onRetry: () -> Unit, onOpenBlockedMuted: () -> Unit = {}, + onOpenUpgrade: () -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -240,6 +248,12 @@ fun ProfileScreen( onClick = onOpenBlockedMuted, tag = AccountMenuTestTags.BLOCKED_MUTED, ) + AccountMenuRow( + icon = Icons.Default.Star, + label = "Subscription", + onClick = onOpenUpgrade, + tag = AccountMenuTestTags.UPGRADE, + ) AccountMenuRow( icon = Icons.Default.ManageAccounts, label = "Account settings", From 7ecf50be8e6bff5ffbe191f174f7cc505ef60e8d Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 19:24:20 -0700 Subject: [PATCH 24/25] chore: remove billing from Android (handled on web) + label device sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per product decision, subscription billing stays on the web app: removes the :feature:billing module, its nav graph, the Account-hub 'Subscription' row, and the app dependency + settings include. Reverses Milestone J on Android. UX fix: the sync-token request now sends a deviceLabel ('InterlinedList Android · ') so the app's session is identifiable in Active Sessions instead of appearing as a generic 'CLI' entry. :app:assembleDebug green; profile 150 + auth 29 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/build.gradle.kts | 1 - .../navigation/InterlinedListNavHost.kt | 8 - .../android/core/network/dto/AuthDto.kt | 2 + .../auth/data/DefaultAuthRepository.kt | 8 +- feature/billing/build.gradle.kts | 71 ------ .../src/androidTest/AndroidManifest.xml | 2 - .../feature/billing/ui/UpsellScreenTest.kt | 44 ---- .../feature/billing/data/BillingRepository.kt | 24 -- .../billing/data/DefaultBillingRepository.kt | 44 ---- .../feature/billing/data/remote/BillingApi.kt | 31 --- .../data/remote/dto/CheckoutSessionDto.kt | 51 ---- .../feature/billing/di/BillingModule.kt | 33 --- .../billing/navigation/BillingNavigation.kt | 30 --- .../billing/ui/BillingErrorMessages.kt | 12 - .../feature/billing/ui/UpsellScreen.kt | 221 ------------------ .../feature/billing/ui/UpsellViewModel.kt | 72 ------ .../data/DefaultBillingRepositoryTest.kt | 146 ------------ .../remote/dto/StripeSessionResponseTest.kt | 45 ---- .../billing/ui/FakeBillingRepository.kt | 28 --- .../feature/billing/ui/UpsellViewModelTest.kt | 119 ---------- .../profile/ui/profile/ProfileScreen.kt | 14 -- settings.gradle.kts | 1 - 22 files changed, 9 insertions(+), 998 deletions(-) delete mode 100644 feature/billing/build.gradle.kts delete mode 100644 feature/billing/src/androidTest/AndroidManifest.xml delete mode 100644 feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt delete mode 100644 feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt delete mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt delete mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt delete mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt delete mode 100644 feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bcc2df2..2868454 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -52,7 +52,6 @@ dependencies { implementation(project(":feature:lists")) implementation(project(":feature:messages")) implementation(project(":feature:directmessages")) - implementation(project(":feature:billing")) implementation(project(":feature:documents")) implementation(project(":feature:profile")) implementation(project(":feature:notifications")) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index f2b7d3a..67a4cd9 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -29,9 +29,6 @@ import androidx.navigation.navDeepLink import androidx.navigation.navigation import com.interlinedlist.android.feature.auth.nav.AuthRoutes import com.interlinedlist.android.feature.auth.nav.authGraph -import com.interlinedlist.android.feature.billing.navigation.BillingDestinations -import com.interlinedlist.android.feature.billing.navigation.billingGraph -import com.interlinedlist.android.feature.billing.navigation.navigateToUpsell import com.interlinedlist.android.feature.directmessages.navigation.DirectMessagesDestinations import com.interlinedlist.android.feature.directmessages.navigation.directMessagesGraph import com.interlinedlist.android.feature.directmessages.navigation.navigateToDmThread @@ -412,7 +409,6 @@ private fun MainShell(onLoggedOut: () -> Unit) { onOpenSessions = { tabNav.navigate(Routes.ACCOUNT_SESSIONS) }, onOpenConnectedAccounts = { tabNav.navigate(Routes.ACCOUNT_CONNECTED) }, onOpenBlockedMuted = { tabNav.navigate(Routes.ACCOUNT_BLOCKED_MUTED) }, - onOpenUpgrade = { tabNav.navigateToUpsell() }, onOpenAccountSettings = { tabNav.navigate(Routes.ACCOUNT_SETTINGS) }, onSignOut = { logoutViewModel.logout(onLoggedOut) }, ) @@ -544,10 +540,6 @@ private fun MainShell(onLoggedOut: () -> Unit) { composable(Routes.INTEGRATIONS_GITHUB) { GitHubRoute(onBack = { tabNav.popBackStack() }) } - - // ---- Billing / subscription upsell (Milestone J) ---- - // Reached from the Account hub's "Subscription" row (BillingDestinations.UPSELL). - billingGraph(onBack = { tabNav.popBackStack() }) } } } diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/AuthDto.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/AuthDto.kt index 92fe624..999be33 100644 --- a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/AuthDto.kt +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/AuthDto.kt @@ -7,6 +7,8 @@ import kotlinx.serialization.Serializable data class SyncTokenRequest( val email: String, val password: String, + /** Human-readable device name shown in the user's Active Sessions list. */ + val deviceLabel: String? = null, ) /** Response from `POST /api/auth/sync-token`; `token` is the `il_tok_...` value. */ diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt index 862d770..036a4fe 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt @@ -96,7 +96,13 @@ class DefaultAuthRepository @Inject constructor( */ private suspend fun signIn(email: String, password: String): ApiResult { val tokenResult = safeApiCall(json) { - api.createSyncToken(SyncTokenRequest(email, password)) + api.createSyncToken( + SyncTokenRequest( + email, + password, + deviceLabel = "InterlinedList Android · ${android.os.Build.MODEL}", + ), + ) } val token = when (tokenResult) { is ApiResult.Success -> tokenResult.data.token diff --git a/feature/billing/build.gradle.kts b/feature/billing/build.gradle.kts deleted file mode 100644 index f0a3740..0000000 --- a/feature/billing/build.gradle.kts +++ /dev/null @@ -1,71 +0,0 @@ -plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) - alias(libs.plugins.kotlin.compose) - alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.ksp) - alias(libs.plugins.hilt) -} - -android { - namespace = "com.interlinedlist.android.feature.billing" - compileSdk = 35 - - defaultConfig { - minSdk = 26 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - - buildFeatures { compose = true } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { jvmTarget = "17" } -} - -dependencies { - implementation(project(":core:model")) - implementation(project(":core:common")) - implementation(project(":core:designsystem")) - implementation(project(":core:network")) - - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.material3) - implementation(libs.androidx.compose.material.icons.extended) - implementation(libs.androidx.compose.ui.tooling.preview) - debugImplementation(libs.androidx.compose.ui.tooling) - implementation(libs.androidx.lifecycle.viewmodel.compose) - implementation(libs.androidx.lifecycle.runtime.compose) - implementation(libs.androidx.activity.compose) - - implementation(libs.hilt.android) - ksp(libs.hilt.compiler) - implementation(libs.androidx.hilt.navigation.compose) - implementation(libs.androidx.navigation.compose) - - implementation(libs.retrofit.core) - implementation(libs.kotlinx.serialization.json) - - // Unit tests - testImplementation(libs.junit) - testImplementation(libs.kotlinx.coroutines.test) - testImplementation(libs.turbine) - testImplementation(libs.truth) - // Repository tests hit a MockWebServer through the real Retrofit stack. - testImplementation(libs.okhttp.mockwebserver) - testImplementation(libs.retrofit.core) - testImplementation(libs.retrofit.kotlinx.serialization) - testImplementation(libs.okhttp.core) - testImplementation(libs.kotlinx.serialization.json) - - // Instrumented / UI tests - androidTestImplementation(libs.androidx.test.ext.junit) - androidTestImplementation(libs.androidx.test.runner) - androidTestImplementation(platform(libs.androidx.compose.bom)) - androidTestImplementation(libs.androidx.compose.ui.test.junit4) - androidTestImplementation(libs.truth) - debugImplementation(libs.androidx.compose.ui.test.manifest) -} diff --git a/feature/billing/src/androidTest/AndroidManifest.xml b/feature/billing/src/androidTest/AndroidManifest.xml deleted file mode 100644 index b2d3ea1..0000000 --- a/feature/billing/src/androidTest/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt b/feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt deleted file mode 100644 index e04e6e3..0000000 --- a/feature/billing/src/androidTest/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreenTest.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.interlinedlist.android.feature.billing.ui - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class UpsellScreenTest { - - @get:Rule - val composeRule = createComposeRule() - - @Test - fun upsell_rendersSubscribeAndManage_andInvokesCallbacks() { - var subscribed = false - var managed = false - composeRule.setContent { - InterlinedListTheme { - UpsellScreen( - state = UpsellUiState(), - onSubscribe = { subscribed = true }, - onManage = { managed = true }, - onDismissError = {}, - onBack = {}, - ) - } - } - - composeRule.onNodeWithTag(UpsellTestTags.SUBSCRIBE).assertIsDisplayed() - composeRule.onNodeWithTag(UpsellTestTags.MANAGE).assertIsDisplayed() - - composeRule.onNodeWithTag(UpsellTestTags.SUBSCRIBE).performClick() - composeRule.onNodeWithTag(UpsellTestTags.MANAGE).performClick() - - assert(subscribed) - assert(managed) - } -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt deleted file mode 100644 index e4769d2..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/BillingRepository.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.interlinedlist.android.feature.billing.data - -import com.interlinedlist.android.core.common.result.ApiResult - -/** - * Data operations for subscription billing. Both calls mint a short-lived Stripe - * hosted session on the server and return its URL for the UI to open in a browser. - * Everything is a live, stateless request — there is no cache — so results come - * back as an [ApiResult] carrying the URL string. - */ -interface BillingRepository { - - /** - * Creates a Stripe Checkout session for the given [priceId] (null lets the - * server pick the default subscription price) and returns its hosted URL. - */ - suspend fun createCheckoutSession(priceId: String? = null): ApiResult - - /** - * Creates a Stripe customer-portal session and returns its hosted URL, where - * the user can manage or cancel an existing subscription. - */ - suspend fun createPortalSession(): ApiResult -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt deleted file mode 100644 index 65adde4..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepository.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.interlinedlist.android.feature.billing.data - -import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.core.common.result.AppError -import com.interlinedlist.android.core.network.error.safeApiCall -import com.interlinedlist.android.feature.billing.data.remote.BillingApi -import com.interlinedlist.android.feature.billing.data.remote.dto.CreateCheckoutSessionRequest -import com.interlinedlist.android.feature.billing.data.remote.dto.CreatePortalSessionRequest -import com.interlinedlist.android.feature.billing.data.remote.dto.StripeSessionResponse -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import javax.inject.Inject - -class DefaultBillingRepository @Inject constructor( - private val api: BillingApi, - private val json: Json, - private val dispatchers: DispatcherProvider, -) : BillingRepository { - - override suspend fun createCheckoutSession(priceId: String?): ApiResult = - withContext(dispatchers.io) { - safeApiCall(json) { api.createCheckoutSession(CreateCheckoutSessionRequest(priceId)) } - .requireUrl() - } - - override suspend fun createPortalSession(): ApiResult = - withContext(dispatchers.io) { - safeApiCall(json) { api.createPortalSession(CreatePortalSessionRequest()) } - .requireUrl() - } - - /** - * A 2xx with no URL is a contract violation, not a success — the UI has nothing - * to open — so it is folded into a failure the error mapper can render, rather - * than surfaced as an empty string. - */ - private fun ApiResult.requireUrl(): ApiResult = when (this) { - is ApiResult.Success -> data.resolvedUrl - ?.let { ApiResult.Success(it) } - ?: ApiResult.Failure(AppError.Server("The billing session did not return a URL.")) - is ApiResult.Failure -> this - } -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt deleted file mode 100644 index dc0cc0e..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/BillingApi.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.interlinedlist.android.feature.billing.data.remote - -import com.interlinedlist.android.feature.billing.data.remote.dto.CreateCheckoutSessionRequest -import com.interlinedlist.android.feature.billing.data.remote.dto.CreatePortalSessionRequest -import com.interlinedlist.android.feature.billing.data.remote.dto.StripeSessionResponse -import retrofit2.http.Body -import retrofit2.http.POST - -/** - * Retrofit description of the Stripe billing endpoints. Provided from the shared, - * already-authenticated [retrofit2.Retrofit] (base URL + Bearer interceptor), so - * both calls are authed. - * - * Each endpoint mints a short-lived Stripe hosted session and returns its URL; the - * client opens that URL in a browser. No live sessions are created in tests — the - * repository is exercised against MockWebServer only. - */ -interface BillingApi { - - /** Creates a Stripe Checkout session and returns its hosted URL. */ - @POST("api/stripe/create-checkout-session") - suspend fun createCheckoutSession( - @Body body: CreateCheckoutSessionRequest, - ): StripeSessionResponse - - /** Creates a Stripe customer-portal session and returns its hosted URL. */ - @POST("api/stripe/create-portal-session") - suspend fun createPortalSession( - @Body body: CreatePortalSessionRequest, - ): StripeSessionResponse -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt deleted file mode 100644 index 76b2966..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/CheckoutSessionDto.kt +++ /dev/null @@ -1,51 +0,0 @@ -package com.interlinedlist.android.feature.billing.data.remote.dto - -import kotlinx.serialization.Serializable - -/** - * Body for `POST /api/stripe/create-checkout-session` (OpenAPI: `{ priceId }`). - * - * [priceId] selects which Stripe Price the checkout is for. It is optional in the - * spec — the server falls back to the account's default subscription price when it - * is omitted — so it is nullable and, with the shared Json's `explicitNulls = false`, - * simply left out of the request body when null. - */ -@Serializable -data class CreateCheckoutSessionRequest( - val priceId: String? = null, -) - -/** - * Body for `POST /api/stripe/create-portal-session` (OpenAPI: `{ flow }`). - * - * [flow] optionally deep-links the customer portal to a specific flow (e.g. - * `subscription_cancel`). Omitted when null, landing the user on the portal home. - */ -@Serializable -data class CreatePortalSessionRequest( - val flow: String? = null, -) - -/** - * Response for both Stripe session endpoints. - * - * The OpenAPI spec does not model these response bodies, but Stripe's - * `checkout.sessions.create` / `billingPortal.sessions.create` return an object - * carrying a hosted `url`, and the web app redirects the browser to it. This DTO - * therefore reads [url] first and, to stay resilient to a minor key rename on the - * backend, falls back to a handful of common aliases via [resolvedUrl]. The shared - * Json is configured with `ignoreUnknownKeys`, so any extra Stripe fields (id, - * sessionId, etc.) decode without throwing. - */ -@Serializable -data class StripeSessionResponse( - val url: String? = null, - val checkoutUrl: String? = null, - val portalUrl: String? = null, - val sessionUrl: String? = null, -) { - /** The first non-blank URL field, or null if the server sent none. */ - val resolvedUrl: String? - get() = listOf(url, checkoutUrl, portalUrl, sessionUrl) - .firstOrNull { !it.isNullOrBlank() } -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt deleted file mode 100644 index e388515..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/di/BillingModule.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.interlinedlist.android.feature.billing.di - -import com.interlinedlist.android.feature.billing.data.BillingRepository -import com.interlinedlist.android.feature.billing.data.DefaultBillingRepository -import com.interlinedlist.android.feature.billing.data.remote.BillingApi -import dagger.Binds -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import retrofit2.Retrofit -import javax.inject.Singleton - -/** Binds the repository interface to its default implementation. */ -@Module -@InstallIn(SingletonComponent::class) -abstract class BillingRepositoryModule { - - @Binds - @Singleton - abstract fun bindBillingRepository(impl: DefaultBillingRepository): BillingRepository -} - -/** Provides this feature's API off the shared authed Retrofit. */ -@Module -@InstallIn(SingletonComponent::class) -object BillingDataModule { - - @Provides - @Singleton - fun provideBillingApi(retrofit: Retrofit): BillingApi = - retrofit.create(BillingApi::class.java) -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt deleted file mode 100644 index 7b271b0..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/navigation/BillingNavigation.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.interlinedlist.android.feature.billing.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.interlinedlist.android.feature.billing.ui.UpsellRoute - -/** Route keys for the billing graph. */ -object BillingDestinations { - /** Subscription upsell — the graph's entry (and only) route. */ - const val UPSELL = "billing/upsell" -} - -/** Convenience navigation helper so callers don't hand-build route strings. */ -fun NavController.navigateToUpsell() = navigate(BillingDestinations.UPSELL) - -/** - * Registers the billing destinations into the host graph. - * - * The app wires this into its top-level NavHost (see the module's report for the - * exact snippet plus how to route here from a 403 subscription gate and the - * Account hub). [onBack] pops the current destination. - */ -fun NavGraphBuilder.billingGraph( - onBack: () -> Unit, -) { - composable(BillingDestinations.UPSELL) { - UpsellRoute(onBack = onBack) - } -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt deleted file mode 100644 index 6dc4409..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/BillingErrorMessages.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.interlinedlist.android.feature.billing.ui - -import com.interlinedlist.android.core.common.result.AppError - -/** Maps a normalised [AppError] to a concise, user-facing message for the billing UI. */ -fun AppError.toUserMessage(): String = when (this) { - is AppError.Network -> "No connection. Check your network and try again." - is AppError.Unauthorized -> message ?: "Please sign in again." - is AppError.RateLimited -> "Too many requests. Please wait a moment and try again." - is AppError.Server -> "We couldn't start your billing session. Please try again shortly." - else -> message ?: "Something went wrong. Please try again." -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt deleted file mode 100644 index 3d2c686..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellScreen.kt +++ /dev/null @@ -1,221 +0,0 @@ -package com.interlinedlist.android.feature.billing.ui - -import android.content.Intent -import android.net.Uri -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme - -/** Stable test tags so UI/instrumented tests can address the upsell controls. */ -object UpsellTestTags { - const val SUBSCRIBE = "upsellSubscribe" - const val MANAGE = "upsellManage" - const val ERROR = "upsellError" -} - -/** The benefit bullets shown on the upsell — kept here so the screen stays declarative. */ -private val benefits = listOf( - "Unlimited lists and data rows", - "Full CSV exports of your data", - "Priority access to new integrations", -) - -/** - * Hilt-wired entry point for the subscription upsell. Collects state and, when a - * Stripe session URL is ready, opens it in the browser with a plain - * `ACTION_VIEW` intent (no Custom Tabs dependency). - */ -@Composable -fun UpsellRoute( - onBack: () -> Unit, - modifier: Modifier = Modifier, - viewModel: UpsellViewModel = hiltViewModel(), -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - val context = LocalContext.current - - // One-shot: open each Stripe hosted URL as it becomes ready. - LaunchedEffect(Unit) { - viewModel.open.collect { event -> - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(event.url)) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(intent) - } - } - - UpsellScreen( - state = state, - onSubscribe = { viewModel.subscribe() }, - onManage = viewModel::manageSubscription, - onDismissError = viewModel::clearError, - onBack = onBack, - modifier = modifier, - ) -} - -/** Stateless upsell UI — easy to preview and to drive from Compose tests. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun UpsellScreen( - state: UpsellUiState, - onSubscribe: () -> Unit, - onManage: () -> Unit, - onDismissError: () -> Unit, - onBack: () -> Unit, - modifier: Modifier = Modifier, -) { - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(state.errorMessage) { - state.errorMessage?.let { - snackbarHostState.showSnackbar(it) - onDismissError() - } - } - - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text("Upgrade") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - snackbarHost = { - SnackbarHost( - snackbarHostState, - modifier = Modifier.testTag(UpsellTestTags.ERROR), - ) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .padding(horizontal = 24.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Spacer(Modifier.size(8.dp)) - Text( - text = "Go Pro", - style = MaterialTheme.typography.headlineMedium, - ) - Text( - text = "Unlock the full InterlinedList experience with a subscription.", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - benefits.forEach { benefit -> BenefitRow(benefit) } - } - - Spacer(Modifier.size(8.dp)) - - Button( - onClick = onSubscribe, - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag(UpsellTestTags.SUBSCRIBE), - ) { - if (state.isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Text("Subscribe") - } - } - - OutlinedButton( - onClick = onManage, - enabled = !state.isLoading, - modifier = Modifier - .fillMaxWidth() - .testTag(UpsellTestTags.MANAGE), - ) { - Text("Manage subscription") - } - - Text( - text = "Already subscribed? Open the customer portal to update or cancel " + - "your plan.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} - -@Composable -private fun BenefitRow(text: String) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - Spacer(Modifier.size(12.dp)) - Text(text = text, style = MaterialTheme.typography.bodyLarge) - } -} - -@Preview(showBackground = true) -@Composable -private fun UpsellScreenPreview() { - InterlinedListTheme { - UpsellScreen( - state = UpsellUiState(), - onSubscribe = {}, - onManage = {}, - onDismissError = {}, - onBack = {}, - ) - } -} diff --git a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt b/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt deleted file mode 100644 index e666e2c..0000000 --- a/feature/billing/src/main/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModel.kt +++ /dev/null @@ -1,72 +0,0 @@ -package com.interlinedlist.android.feature.billing.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.feature.billing.data.BillingRepository -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch -import javax.inject.Inject - -/** - * UI state for the upsell screen. [isLoading] disables both buttons while a - * session is being minted; [errorMessage] renders inline when a request fails. - */ -data class UpsellUiState( - val isLoading: Boolean = false, - val errorMessage: String? = null, -) - -/** - * A Stripe hosted URL ready to be opened in a browser — a one-shot event. Emitting - * the URL rather than launching it here keeps the ViewModel free of Android - * `Intent`/`Context` and therefore unit-testable. - */ -data class OpenUrl(val url: String) - -@HiltViewModel -class UpsellViewModel @Inject constructor( - private val repository: BillingRepository, -) : ViewModel() { - - private val _uiState = MutableStateFlow(UpsellUiState()) - val uiState: StateFlow = _uiState.asStateFlow() - - // Buffered channel so a URL event survives brief config-change gaps. - private val _open = Channel(Channel.BUFFERED) - val open = _open.receiveAsFlow() - - /** Mints a Checkout session; on success emits its URL for the screen to open. */ - fun subscribe(priceId: String? = null) = launchSession { - repository.createCheckoutSession(priceId) - } - - /** Mints a customer-portal session; on success emits its URL to open. */ - fun manageSubscription() = launchSession { - repository.createPortalSession() - } - - private fun launchSession(request: suspend () -> ApiResult) { - if (_uiState.value.isLoading) return - _uiState.update { it.copy(isLoading = true, errorMessage = null) } - viewModelScope.launch { - when (val result = request()) { - is ApiResult.Success -> { - _uiState.update { it.copy(isLoading = false) } - _open.send(OpenUrl(result.data)) - } - is ApiResult.Failure -> _uiState.update { - it.copy(isLoading = false, errorMessage = result.error.toUserMessage()) - } - } - } - } - - fun clearError() = _uiState.update { it.copy(errorMessage = null) } -} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt deleted file mode 100644 index e020de8..0000000 --- a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/DefaultBillingRepositoryTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.interlinedlist.android.feature.billing.data - -import com.google.common.truth.Truth.assertThat -import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.core.common.result.AppError -import com.interlinedlist.android.feature.billing.data.remote.BillingApi -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.runTest -import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.MockWebServer -import org.junit.After -import org.junit.Before -import org.junit.Test -import retrofit2.Retrofit - -/** - * Repository behaviour against a real HTTP stack (Retrofit + OkHttp) driven by - * MockWebServer. No live Stripe sessions are ever created — every response is a - * canned MockResponse. Verifies the checkout/portal URLs are surfaced, the right - * endpoints/bodies are hit, errors are mapped, and a URL-less 2xx degrades to a - * failure the UI can render. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class DefaultBillingRepositoryTest { - - private lateinit var server: MockWebServer - private lateinit var api: BillingApi - private lateinit var repository: DefaultBillingRepository - - private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } - private val dispatcher = StandardTestDispatcher() - - private val testDispatchers = object : DispatcherProvider { - override val io: CoroutineDispatcher get() = dispatcher - override val default: CoroutineDispatcher get() = dispatcher - override val main: CoroutineDispatcher get() = dispatcher - } - - @Before - fun setUp() { - server = MockWebServer().also { it.start() } - api = Retrofit.Builder() - .baseUrl(server.url("/")) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .build() - .create(BillingApi::class.java) - repository = DefaultBillingRepository(api, json, testDispatchers) - } - - @After - fun tearDown() = server.shutdown() - - @Test - fun `createCheckoutSession posts to the checkout endpoint and returns the url`() = - runTest(dispatcher) { - server.enqueue( - MockResponse().setResponseCode(201) - .setBody("""{ "url": "https://checkout.stripe.com/c/pay/cs_test_123" }"""), - ) - - val result = repository.createCheckoutSession("price_pro_monthly") - - assertThat(result).isInstanceOf(ApiResult.Success::class.java) - assertThat((result as ApiResult.Success).data) - .isEqualTo("https://checkout.stripe.com/c/pay/cs_test_123") - - val request = server.takeRequest() - assertThat(request.method).isEqualTo("POST") - assertThat(request.path).isEqualTo("/api/stripe/create-checkout-session") - assertThat(request.body.readUtf8()).contains("price_pro_monthly") - } - - @Test - fun `createPortalSession posts to the portal endpoint and returns the url`() = - runTest(dispatcher) { - server.enqueue( - MockResponse().setResponseCode(201) - .setBody("""{ "url": "https://billing.stripe.com/p/session/bps_test_456" }"""), - ) - - val result = repository.createPortalSession() - - assertThat(result).isInstanceOf(ApiResult.Success::class.java) - assertThat((result as ApiResult.Success).data) - .isEqualTo("https://billing.stripe.com/p/session/bps_test_456") - - val request = server.takeRequest() - assertThat(request.method).isEqualTo("POST") - assertThat(request.path).isEqualTo("/api/stripe/create-portal-session") - } - - @Test - fun `createCheckoutSession maps a 401 to Unauthorized`() = runTest(dispatcher) { - server.enqueue( - MockResponse().setResponseCode(401) - .setBody("""{ "error": "Not authenticated" }"""), - ) - - val result = repository.createCheckoutSession() - - assertThat(result).isInstanceOf(ApiResult.Failure::class.java) - assertThat((result as ApiResult.Failure).error) - .isInstanceOf(AppError.Unauthorized::class.java) - } - - @Test - fun `createPortalSession maps a 500 to Server`() = runTest(dispatcher) { - server.enqueue(MockResponse().setResponseCode(500).setBody("boom")) - - val result = repository.createPortalSession() - - assertThat(result).isInstanceOf(ApiResult.Failure::class.java) - assertThat((result as ApiResult.Failure).error) - .isInstanceOf(AppError.Server::class.java) - } - - @Test - fun `a successful response with no url degrades to a Server failure`() = runTest(dispatcher) { - server.enqueue(MockResponse().setResponseCode(201).setBody("""{ "id": "cs_test_789" }""")) - - val result = repository.createCheckoutSession() - - assertThat(result).isInstanceOf(ApiResult.Failure::class.java) - assertThat((result as ApiResult.Failure).error) - .isInstanceOf(AppError.Server::class.java) - } - - @Test - fun `checkout accepts a checkoutUrl alias when url is absent`() = runTest(dispatcher) { - server.enqueue( - MockResponse().setResponseCode(201) - .setBody("""{ "checkoutUrl": "https://checkout.stripe.com/c/pay/alias" }"""), - ) - - val result = repository.createCheckoutSession() - - assertThat((result as ApiResult.Success).data) - .isEqualTo("https://checkout.stripe.com/c/pay/alias") - } -} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt deleted file mode 100644 index 8ec437c..0000000 --- a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/data/remote/dto/StripeSessionResponseTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.interlinedlist.android.feature.billing.data.remote.dto - -import com.google.common.truth.Truth.assertThat -import kotlinx.serialization.json.Json -import org.junit.Test - -class StripeSessionResponseTest { - - private val json = Json { ignoreUnknownKeys = true; explicitNulls = false } - - @Test - fun `resolvedUrl prefers the canonical url field`() { - val dto = json.decodeFromString( - StripeSessionResponse.serializer(), - """{ "url": "https://a", "checkoutUrl": "https://b" }""", - ) - assertThat(dto.resolvedUrl).isEqualTo("https://a") - } - - @Test - fun `resolvedUrl falls back to aliases when url is missing`() { - assertThat( - json.decodeFromString(StripeSessionResponse.serializer(), """{ "portalUrl": "https://p" }""") - .resolvedUrl, - ).isEqualTo("https://p") - } - - @Test - fun `resolvedUrl ignores blank values`() { - val dto = json.decodeFromString( - StripeSessionResponse.serializer(), - """{ "url": "", "sessionUrl": "https://s" }""", - ) - assertThat(dto.resolvedUrl).isEqualTo("https://s") - } - - @Test - fun `resolvedUrl is null when no url field is present`() { - val dto = json.decodeFromString( - StripeSessionResponse.serializer(), - """{ "id": "cs_test_1", "object": "checkout.session" }""", - ) - assertThat(dto.resolvedUrl).isNull() - } -} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt deleted file mode 100644 index c1281b1..0000000 --- a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/FakeBillingRepository.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.interlinedlist.android.feature.billing.ui - -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.feature.billing.data.BillingRepository - -/** - * In-memory [BillingRepository] for ViewModel tests. Each operation returns its - * configured result and records the arguments it was called with, so tests can - * assert both the emitted effect and that the right request was made. - */ -class FakeBillingRepository : BillingRepository { - - var checkoutResult: ApiResult = ApiResult.Success("https://checkout.example/session") - var portalResult: ApiResult = ApiResult.Success("https://portal.example/session") - - val requestedPriceIds = mutableListOf() - var portalCalls = 0 - - override suspend fun createCheckoutSession(priceId: String?): ApiResult { - requestedPriceIds += priceId - return checkoutResult - } - - override suspend fun createPortalSession(): ApiResult { - portalCalls++ - return portalResult - } -} diff --git a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt b/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt deleted file mode 100644 index 69ebb37..0000000 --- a/feature/billing/src/test/kotlin/com/interlinedlist/android/feature/billing/ui/UpsellViewModelTest.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.interlinedlist.android.feature.billing.ui - -import app.cash.turbine.test -import com.google.common.truth.Truth.assertThat -import com.interlinedlist.android.core.common.result.ApiResult -import com.interlinedlist.android.core.common.result.AppError -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.resetMain -import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.test.setMain -import org.junit.After -import org.junit.Before -import org.junit.Test - -@OptIn(ExperimentalCoroutinesApi::class) -class UpsellViewModelTest { - - private val dispatcher = StandardTestDispatcher() - private lateinit var repo: FakeBillingRepository - - @Before - fun setUp() { - Dispatchers.setMain(dispatcher) - repo = FakeBillingRepository() - } - - @After - fun tearDown() = Dispatchers.resetMain() - - @Test - fun `subscribe emits the checkout URL as an open effect`() = runTest(dispatcher) { - repo.checkoutResult = ApiResult.Success("https://checkout.stripe.example/abc") - val vm = UpsellViewModel(repo) - - vm.open.test { - vm.subscribe() - advanceUntilIdle() - - assertThat(awaitItem().url).isEqualTo("https://checkout.stripe.example/abc") - cancelAndIgnoreRemainingEvents() - } - assertThat(repo.requestedPriceIds).containsExactly(null as String?) - assertThat(vm.uiState.value.isLoading).isFalse() - assertThat(vm.uiState.value.errorMessage).isNull() - } - - @Test - fun `manage subscription emits the portal URL as an open effect`() = runTest(dispatcher) { - repo.portalResult = ApiResult.Success("https://portal.stripe.example/xyz") - val vm = UpsellViewModel(repo) - - vm.open.test { - vm.manageSubscription() - advanceUntilIdle() - - assertThat(awaitItem().url).isEqualTo("https://portal.stripe.example/xyz") - cancelAndIgnoreRemainingEvents() - } - assertThat(repo.portalCalls).isEqualTo(1) - } - - @Test - fun `subscribe shows a loading spinner while the session is in flight`() = runTest(dispatcher) { - repo.checkoutResult = ApiResult.Success("https://checkout.example/s") - val vm = UpsellViewModel(repo) - - vm.uiState.test { - assertThat(awaitItem().isLoading).isFalse() // initial - - vm.subscribe() - assertThat(awaitItem().isLoading).isTrue() // in-flight - - advanceUntilIdle() - assertThat(awaitItem().isLoading).isFalse() // done - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `a second request is ignored while one is in flight`() = runTest(dispatcher) { - repo.checkoutResult = ApiResult.Success("https://checkout.example/s") - val vm = UpsellViewModel(repo) - - vm.subscribe() - vm.manageSubscription() // dropped: busy - advanceUntilIdle() - - assertThat(repo.requestedPriceIds).hasSize(1) - assertThat(repo.portalCalls).isEqualTo(0) - } - - @Test - fun `failed checkout surfaces a mapped error and emits no open effect`() = runTest(dispatcher) { - repo.checkoutResult = ApiResult.Failure(AppError.Network(null)) - val vm = UpsellViewModel(repo) - - vm.subscribe() - advanceUntilIdle() - - assertThat(vm.uiState.value.isLoading).isFalse() - assertThat(vm.uiState.value.errorMessage) - .isEqualTo("No connection. Check your network and try again.") - } - - @Test - fun `failed portal session surfaces a mapped error`() = runTest(dispatcher) { - repo.portalResult = ApiResult.Failure(AppError.Server(null)) - val vm = UpsellViewModel(repo) - - vm.manageSubscription() - advanceUntilIdle() - - assertThat(vm.uiState.value.errorMessage) - .isEqualTo("We couldn't start your billing session. Please try again shortly.") - } -} diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt index 4ff8a2d..4b16768 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/profile/ProfileScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.material.icons.filled.ManageAccounts import androidx.compose.material.icons.filled.Notifications import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Star import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -62,7 +61,6 @@ object AccountMenuTestTags { const val SESSIONS = "accountMenuSessions" const val CONNECTED_ACCOUNTS = "accountMenuConnectedAccounts" const val BLOCKED_MUTED = "accountMenuBlockedMuted" - const val UPGRADE = "accountMenuUpgrade" const val ACCOUNT_SETTINGS = "accountMenuAccountSettings" } @@ -86,7 +84,6 @@ object AccountMenuTestTags { * @param onOpenSessions navigate to the Active Sessions screen (within this module). * @param onOpenConnectedAccounts navigate to the Connected Accounts screen (within this module). * @param onOpenBlockedMuted navigate to the "Blocked & muted" screen (within this module). - * @param onOpenUpgrade navigate to the subscription upsell (the billing module). * @param onOpenAccountSettings navigate to the Account settings screen (within this module). * @param onSignOut invoked after the caller performs sign-out; the profile module does * not own session state, so the app wires this to the auth logout + navigation. @@ -108,9 +105,6 @@ fun ProfileRoute( // Defaulted so existing app nav wiring compiles unchanged; wire this to the // `account/blocked-muted` route to enable the Blocked & muted screen (Milestone D). onOpenBlockedMuted: () -> Unit = {}, - // Defaulted so existing app nav wiring compiles unchanged; wire this to the - // billing upsell route to enable the subscription upgrade flow (Milestone J). - onOpenUpgrade: () -> Unit = {}, modifier: Modifier = Modifier, viewModel: ProfileViewModel = hiltViewModel(), ) { @@ -130,7 +124,6 @@ fun ProfileRoute( onOpenSessions = onOpenSessions, onOpenConnectedAccounts = onOpenConnectedAccounts, onOpenBlockedMuted = onOpenBlockedMuted, - onOpenUpgrade = onOpenUpgrade, onOpenAccountSettings = onOpenAccountSettings, onSignOut = onSignOut, onRetry = viewModel::refresh, @@ -157,7 +150,6 @@ fun ProfileScreen( onSignOut: () -> Unit, onRetry: () -> Unit, onOpenBlockedMuted: () -> Unit = {}, - onOpenUpgrade: () -> Unit = {}, modifier: Modifier = Modifier, ) { Scaffold( @@ -248,12 +240,6 @@ fun ProfileScreen( onClick = onOpenBlockedMuted, tag = AccountMenuTestTags.BLOCKED_MUTED, ) - AccountMenuRow( - icon = Icons.Default.Star, - label = "Subscription", - onClick = onOpenUpgrade, - tag = AccountMenuTestTags.UPGRADE, - ) AccountMenuRow( icon = Icons.Default.ManageAccounts, label = "Account settings", diff --git a/settings.gradle.kts b/settings.gradle.kts index 055b9ab..e3a23ef 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,4 +43,3 @@ include(":feature:notifications") include(":feature:organizations") include(":feature:integrations") include(":feature:directmessages") -include(":feature:billing") From b5a475d0e8a2202551712bf0a7130416ed3e651e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 31 Jul 2026 19:30:13 -0700 Subject: [PATCH 25/25] feat(app): make folder-browser (lists) and templates (documents) reachable Adds a Folders top-bar action on the Lists screen -> FolderBrowserRoute (lists/folders), and a Templates action on the Documents browser -> new documents/templates route (DocumentTemplatesRoute). Completes the deferred folder/templates reachability (UX polish). :app:assembleDebug green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../android/navigation/InterlinedListNavHost.kt | 10 ++++++++++ .../documents/ui/browser/DocumentsBrowserScreen.kt | 13 +++++++++++++ .../android/feature/lists/ui/list/ListsScreen.kt | 11 +++++++++++ 3 files changed, 34 insertions(+) diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 67a4cd9..b39c2c7 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -39,6 +39,7 @@ import com.interlinedlist.android.feature.documents.ui.editor.DocumentEditorRout import com.interlinedlist.android.feature.documents.ui.share.DocumentShareRoute import com.interlinedlist.android.feature.documents.ui.share.SharedDocumentRoute import com.interlinedlist.android.feature.documents.ui.collaborators.DocumentCollaboratorsRoute +import com.interlinedlist.android.feature.documents.ui.templates.DocumentTemplatesRoute import com.interlinedlist.android.feature.integrations.ui.accounts.ConnectedAccountsRoute import com.interlinedlist.android.feature.integrations.ui.export.ExportRoute import com.interlinedlist.android.feature.integrations.ui.github.GitHubRoute @@ -103,6 +104,7 @@ object Routes { // Documents destinations. const val DOCUMENT_FOLDER = "documents/folder/{folderId}" const val DOCUMENT_EDITOR = "documents/editor/{documentId}" + const val DOCUMENT_TEMPLATES = "documents/templates" // Documents sharing (Milestone F). const val DOCUMENT_SHARE = "documents/{documentId}/share" @@ -281,6 +283,7 @@ private fun MainShell(onLoggedOut: () -> Unit) { onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) }, onOpenConnections = { tabNav.navigate(Routes.LIST_CONNECTIONS) }, onOpenSharedWithMe = { tabNav.navigate(Routes.LISTS_SHARED_WITH_ME) }, + onOpenFolders = { tabNav.navigate(Routes.LIST_FOLDERS) }, ) } composable( @@ -345,6 +348,13 @@ private fun MainShell(onLoggedOut: () -> Unit) { DocumentsRoute( onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) }, onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, + onOpenTemplates = { tabNav.navigate(Routes.DOCUMENT_TEMPLATES) }, + ) + } + composable(Routes.DOCUMENT_TEMPLATES) { + DocumentTemplatesRoute( + onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) }, + onBack = { tabNav.popBackStack() }, ) } composable( diff --git a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt index 3c8b7c8..19505ff 100644 --- a/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt +++ b/feature/documents/src/main/kotlin/com/interlinedlist/android/feature/documents/ui/browser/DocumentsBrowserScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material.icons.automirrored.filled.DriveFileMove import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.CreateNewFolder +import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit @@ -64,6 +65,7 @@ object DocumentsBrowserTestTags { const val CREATE_DOC = "browserCreateDoc" const val CREATE_FOLDER = "browserCreateFolder" const val SEARCH_ACTION = "browserSearchAction" + const val TEMPLATES_ACTION = "browserTemplatesAction" const val SEARCH_FIELD = "browserSearchField" const val SEARCH_RESULTS = "browserSearchResults" const val BREADCRUMB = "browserBreadcrumb" @@ -88,6 +90,7 @@ fun DocumentsRoute( onOpenFolder: (String) -> Unit, onOpenDocument: (String) -> Unit, modifier: Modifier = Modifier, + onOpenTemplates: () -> Unit = {}, viewModel: DocumentsBrowserViewModel = hiltViewModel(), ) { DocumentsFolderRoute( @@ -95,6 +98,7 @@ fun DocumentsRoute( onOpenDocument = onOpenDocument, onBack = null, modifier = modifier, + onOpenTemplates = onOpenTemplates, viewModel = viewModel, ) } @@ -109,6 +113,7 @@ fun DocumentsFolderRoute( onOpenDocument: (String) -> Unit, onBack: (() -> Unit)?, modifier: Modifier = Modifier, + onOpenTemplates: () -> Unit = {}, viewModel: DocumentsBrowserViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -126,6 +131,7 @@ fun DocumentsFolderRoute( onCloseSearch = viewModel::closeSearch, onSearchQueryChange = viewModel::onSearchQueryChange, onBack = onBack, + onOpenTemplates = onOpenTemplates, modifier = modifier, ) } @@ -147,6 +153,7 @@ fun DocumentsBrowserScreen( onSearchQueryChange: (String) -> Unit, onBack: (() -> Unit)?, modifier: Modifier = Modifier, + onOpenTemplates: () -> Unit = {}, ) { var dialog by remember { mutableStateOf(BrowserDialog.None) } @@ -175,6 +182,12 @@ fun DocumentsBrowserScreen( ) { Icon(Icons.Default.Search, contentDescription = "Search documents") } + IconButton( + onClick = onOpenTemplates, + modifier = Modifier.testTag(DocumentsBrowserTestTags.TEMPLATES_ACTION), + ) { + Icon(Icons.Default.Dashboard, contentDescription = "Templates") + } IconButton( onClick = { dialog = BrowserDialog.CreateFolder }, modifier = Modifier.testTag(DocumentsBrowserTestTags.CREATE_FOLDER), diff --git a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt index 00d6992..2e9cefa 100644 --- a/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt +++ b/feature/lists/src/main/kotlin/com/interlinedlist/android/feature/lists/ui/list/ListsScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Hub import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.Search @@ -51,6 +52,7 @@ object ListsTestTags { const val PROGRESS = "listsProgress" const val SUBSCRIPTION = "listsSubscription" const val SHARED_WITH_ME = "listsSharedWithMe" + const val FOLDERS = "listsFolders" fun row(id: String) = "listRow_$id" } @@ -64,6 +66,7 @@ fun ListsRoute( onOpenConnections: () -> Unit, modifier: Modifier = Modifier, onOpenSharedWithMe: () -> Unit = {}, + onOpenFolders: () -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -72,6 +75,7 @@ fun ListsRoute( onOpenList = onOpenList, onOpenConnections = onOpenConnections, onOpenSharedWithMe = onOpenSharedWithMe, + onOpenFolders = onOpenFolders, onSearchQueryChange = viewModel::onSearchQueryChange, onLoadMore = viewModel::loadMore, onCreateList = { title -> viewModel.createList(title, description = null, onCreated = { onOpenList(it.id) }) }, @@ -91,6 +95,7 @@ fun ListsScreen( onCreateList: (String) -> Unit, modifier: Modifier = Modifier, onOpenSharedWithMe: () -> Unit = {}, + onOpenFolders: () -> Unit = {}, ) { Scaffold( modifier = modifier.fillMaxSize(), @@ -98,6 +103,12 @@ fun ListsScreen( TopAppBar( title = { Text("Lists") }, actions = { + IconButton( + onClick = onOpenFolders, + modifier = Modifier.testTag(ListsTestTags.FOLDERS), + ) { + Icon(Icons.Default.Folder, contentDescription = "Folders") + } IconButton( onClick = onOpenSharedWithMe, modifier = Modifier.testTag(ListsTestTags.SHARED_WITH_ME),