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 @@ -351,6 +351,8 @@ private fun MainShell(
onEditSchema = { tabNav.navigate(Routes.listSchema(listId)) },
onOpenWatchers = { tabNav.navigate(Routes.listWatchers(listId)) },
onOpenShare = { tabNav.navigate(Routes.listShare(listId)) },
// Breadcrumb hops and newly created child lists open a detail route.
onOpenList = { id -> tabNav.navigate(Routes.listDetail(id)) },
)
}
composable(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ class ListDetailScreenTest {
),
)

private fun setScreen(state: ListDetailUiState, onOpenShare: () -> Unit = {}) {
private fun setScreen(
state: ListDetailUiState,
onOpenShare: () -> Unit = {},
onOpenList: (String) -> Unit = {},
onNewChildList: () -> Unit = {},
) {
composeRule.setContent {
InterlinedListTheme {
ListDetailScreen(
Expand All @@ -45,6 +50,8 @@ class ListDetailScreenTest {
onDeleteRow = {},
onDeleteList = {},
onOpenShare = onOpenShare,
onOpenList = onOpenList,
onNewChildList = onNewChildList,
)
}
}
Expand Down Expand Up @@ -101,4 +108,62 @@ class ListDetailScreenTest {
composeRule.onNodeWithTag(ListDetailTestTags.SHARE).assertIsDisplayed().performClick()
assert(shared)
}

@Test
fun rendersParentBreadcrumb_andOpensAnAncestor() {
var opened: String? = null
setScreen(
ListDetailUiState(
summary = ListSummary("L1", "Chapter 3", null, 0, null, false, null, "P1"),
schema = schema,
rows = emptyList(),
isLoading = false,
breadcrumb = listOf(
ListSummary("ROOT", "Book", null, 0, null, false, null, null),
ListSummary("P1", "Part 2", null, 0, null, false, null, "ROOT"),
),
),
onOpenList = { opened = it },
)

composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertIsDisplayed()
composeRule.onNodeWithText("Book").assertIsDisplayed()
composeRule.onNodeWithText("Part 2").assertIsDisplayed()

composeRule.onNodeWithTag(ListDetailTestTags.crumb("ROOT")).performClick()
assert(opened == "ROOT")
}

@Test
fun hidesBreadcrumb_forARootList() {
setScreen(
ListDetailUiState(
summary = ListSummary("L1", "Reading", null, 0, null, false, null),
schema = schema,
rows = emptyList(),
isLoading = false,
),
)

composeRule.onNodeWithTag(ListDetailTestTags.BREADCRUMB).assertDoesNotExist()
}

@Test
fun overflowOffersANewChildList() {
var requested = false
setScreen(
ListDetailUiState(
summary = ListSummary("L1", "Reading", null, 0, null, false, null),
schema = schema,
rows = emptyList(),
isLoading = false,
),
onNewChildList = { requested = true },
)

composeRule.onNodeWithTag(ListDetailTestTags.OVERFLOW).performClick()
composeRule.onNodeWithTag(ListDetailTestTags.NEW_CHILD).performClick()

assert(requested)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ 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.ListSource
import com.interlinedlist.android.feature.lists.domain.ListSummary
import com.interlinedlist.android.feature.lists.domain.Paged
import com.interlinedlist.android.feature.lists.domain.RefreshResult
Expand All @@ -38,6 +39,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import javax.inject.Inject

Expand Down Expand Up @@ -92,15 +94,33 @@ class DefaultListsRepository @Inject constructor(
title: String,
description: String?,
isPublic: Boolean,
parentId: String?,
folderId: String?,
messageId: String?,
initialRows: List<Map<String, String>>?,
metadata: JsonObject?,
source: ListSource?,
): ApiResult<ListSummary> = withContext(dispatchers.io) {
val body = CreateListRequest(title = title, description = description, isPublic = isPublic)
val body = CreateListRequest(
title = title,
description = description,
isPublic = isPublic,
parentId = parentId,
folderId = folderId,
messageId = messageId,
// Starter rows go out in the same shape a row write uses.
initialRows = initialRows?.map { JsonObject(it.toJsonData()) },
metadata = metadata,
source = source?.wire,
)
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,
itemCount = 0, folderId = folderId, isPublic = isPublic,
updatedAt = null, parentId = parentId,
),
)
val summary = ListMapper.summaryFromDto(dto)
Expand All @@ -111,6 +131,45 @@ class DefaultListsRepository @Inject constructor(
}
}

override suspend fun createListFromMessage(
messageId: String,
title: String,
description: String?,
): ApiResult<ListSummary> =
createList(title = title, description = description, messageId = messageId)

override suspend fun getParentChain(parentId: String): ApiResult<List<ListSummary>> =
withContext(dispatchers.io) {
val ancestors = mutableListOf<ListSummary>() // nearest parent first
val visited = mutableSetOf<String>()
// The server may inline the next level under `parent`; when it does,
// that level costs no request.
var inlined: ListDto? = null
var nextId: String? = parentId

while (ancestors.size < MAX_PARENT_CHAIN) {
// A level already seen means the tree loops: stop before spending
// another request on it.
val dto = inlined?.takeIf { visited.add(it.id) }
?: nextId?.takeIf { visited.add(it) }?.let { id ->
when (val result = safeApiCall(json) { api.getList(id) }) {
is ApiResult.Success -> result.data.list ?: result.data.data
// A level we cannot fetch truncates the breadcrumb; only a
// chain that resolved nothing at all is reported as a failure.
is ApiResult.Failure ->
if (ancestors.isEmpty()) return@withContext result else null
}
} ?: break

ancestors += ListMapper.summaryFromDto(dto)
inlined = dto.parent
nextId = dto.parentId
}

listDao.upsertAll(ancestors.map(ListMapper::summaryToEntity))
ApiResult.Success(ancestors.reversed()) // root → immediate parent
}

override suspend fun updateList(
id: String,
title: String?,
Expand Down Expand Up @@ -411,6 +470,11 @@ class DefaultListsRepository @Inject constructor(
private fun Map<String, String>.toJsonData(): Map<String, JsonElement> =
filterValues { it.isNotBlank() }
.mapValues { (_, value) -> JsonPrimitive(value) as JsonElement }

private companion object {
/** Safety net for a breadcrumb walk: deep nesting is not worth the requests. */
const val MAX_PARENT_CHAIN = 10
}
}

/** Builds a [Paged] from the response's pagination block, tolerating its absence. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ object ListMapper {
folderId = dto.folderId,
isPublic = dto.isPublic,
updatedAt = dto.updatedAt,
parentId = dto.parentId ?: dto.parent?.id,
)

fun summaryToEntity(summary: ListSummary): CachedListEntity = CachedListEntity(
Expand All @@ -31,6 +32,7 @@ object ListMapper {
folderId = summary.folderId,
isPublic = summary.isPublic,
updatedAt = summary.updatedAt,
parentId = summary.parentId,
)

fun summaryFromEntity(entity: CachedListEntity): ListSummary = ListSummary(
Expand All @@ -41,6 +43,7 @@ object ListMapper {
folderId = entity.folderId,
isPublic = entity.isPublic,
updatedAt = entity.updatedAt,
parentId = entity.parentId,
)

fun folderFromDto(dto: FolderDto): ListFolder = ListFolder(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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.ListSource
import com.interlinedlist.android.feature.lists.domain.ListSummary
import com.interlinedlist.android.feature.lists.domain.Paged
import com.interlinedlist.android.feature.lists.domain.RefreshResult
Expand All @@ -18,6 +19,7 @@ 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.serialization.json.JsonObject

/**
* Offline-first access to the Lists domain. The index streams from Room (the
Expand All @@ -38,8 +40,54 @@ interface ListsRepository {
/** Server-side search (not cached) by title/description. */
suspend fun searchLists(query: String, limit: Int = DEFAULT_PAGE_SIZE): ApiResult<List<ListSummary>>

/** Creates a list; caches the result and returns its summary. */
suspend fun createList(title: String, description: String?, isPublic: Boolean): ApiResult<ListSummary>
/**
* Creates a list; caches the result and returns its summary.
*
* Everything past [isPublic] is an optional creation option the API accepts
* and is omitted from the request when null, so callers that only need a
* title/description send exactly what they always sent:
* - [parentId] nests the new list under an existing one (see [getParentChain]).
* - [folderId] files it into a folder.
* - [messageId] records the message the list was created from.
* - [initialRows] seeds starter rows, each a column-key → value map, exactly
* as [addRow] sends a row.
* - [metadata] is a free-form JSON object passed through untouched.
* - [source] marks where the rows come from (defaults to the server's own).
*/
suspend fun createList(
title: String,
description: String? = null,
isPublic: Boolean = false,
parentId: String? = null,
folderId: String? = null,
messageId: String? = null,
initialRows: List<Map<String, String>>? = null,
metadata: JsonObject? = null,
source: ListSource? = null,
): ApiResult<ListSummary>

/**
* Creates a list from a message: the message becomes the new list's
* description and the API keeps the link via `messageId`.
*
* The entry point that offers this on a message lives in `:feature:messages`
* (see the cross-object "create from" work); this is the call it makes.
*/
suspend fun createListFromMessage(
messageId: String,
title: String,
description: String?,
): ApiResult<ListSummary>

/**
* Resolves a list's ancestry for breadcrumb navigation: fetches [parentId]
* and keeps walking up, returning the chain ordered root → immediate parent.
*
* A level the server cannot serve truncates the chain rather than failing,
* since a breadcrumb is navigation decoration; a failure is only reported
* when nothing at all could be resolved.
*/
suspend fun getParentChain(parentId: String): ApiResult<List<ListSummary>>

/**
* Updates a list's metadata (title/description/visibility/folder). Only the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,6 @@ data class CachedListEntity(
val folderId: String?,
val isPublic: Boolean,
val updatedAt: String?,
/** Parent list id — kept so a cached list still knows where it sits in the tree. */
val parentId: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import androidx.room.RoomDatabase
*/
@Database(
entities = [CachedListEntity::class],
version = 1,
// v2 adds CachedListEntity.parentId (list tree / breadcrumb).
version = 2,
exportSchema = false,
)
abstract class ListsDatabase : RoomDatabase() {
Expand Down
Loading
Loading