Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import com.interlinedlist.android.feature.documents.sync.DocumentsSyncScheduler
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.powered.PoweredDocumentRoute
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
Expand Down Expand Up @@ -112,6 +113,7 @@ object Routes {
const val DOCUMENT_FOLDER = "documents/folder/{folderId}"
const val DOCUMENT_EDITOR = "documents/editor/{documentId}"
const val DOCUMENT_TEMPLATES = "documents/templates"
const val DOCUMENT_POWERED = "documents/powered"

// Documents sharing (Milestone F).
const val DOCUMENT_SHARE = "documents/{documentId}/share"
Expand Down Expand Up @@ -429,6 +431,18 @@ private fun MainShell(
onOpenFolder = { id -> tabNav.navigate(Routes.documentFolder(id)) },
onOpenDocument = { id -> tabNav.navigate(Routes.documentEditor(id)) },
onOpenTemplates = { tabNav.navigate(Routes.DOCUMENT_TEMPLATES) },
onOpenPoweredDocument = { tabNav.navigate(Routes.DOCUMENT_POWERED) },
)
}
composable(Routes.DOCUMENT_POWERED) {
// A saved draft replaces this screen with the new document, so the
// Powered Document form is not left on the back stack behind it.
PoweredDocumentRoute(
onOpenDocument = { id ->
tabNav.popBackStack()
tabNav.navigate(Routes.documentEditor(id))
},
onBack = { tabNav.popBackStack() },
)
}
composable(Routes.DOCUMENT_TEMPLATES) {
Expand Down
6 changes: 6 additions & 0 deletions feature/documents/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ dependencies {
implementation(project(":core:designsystem"))
implementation(project(":core:network"))
implementation(project(":core:datastore"))
// The AI capability module: the `/api/ai/*` client, the availability gate and
// the preview -> confirm contract. `:feature:ai` is a leaf that depends only on
// `:core:*` and owns no navigation of its own -- its surfaces live in the
// feature modules that use them, which is what the Powered Document entry
// point on this surface is. No other feature module is depended on.
implementation(project(":feature:ai"))

implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.interlinedlist.android.feature.documents.ui

import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
Expand Down Expand Up @@ -44,6 +46,7 @@ class DocumentsBrowserScreenTest {
onCreateFolder: (String) -> Unit = {},
onSearchQueryChange: (String) -> Unit = {},
onBack: (() -> Unit)? = null,
onOpenPoweredDocument: () -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
Expand All @@ -61,11 +64,35 @@ class DocumentsBrowserScreenTest {
onCloseSearch = {},
onSearchQueryChange = onSearchQueryChange,
onBack = onBack,
onOpenPoweredDocument = onOpenPoweredDocument,
)
}
}
}

@Test
fun poweredDocumentAction_isHidden_whenAiIsNotAvailable() {
setContent(
DocumentsBrowserUiState(isLoading = false, contents = rootContents(), isAiEnabled = false),
)
composeRule.onAllNodesWithTag(DocumentsBrowserTestTags.POWERED_DOCUMENT_ACTION)
.assertCountEquals(0)
}

@Test
fun poweredDocumentAction_opensTheSurface_whenAiIsAvailable() {
var opened = false
setContent(
state = DocumentsBrowserUiState(isLoading = false, contents = rootContents(), isAiEnabled = true),
onOpenPoweredDocument = { opened = true },
)

composeRule.onNodeWithTag(DocumentsBrowserTestTags.POWERED_DOCUMENT_ACTION)
.assertIsDisplayed()
.performClick()
assert(opened)
}

@Test
fun emptyState_isShown_whenFolderIsEmpty() {
setContent(DocumentsBrowserUiState(isLoading = false, contents = rootContents()))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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.map
import com.interlinedlist.android.core.network.error.safeApiCall
import com.interlinedlist.android.feature.documents.data.remote.ListSourcesApi
import com.interlinedlist.android.feature.documents.domain.ListSource
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import javax.inject.Inject

/**
* Read-only access to the user's lists, used solely to populate the "Derived
* From List" source picker. Nothing is cached: the picker is opened rarely and a
* list created moments ago must show up.
*/
interface ListSourcesRepository {

/** The user's lists as pickable sources. */
suspend fun getListSources(): ApiResult<List<ListSource>>
}

class DefaultListSourcesRepository @Inject constructor(
private val api: ListSourcesApi,
private val json: Json,
private val dispatchers: DispatcherProvider,
) : ListSourcesRepository {

override suspend fun getListSources(): ApiResult<List<ListSource>> =
withContext(dispatchers.io) {
safeApiCall(json) { api.getLists(PICKER_LIMIT) }.map { response ->
response.listsOrEmpty.mapNotNull { dto ->
val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
ListSource(
id = id,
title = dto.title?.takeIf { it.isNotBlank() } ?: "Untitled list",
description = dto.description?.takeIf { it.isNotBlank() },
)
}
}
}

private companion object {
/** A picker, not a browser — one page of lists is plenty. */
const val PICKER_LIMIT = 100
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.interlinedlist.android.feature.documents.data.remote

import com.interlinedlist.android.feature.documents.data.remote.dto.ListSourcesResponse
import retrofit2.http.GET
import retrofit2.http.Query

/**
* The one lists endpoint this module needs: the picker behind the "Derived From
* List" Powered Document mode.
*
* It is declared here, and not imported from `:feature:lists`, because no feature
* module in this repo depends on another. Each feature owns its own Retrofit
* interface over the shared authed Retrofit, so reading `/api/lists` from the
* documents module costs one read-only call and keeps the module graph flat.
*/
interface ListSourcesApi {

/** The user's lists, newest first, capped at [limit]. */
@GET("api/lists")
suspend fun getLists(@Query("limit") limit: Int): ListSourcesResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.interlinedlist.android.feature.documents.data.remote.dto

import kotlinx.serialization.Serializable

/**
* A row of `GET /api/lists`, narrowed to the fields the Powered Document list
* picker shows. The endpoint returns a lot more (folder, github, parent chain);
* everything unmodelled is ignored.
*/
@Serializable
data class ListSourceDto(
val id: String? = null,
val title: String? = null,
val description: String? = null,
)

/** `GET /api/lists` → `{ lists: [...], pagination: { … } }`. */
@Serializable
data class ListSourcesResponse(
val lists: List<ListSourceDto>? = null,
) {
val listsOrEmpty: List<ListSourceDto> get() = lists.orEmpty()
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ 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.DefaultListSourcesRepository
import com.interlinedlist.android.feature.documents.data.DocumentsRepository
import com.interlinedlist.android.feature.documents.data.ListSourcesRepository
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 com.interlinedlist.android.feature.documents.data.remote.ListSourcesApi
import dagger.Binds
import dagger.Module
import dagger.Provides
Expand All @@ -27,6 +30,10 @@ abstract class DocumentsRepositoryModule {
@Binds
@Singleton
abstract fun bindDocumentsRepository(impl: DefaultDocumentsRepository): DocumentsRepository

@Binds
@Singleton
abstract fun bindListSourcesRepository(impl: DefaultListSourcesRepository): ListSourcesRepository
}

/** Provides this feature's API, its own Room database, and DAOs. */
Expand All @@ -39,6 +46,12 @@ object DocumentsDataModule {
fun provideDocumentsApi(retrofit: Retrofit): DocumentsApi =
retrofit.create(DocumentsApi::class.java)

/** Read-only `/api/lists` access for the Powered Document list-source picker. */
@Provides
@Singleton
fun provideListSourcesApi(retrofit: Retrofit): ListSourcesApi =
retrofit.create(ListSourcesApi::class.java)

@Provides
@Singleton
fun provideDocumentsDatabase(@ApplicationContext context: Context): DocumentsDatabase =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.interlinedlist.android.feature.documents.domain

/**
* One of the signed-in user's lists, reduced to what the "Derived From List"
* Powered Document mode needs: something to show in the picker and the `listId`
* the AI endpoint resolves server-side.
*
* Only the id is sent — `/api/ai/suggest` loads the list's schema and rows itself
* under the owning user, so the client never ships list content.
*/
data class ListSource(
val id: String,
val title: String,
val description: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ 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.AutoAwesome
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.CreateNewFolder
import androidx.compose.material.icons.filled.Dashboard
Expand Down Expand Up @@ -66,6 +67,7 @@ object DocumentsBrowserTestTags {
const val CREATE_FOLDER = "browserCreateFolder"
const val SEARCH_ACTION = "browserSearchAction"
const val TEMPLATES_ACTION = "browserTemplatesAction"
const val POWERED_DOCUMENT_ACTION = "browserPoweredDocumentAction"
const val SEARCH_FIELD = "browserSearchField"
const val SEARCH_RESULTS = "browserSearchResults"
const val BREADCRUMB = "browserBreadcrumb"
Expand All @@ -91,6 +93,7 @@ fun DocumentsRoute(
onOpenDocument: (String) -> Unit,
modifier: Modifier = Modifier,
onOpenTemplates: () -> Unit = {},
onOpenPoweredDocument: () -> Unit = {},
viewModel: DocumentsBrowserViewModel = hiltViewModel(),
) {
DocumentsFolderRoute(
Expand All @@ -99,6 +102,7 @@ fun DocumentsRoute(
onBack = null,
modifier = modifier,
onOpenTemplates = onOpenTemplates,
onOpenPoweredDocument = onOpenPoweredDocument,
viewModel = viewModel,
)
}
Expand All @@ -114,6 +118,7 @@ fun DocumentsFolderRoute(
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
onOpenTemplates: () -> Unit = {},
onOpenPoweredDocument: () -> Unit = {},
viewModel: DocumentsBrowserViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Expand All @@ -132,6 +137,7 @@ fun DocumentsFolderRoute(
onSearchQueryChange = viewModel::onSearchQueryChange,
onBack = onBack,
onOpenTemplates = onOpenTemplates,
onOpenPoweredDocument = onOpenPoweredDocument,
modifier = modifier,
)
}
Expand All @@ -154,6 +160,7 @@ fun DocumentsBrowserScreen(
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
onOpenTemplates: () -> Unit = {},
onOpenPoweredDocument: () -> Unit = {},
) {
var dialog by remember { mutableStateOf<BrowserDialog>(BrowserDialog.None) }

Expand Down Expand Up @@ -188,6 +195,17 @@ fun DocumentsBrowserScreen(
) {
Icon(Icons.Default.Dashboard, contentDescription = "Templates")
}
// Drawn only when the AI gate says this account may use AI, so a
// free account is never offered a control that would fail.
if (state.isAiEnabled) {
IconButton(
onClick = onOpenPoweredDocument,
modifier = Modifier
.testTag(DocumentsBrowserTestTags.POWERED_DOCUMENT_ACTION),
) {
Icon(Icons.Default.AutoAwesome, contentDescription = "Powered Document")
}
}
IconButton(
onClick = { dialog = BrowserDialog.CreateFolder },
modifier = Modifier.testTag(DocumentsBrowserTestTags.CREATE_FOLDER),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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.ai.domain.AiGate
import com.interlinedlist.android.feature.documents.data.DocumentsRepository
import com.interlinedlist.android.feature.documents.domain.Document
import com.interlinedlist.android.feature.documents.domain.FolderContents
Expand Down Expand Up @@ -39,6 +40,12 @@ data class DocumentsBrowserUiState(
val isSearchActive: Boolean = false,
val isSearching: Boolean = false,
val searchResults: List<Document> = emptyList(),
/**
* Whether the Powered Document control may be drawn. False unless
* `GET /api/ai/status` says this account may use AI, so a free account — or a
* deployment with no provider configured — never sees the entry point at all.
*/
val isAiEnabled: Boolean = false,
) {
val isEmpty: Boolean get() = contents.isEmpty && !isLoading

Expand All @@ -62,6 +69,7 @@ data class DocumentsBrowserUiState(
@HiltViewModel
class DocumentsBrowserViewModel @Inject constructor(
private val repository: DocumentsRepository,
private val aiGate: AiGate,
savedStateHandle: SavedStateHandle,
) : ViewModel() {

Expand All @@ -77,6 +85,7 @@ class DocumentsBrowserViewModel @Inject constructor(
init {
observeContents()
observeFolders()
observeAiAvailability()
refresh()
}

Expand All @@ -96,6 +105,20 @@ class DocumentsBrowserViewModel @Inject constructor(
}
}

/**
* The AI gate decides whether the Powered Document control exists. It is
* resolved once per app session and shared by every AI surface, so opening the
* Documents tab costs at most one `/api/ai/status` read.
*/
private fun observeAiAvailability() {
viewModelScope.launch { aiGate.ensureResolved() }
viewModelScope.launch {
aiGate.availability.collect { availability ->
_uiState.update { it.copy(isAiEnabled = availability.isEnabled) }
}
}
}

/** Pulls the whole tree from the API into Room; the observers render the result. */
fun refresh() {
_uiState.update {
Expand Down
Loading
Loading