From 87a8cc7c244b21b84da56ea9c458da99a4e12c96 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 29 Jul 2026 14:59:25 +0200 Subject: [PATCH 1/3] feat(dynamic-ui): add adaptive collection navigation --- .../250-adaptive-collection-navigation.md | 7 + .../app/MarketingCaptureScenarios.kt | 77 +++++++ .../nextcloudnative/app/NextcloudNativeApp.kt | 68 ++++-- .../design/NextcloudCollectionNavigator.kt | 139 +++++++++++++ .../app/design/NextcloudIcons.kt | 4 + .../nativeui/runtime/GenericNativeRenderer.kt | 161 +++++++++++++- .../nativeui/runtime/NativeDatasetFacets.kt | 196 ++++++++++++++++++ .../NextcloudCollectionNavigatorTest.kt | 21 ++ .../runtime/NativeDatasetFacetsTest.kt | 163 +++++++++++++++ 9 files changed, 814 insertions(+), 22 deletions(-) create mode 100644 changes/unreleased/250-adaptive-collection-navigation.md create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt create mode 100644 ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacets.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt create mode 100644 ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacetsTest.kt diff --git a/changes/unreleased/250-adaptive-collection-navigation.md b/changes/unreleased/250-adaptive-collection-navigation.md new file mode 100644 index 00000000..a22c54aa --- /dev/null +++ b/changes/unreleased/250-adaptive-collection-navigation.md @@ -0,0 +1,7 @@ +category: feature +issue: 250 +pull: none +platforms: android, desktop +user-facing: yes + +Dynamic collections now provide adaptive navigation, scoped search and filters, deterministic sorting, and a clear no-results state without carrying browse choices into another account or parent collection. diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt index 198a4dc3..12e0841d 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/MarketingCaptureScenarios.kt @@ -20,6 +20,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.unit.dp +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionNavigationItem +import dev.obiente.nextcloudnative.app.design.NextcloudCompactCollectionNavigator import dev.obiente.nextcloudnative.app.design.NextcloudPresentation import dev.obiente.nextcloudnative.app.design.NextcloudSpacing import dev.obiente.nextcloudnative.nativeui.model.AppIdentity @@ -88,6 +90,20 @@ enum class MarketingCaptureScenario( "Dynamic apps", "Data table", "Ready", MarketingCapturePurpose.Showcase, "mobile", "phone-portrait", width = 1_080, height = 1_800, density = 2.625f, ), + AdaptiveCollectionNavigationMobile( + "adaptive-collection-navigation-mobile", + "adaptive-collection-navigation-mobile.png", + NextcloudPresentation.Adaptive, + "Dynamic apps", + "Collection navigation", + "Ready", + MarketingCapturePurpose.Showcase, + "mobile", + "phone-portrait", + width = 1_080, + height = 1_800, + density = 2.625f, + ), PhotoTimelineRevalidationErrorMobile( "photo-timeline-revalidation-error-mobile", "photo-timeline-revalidation-error-mobile.png", @@ -702,6 +718,67 @@ internal fun MarketingAdaptiveAppScenario(scenario: MarketingCaptureScenario) { } } +/** + * Exercises the same generic compact collection selector used by dynamic-app hosts, without + * loading an account, contract, or app-specific fixture. + */ +@Composable +internal fun MarketingAdaptiveCollectionNavigationScenario(scenario: MarketingCaptureScenario) { + require(scenario == MarketingCaptureScenario.AdaptiveCollectionNavigationMobile) { + "${scenario.id} is not an adaptive collection-navigation capture." + } + val schema = marketingAdaptiveSchema + val view = schema.views.single() + Column(modifier = Modifier.fillMaxSize()) { + DynamicAppChromeHeader( + title = schema.app.name, + subtitle = "Collections", + onBack = {}, + compact = true, + onContractInfo = {}, + ) + NextcloudCompactCollectionNavigator( + items = listOf( + NextcloudCollectionNavigationItem( + id = "inventory", + label = "Inventory", + selected = true, + onSelect = {}, + ), + NextcloudCollectionNavigationItem( + id = "loans", + label = "Loans", + selected = false, + onSelect = {}, + ), + NextcloudCollectionNavigationItem( + id = "suppliers", + label = "Suppliers", + selected = false, + onSelect = {}, + ), + ), + modifier = Modifier.fillMaxWidth().padding( + start = NextcloudSpacing.Large, + end = NextcloudSpacing.Small, + top = NextcloudSpacing.Small, + bottom = NextcloudSpacing.Small, + ), + label = "Collection", + ) + GenericNativeAppScreen( + schema = schema, + view = view, + state = NativeScreenState.Ready(marketingAdaptiveRecords), + actionExecutor = NativeActionExecutor { + NativeActionExecutionResult.Failure("This fixture is read-only.") + }, + onSelectRecord = {}, + modifier = Modifier.weight(1f), + ) + } +} + @Composable internal fun MarketingHomeDashboardScenario( scenario: MarketingCaptureScenario, diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3641dc61..3ee8c3d7 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -93,6 +93,10 @@ import com.mikepenz.markdown.m3.Markdown import dev.obiente.nextcloudnative.app.design.NextcloudAppBackground import dev.obiente.nextcloudnative.app.design.NextcloudAppTile import dev.obiente.nextcloudnative.app.design.NextcloudBottomNavigation +import dev.obiente.nextcloudnative.app.design.NextcloudCollectionNavigationItem +import dev.obiente.nextcloudnative.app.design.NextcloudCompactCollectionNavigator +import dev.obiente.nextcloudnative.app.design.shouldShowCompactCollectionNavigator +import dev.obiente.nextcloudnative.app.design.shouldUseCompactCollectionNavigation import dev.obiente.nextcloudnative.app.design.NextcloudDestination import dev.obiente.nextcloudnative.app.design.NextcloudIcons import dev.obiente.nextcloudnative.app.design.NextcloudNativeTheme @@ -436,6 +440,8 @@ fun NextcloudNativeMarketingCapture( MarketingCaptureScenario.AdaptiveApp, MarketingCaptureScenario.AdaptiveAppMobile, -> MarketingAdaptiveAppScenario(scenario) + MarketingCaptureScenario.AdaptiveCollectionNavigationMobile -> + MarketingAdaptiveCollectionNavigationScenario(scenario) MarketingCaptureScenario.PhotoTimelineRevalidationErrorMobile, MarketingCaptureScenario.PhotoTimelineReturnToNewestErrorMobile, MarketingCaptureScenario.PhotoTimelineRawRetryMobile, @@ -2152,6 +2158,7 @@ private fun DynamicDiscoveredAppScreen( BoxWithConstraints(modifier = modifier.fillMaxSize()) { val compactLandscape = shouldUseCompactDynamicAppChrome(maxWidth.value, maxHeight.value) + val compactCollectionNavigation = shouldUseCompactCollectionNavigation(maxWidth.value) Column(modifier = Modifier.fillMaxSize()) { DynamicAppChromeHeader( title = descriptor.app.name, @@ -2226,25 +2233,50 @@ private fun DynamicDiscoveredAppScreen( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - LazyRow( - modifier = Modifier.weight(1f), - contentPadding = PaddingValues( - start = NextcloudSpacing.Large, - end = NextcloudSpacing.Small, - top = NextcloudSpacing.Small, - bottom = NextcloudSpacing.Small, - ), - horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + if ( + compactCollectionNavigation && + shouldShowCompactCollectionNavigator(primaryNavigationDestinations.size) ) { - listItems(primaryNavigationDestinations, key = { (_, view) -> view.id }) { (destination, view) -> - FilterChip( - selected = view.id == selectedView.id, - onClick = { - selectedPathParameterValues = destination.pathParameterValues - selectedViewId = view.id - }, - label = { Text(destination.label.dynamicUiLabel(descriptor.app.name)) }, - ) + NextcloudCompactCollectionNavigator( + items = primaryNavigationDestinations.map { (destination, view) -> + NextcloudCollectionNavigationItem( + id = view.id, + label = destination.label.dynamicUiLabel(descriptor.app.name), + selected = view.id == selectedView.id, + onSelect = { + selectedPathParameterValues = destination.pathParameterValues + selectedViewId = view.id + }, + ) + }, + modifier = Modifier.weight(1f).padding( + start = NextcloudSpacing.Large, + top = NextcloudSpacing.Small, + bottom = NextcloudSpacing.Small, + ), + label = "Collection", + ) + } else { + LazyRow( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues( + start = NextcloudSpacing.Large, + end = NextcloudSpacing.Small, + top = NextcloudSpacing.Small, + bottom = NextcloudSpacing.Small, + ), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + listItems(primaryNavigationDestinations, key = { (_, view) -> view.id }) { (destination, view) -> + FilterChip( + selected = view.id == selectedView.id, + onClick = { + selectedPathParameterValues = destination.pathParameterValues + selectedViewId = view.id + }, + label = { Text(destination.label.dynamicUiLabel(descriptor.app.name)) }, + ) + } } } if (secondaryNavigationDestinations.isNotEmpty()) { diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt new file mode 100644 index 00000000..85fb2e2c --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigator.kt @@ -0,0 +1,139 @@ +package dev.obiente.nextcloudnative.app.design + +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +data class NextcloudCollectionNavigationItem( + val id: String, + val label: String, + val count: Int? = null, + val icon: ImageVector? = null, + val selected: Boolean, + val onSelect: () -> Unit, +) + +internal fun shouldUseCompactCollectionNavigation(widthDp: Float): Boolean = widthDp < 720f + +/** A selector is useful only when it lets the user choose among multiple destinations. */ +internal fun shouldShowCompactCollectionNavigator(itemCount: Int): Boolean = itemCount > 1 + +/** + * Compact collection navigation for narrow workspaces. + * + * A single, full-width selector preserves reading space and keeps every collection reachable + * without relying on a horizontally scrolling row of chips. + */ +@Composable +fun NextcloudCompactCollectionNavigator( + items: List, + modifier: Modifier = Modifier, + label: String = "View", +) { + if (items.isEmpty()) return + var expanded by remember(items.map(NextcloudCollectionNavigationItem::id)) { mutableStateOf(false) } + val selected = items.firstOrNull(NextcloudCollectionNavigationItem::selected) ?: items.first() + Box(modifier = modifier) { + Surface( + modifier = Modifier.fillMaxWidth().clickable { expanded = true }, + color = MaterialTheme.colorScheme.surfaceContainerLow, + shape = RoundedCornerShape(NextcloudRadii.Card), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding( + horizontal = NextcloudSpacing.Medium, + vertical = NextcloudSpacing.Small, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Medium), + ) { + selected.icon?.let { icon -> + Icon(icon, contentDescription = null, modifier = Modifier.size(22.dp)) + } + Column(modifier = Modifier.weight(1f)) { + Text( + label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + selected.label, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + selected.count?.let { count -> + Text( + count.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + NextcloudIcons.ExpandMore, + contentDescription = "Choose $label", + modifier = Modifier.size(22.dp), + ) + } + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.fillMaxWidth(0.92f), + ) { + items.forEach { item -> + DropdownMenuItem( + leadingIcon = item.icon?.let { icon -> + { Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp)) } + }, + text = { + Text( + item.label, + fontWeight = if (item.selected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + trailingIcon = item.count?.let { count -> + { + Text( + count.toString(), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + onClick = { + expanded = false + item.onSelect() + }, + ) + } + } + } +} diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt index 40f56c5a..7fae1ed1 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudIcons.kt @@ -29,6 +29,8 @@ import androidx.compose.material.icons.outlined.DragIndicator import androidx.compose.material.icons.outlined.Email import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.FilterList import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.FolderOpen import androidx.compose.material.icons.outlined.FormatBold @@ -101,6 +103,8 @@ object NextcloudIcons { val Video: ImageVector = Icons.Outlined.VideoLibrary val ListView: ImageVector = Icons.AutoMirrored.Outlined.ViewList val Edit: ImageVector = Icons.Outlined.Edit + val Filter: ImageVector = Icons.Outlined.FilterList + val ExpandMore: ImageVector = Icons.Outlined.ExpandMore val Save: ImageVector = Icons.Outlined.Save val Info: ImageVector = Icons.Outlined.Info val CheckCircle: ImageVector = Icons.Outlined.CheckCircle diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt index a8f3c7d1..29e2f851 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/GenericNativeRenderer.kt @@ -496,6 +496,33 @@ private fun GenericTableCollection( nativeTableProjection(resource, records, columnResource, columnRecords, composite) } val insights = remember(projection) { nativeDatasetInsights(projection.resource, projection.records) } + val facets = remember(projection) { inferNativeDatasetFacets(projection.resource, projection.records) } + val browseStateKey = remember(schema, view, projection.resource, datasetContext) { + nativeDatasetBrowseStateKey(schema, view, projection.resource, datasetContext) + } + var facetSelections by remember(browseStateKey) { mutableStateOf>>(emptyMap()) } + var searchQuery by remember(browseStateKey) { mutableStateOf("") } + var sortMode by remember(browseStateKey) { mutableStateOf(NativeDatasetSortMode.Server) } + var filtersExpanded by remember(browseStateKey) { mutableStateOf(false) } + val filteredRecords = remember(projection.records, facetSelections, searchQuery, sortMode) { + browseNativeDatasetRecords( + resource = projection.resource, + records = projection.records, + selections = facetSelections, + searchQuery = searchQuery, + sortMode = sortMode, + ) + } + + fun toggleFacet(fieldId: String, value: String) { + val nextValues = facetSelections[fieldId].orEmpty().toMutableSet().apply { + if (!add(value)) remove(value) + } + facetSelections = facetSelections.toMutableMap().apply { + if (nextValues.isEmpty()) remove(fieldId) else put(fieldId, nextValues) + } + } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val compactRecordList = shouldUseCompactTableRecordList(maxWidth.value) val expandInsights = datasetInsightsDefaultExpanded(maxWidth.value, maxHeight.value) @@ -505,15 +532,36 @@ private fun GenericTableCollection( insights = it, compact = !expandInsights, initiallyExpanded = expandInsights, - stateKey = "table:${resource.id}", + stateKey = "table-insights:$browseStateKey", ) } - if (compactRecordList) { + NativeTableBrowseControls( + searchQuery = searchQuery, + onSearchQueryChanged = { searchQuery = it }, + facets = facets, + selections = facetSelections, + filtersExpanded = filtersExpanded, + onFiltersExpandedChange = { filtersExpanded = it }, + onToggleFacet = ::toggleFacet, + onClearFilters = { facetSelections = emptyMap() }, + sortMode = sortMode, + onSortModeChanged = { sortMode = it }, + ) + if (filteredRecords.isEmpty()) { + GenericCenteredState { + Text("No matching records", style = MaterialTheme.typography.titleMedium) + Text( + "Clear or adjust the current search and filters to see more records.", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + } else if (compactRecordList) { GenericEditableTableRecordList( schema = schema, sourceResource = resource, projection = projection, - records = projection.records, + records = filteredRecords, onSelectRecord = onSelectRecord, actionExecutor = actionExecutor, onInlineActionSucceeded = onInlineActionSucceeded, @@ -524,7 +572,7 @@ private fun GenericTableCollection( schema, view, resource, - records, + filteredRecords, datasetContext, actionExecutor, onSelectRecord, @@ -536,6 +584,111 @@ private fun GenericTableCollection( } } +@Composable +private fun NativeTableBrowseControls( + searchQuery: String, + onSearchQueryChanged: (String) -> Unit, + facets: List, + selections: Map>, + filtersExpanded: Boolean, + onFiltersExpandedChange: (Boolean) -> Unit, + onToggleFacet: (fieldId: String, value: String) -> Unit, + onClearFilters: () -> Unit, + sortMode: NativeDatasetSortMode, + onSortModeChanged: (NativeDatasetSortMode) -> Unit, +) { + val activeFilterCount = selections.values.sumOf(Set::size) + val sortModes = NativeDatasetSortMode.entries + Column( + modifier = Modifier.fillMaxWidth().padding( + horizontal = NextcloudSpacing.Large, + vertical = NextcloudSpacing.Small, + ), + verticalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = onSearchQueryChanged, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + label = { Text("Search records") }, + leadingIcon = { Icon(NextcloudIcons.Search, contentDescription = null) }, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(NextcloudSpacing.Small), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + OutlinedButton( + enabled = facets.isNotEmpty(), + onClick = { onFiltersExpandedChange(!filtersExpanded) }, + ) { + Icon(NextcloudIcons.Filter, contentDescription = null, modifier = Modifier.size(18.dp)) + Text( + if (activeFilterCount == 0) "Filter" else "Filter ($activeFilterCount)", + modifier = Modifier.padding(start = NextcloudSpacing.XSmall), + ) + } + DropdownMenu( + expanded = filtersExpanded, + onDismissRequest = { onFiltersExpandedChange(false) }, + ) { + facets.forEachIndexed { index, facet -> + Text( + facet.field.label, + modifier = Modifier.padding( + start = NextcloudSpacing.Large, + top = if (index == 0) NextcloudSpacing.Small else NextcloudSpacing.Medium, + end = NextcloudSpacing.Large, + bottom = NextcloudSpacing.XSmall, + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + facet.options.forEach { option -> + val selected = option.value in selections[facet.field.id].orEmpty() + DropdownMenuItem( + text = { Text("${option.label} (${option.count})") }, + trailingIcon = if (selected) { + { + Icon( + NextcloudIcons.CheckCircle, + contentDescription = "Selected", + modifier = Modifier.size(18.dp), + ) + } + } else { + null + }, + onClick = { onToggleFacet(facet.field.id, option.value) }, + ) + } + } + if (activeFilterCount > 0) { + DropdownMenuItem( + text = { Text("Clear filters") }, + onClick = { + onClearFilters() + onFiltersExpandedChange(false) + }, + ) + } + } + } + OutlinedButton( + onClick = { + val next = (sortModes.indexOf(sortMode) + 1) % sortModes.size + onSortModeChanged(sortModes[next]) + }, + ) { + Text(sortMode.label) + } + } + } +} + @Composable private fun GenericTableHeaderCell(field: FieldSpec, width: androidx.compose.ui.unit.Dp) { Box( diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacets.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacets.kt new file mode 100644 index 00000000..c9f08f41 --- /dev/null +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacets.kt @@ -0,0 +1,196 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec + +internal data class NativeDatasetFacet( + val field: FieldSpec, + val options: List, +) + +internal data class NativeDatasetFacetOption( + val value: String, + val label: String, + val count: Int, +) + +internal enum class NativeDatasetSortMode(val label: String) { + Server("Server order"), + NameAscending("Name A-Z"), + NameDescending("Name Z-A"), +} + +/** + * Returns an opaque, deterministic identity for ephemeral collection-browsing state. + * + * Search, filter, and sort choices belong to one concrete rendered dataset, not merely a resource + * name. Dynamic apps can reuse resource IDs across views or nested parent scopes, so keeping that + * state by [ResourceSpec.id] alone can apply one collection's choices to another collection. The + * key incorporates the declared schema, view, displayed resource, and verified parent scope while + * hashing the parts so no raw parent record identity appears in the composable state key. + */ +internal fun nativeDatasetBrowseStateKey( + schema: NativeAppSchema, + view: ViewSpec, + resource: ResourceSpec, + datasetContext: NativeDatasetContext, +): String = "dataset-browse:${nativeDatasetStateScopeDigest( + listOf( + schema.schemaVersion, + schema.app.id, + schema.app.version, + view.id, + view.resourceId, + view.sourceActionId, + resource.id, + datasetContext.parentResourceId.orEmpty(), + datasetContext.parentRecord?.id.orEmpty(), + ), +)}" + +private fun nativeDatasetStateScopeDigest(parts: List): String { + var hash = 0xcbf29ce484222325UL + + fun mix(text: String) { + text.forEach { character -> + hash = hash xor character.code.toULong() + hash *= 0x100000001b3UL + } + } + + parts.forEach { part -> + mix(part.length.toString()) + mix(":") + mix(part) + mix(";") + } + return hash.toString(16) +} + +/** + * Infers bounded collection facets from the schema and rows already loaded for display. + * + * Enumeration/boolean fields are explicit evidence. String fields are admitted only when their + * field name has known grouping semantics. High-cardinality and singleton fields are excluded so + * arbitrary names, identifiers, and free text never become noisy navigation. + */ +internal fun inferNativeDatasetFacets( + resource: ResourceSpec, + records: List, + maximumFacets: Int = 3, + maximumOptions: Int = 8, +): List { + if (maximumFacets <= 0 || maximumOptions < 2 || records.size < 2) return emptyList() + return resource.fields.asSequence() + .filter(FieldSpec::canDefineNativeDatasetFacet) + .mapNotNull { field -> + val values = records.mapNotNull { record -> + record.presentationValue(field.id)?.trim()?.takeIf(String::isNotBlank) + } + val counts = linkedMapOf() + values.forEach { value -> counts[value] = counts.getOrElse(value) { 0 } + 1 } + if (counts.size !in 2..maximumOptions) return@mapNotNull null + if (field.kind !in setOf(FieldKind.enumeration, FieldKind.boolean) && counts.size == records.size) { + return@mapNotNull null + } + NativeDatasetFacet( + field = field, + options = counts.map { (value, count) -> + NativeDatasetFacetOption( + value = value, + label = formatNativeField(field, value).displayValue, + count = count, + ) + }, + ) + } + .sortedByDescending { facet -> facet.field.nativeDatasetFacetPriority() } + .take(maximumFacets) + .toList() +} + +internal fun filterNativeDatasetRecords( + records: List, + selections: Map>, +): List { + val active = selections.filterValues(Set::isNotEmpty) + if (active.isEmpty()) return records + return records.filter { record -> + active.all { (fieldId, acceptedValues) -> + record.presentationValue(fieldId)?.trim() in acceptedValues + } + } +} + +/** + * Derives the currently visible rows from data that has already been loaded. + * + * This intentionally has no transport, cache, or mutation dependency: table browsing remains + * responsive without changing the server query or writing user data. + */ +internal fun browseNativeDatasetRecords( + resource: ResourceSpec, + records: List, + selections: Map> = emptyMap(), + searchQuery: String = "", + sortMode: NativeDatasetSortMode = NativeDatasetSortMode.Server, +): List { + val faceted = filterNativeDatasetRecords(records, selections) + val searched = searchQuery.trim().takeIf(String::isNotBlank)?.let { query -> + faceted.filter { record -> + nativeRecordPresentation(resource, record).title.contains(query, ignoreCase = true) || + resource.fields.any { field -> + record.presentationValue(field.id)?.contains(query, ignoreCase = true) == true + } + } + } ?: faceted + val alphabetical = compareBy( + { nativeRecordPresentation(resource, it).title.lowercase() }, + NativeRecord::id, + ) + return when (sortMode) { + NativeDatasetSortMode.Server -> searched + NativeDatasetSortMode.NameAscending -> searched.sortedWith(alphabetical) + NativeDatasetSortMode.NameDescending -> searched.sortedWith(alphabetical.reversed()) + } +} + +private fun FieldSpec.canDefineNativeDatasetFacet(): Boolean { + if (kind in setOf(FieldKind.enumeration, FieldKind.boolean)) return true + if (kind != FieldKind.string) return false + val semantic = (id + " " + label).nativeFacetWords() + return semantic.any { word -> word in NATIVE_FACET_WORDS } +} + +private fun FieldSpec.nativeDatasetFacetPriority(): Int { + val words = (id + " " + label).nativeFacetWords() + return when { + words.any { it in setOf("status", "state", "stage") } -> 300 + kind == FieldKind.enumeration -> 250 + words.any { it in setOf("category", "group", "priority", "type") } -> 200 + kind == FieldKind.boolean -> 150 + else -> 100 + } +} + +private fun String.nativeFacetWords(): Set = + lowercase() + .map { character -> if (character.isLetterOrDigit()) character else ' ' } + .joinToString("") + .split(' ') + .filter(String::isNotBlank) + .toSet() + +private val NATIVE_FACET_WORDS = setOf( + "category", + "group", + "priority", + "stage", + "state", + "status", + "tag", + "type", +) diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt new file mode 100644 index 00000000..8e567bd3 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/app/design/NextcloudCollectionNavigatorTest.kt @@ -0,0 +1,21 @@ +package dev.obiente.nextcloudnative.app.design + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class NextcloudCollectionNavigatorTest { + @Test + fun `collection navigation becomes compact only below the phone width threshold`() { + assertTrue(shouldUseCompactCollectionNavigation(719f)) + assertFalse(shouldUseCompactCollectionNavigation(720f)) + assertFalse(shouldUseCompactCollectionNavigation(1_280f)) + } + + @Test + fun `compact selector is reserved for multiple collection destinations`() { + assertFalse(shouldShowCompactCollectionNavigator(0)) + assertFalse(shouldShowCompactCollectionNavigator(1)) + assertTrue(shouldShowCompactCollectionNavigator(2)) + } +} diff --git a/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacetsTest.kt b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacetsTest.kt new file mode 100644 index 00000000..a2282439 --- /dev/null +++ b/ui/src/commonTest/kotlin/dev/obiente/nextcloudnative/nativeui/runtime/NativeDatasetFacetsTest.kt @@ -0,0 +1,163 @@ +package dev.obiente.nextcloudnative.nativeui.runtime + +import dev.obiente.nextcloudnative.nativeui.model.AppIdentity +import dev.obiente.nextcloudnative.nativeui.model.Confidence +import dev.obiente.nextcloudnative.nativeui.model.FieldKind +import dev.obiente.nextcloudnative.nativeui.model.FieldSpec +import dev.obiente.nextcloudnative.nativeui.model.NativeAppSchema +import dev.obiente.nextcloudnative.nativeui.model.NativeComponent +import dev.obiente.nextcloudnative.nativeui.model.ResourceSpec +import dev.obiente.nextcloudnative.nativeui.model.ViewSpec +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class NativeDatasetFacetsTest { + private val resource = ResourceSpec( + id = "rows", + name = "Inventory items", + confidence = Confidence.verified, + fields = listOf( + FieldSpec("name", "Item", FieldKind.string, true, true), + FieldSpec("category", "Category", FieldKind.string, false, true), + FieldSpec("status", "Status", FieldKind.enumeration, false, true), + FieldSpec("serial", "Serial number", FieldKind.string, false, true), + ), + ) + private val records = listOf( + record("1", "Field recorder", "Audio", "Available", "A-001"), + record("2", "Tripod", "Camera", "On loan", "C-002"), + record("3", "USB-C hub", "Computer", "Available", "D-003"), + record("4", "Lighting kit", "Camera", "Reserved", "C-004"), + record("5", "Studio monitor", "Audio", "Available", "A-005"), + ) + + @Test + fun `facets use semantic categorical fields and reject unique strings`() { + val facets = inferNativeDatasetFacets(resource, records) + + assertEquals(listOf("status", "category"), facets.map { it.field.id }) + assertEquals( + listOf("Available" to 3, "On loan" to 1, "Reserved" to 1), + facets.first().options.map { it.label to it.count }, + ) + assertTrue(facets.none { it.field.id in setOf("name", "serial") }) + } + + @Test + fun `facet groups combine with and while values within one group use or`() { + val filtered = filterNativeDatasetRecords( + records, + mapOf( + "status" to setOf("Available", "Reserved"), + "category" to setOf("Audio"), + ), + ) + + assertEquals(listOf("1", "5"), filtered.map(NativeRecord::id)) + } + + @Test + fun `browsing searches the title and visible field values without changing server order`() { + assertEquals( + listOf("1", "5"), + browseNativeDatasetRecords( + resource = resource, + records = records, + searchQuery = " audio ", + ).map(NativeRecord::id), + ) + assertEquals( + listOf("2"), + browseNativeDatasetRecords( + resource = resource, + records = records, + searchQuery = "TRIPOD", + ).map(NativeRecord::id), + ) + assertEquals( + records.map(NativeRecord::id), + browseNativeDatasetRecords(resource, records).map(NativeRecord::id), + ) + } + + @Test + fun `browsing sorts titles deterministically in both alphabetical directions`() { + assertEquals( + listOf("1", "4", "5", "2", "3"), + browseNativeDatasetRecords( + resource = resource, + records = records, + sortMode = NativeDatasetSortMode.NameAscending, + ).map(NativeRecord::id), + ) + assertEquals( + listOf("3", "2", "5", "4", "1"), + browseNativeDatasetRecords( + resource = resource, + records = records, + sortMode = NativeDatasetSortMode.NameDescending, + ).map(NativeRecord::id), + ) + } + + @Test + fun `browse state key separates schema view resource and parent scope without exposing parent ids`() { + val schema = NativeAppSchema( + schemaVersion = "1", + app = AppIdentity("inventory", "Inventory", "1.0.0"), + confidence = Confidence.verified, + resources = listOf(resource), + ) + val view = ViewSpec( + id = "inventory-table", + title = "Inventory", + resourceId = resource.id, + component = NativeComponent.dataTable, + sourceActionId = "read-inventory", + confidence = Confidence.verified, + ) + val firstScope = NativeDatasetContext( + parentResourceId = "collections", + parentRecord = NativeRecord("private-parent-a", emptyMap()), + ) + val sameScope = NativeDatasetContext( + parentResourceId = "collections", + parentRecord = NativeRecord("private-parent-a", emptyMap()), + ) + val otherParent = NativeDatasetContext( + parentResourceId = "collections", + parentRecord = NativeRecord("private-parent-b", emptyMap()), + ) + val otherSchema = schema.copy(app = schema.app.copy(version = "1.0.1")) + val otherView = view.copy(id = "inventory-overview") + val otherResource = resource.copy(id = "archived-rows") + + val key = nativeDatasetBrowseStateKey(schema, view, resource, firstScope) + + assertEquals(key, nativeDatasetBrowseStateKey(schema, view, resource, sameScope)) + assertNotEquals(key, nativeDatasetBrowseStateKey(schema, view, resource, otherParent)) + assertNotEquals(key, nativeDatasetBrowseStateKey(otherSchema, view, resource, firstScope)) + assertNotEquals(key, nativeDatasetBrowseStateKey(schema, otherView, resource, firstScope)) + assertNotEquals(key, nativeDatasetBrowseStateKey(schema, view, otherResource, firstScope)) + assertTrue(key.startsWith("dataset-browse:")) + assertTrue("private-parent-a" !in key) + } + + private fun record( + id: String, + name: String, + category: String, + status: String, + serial: String, + ) = NativeRecord( + id = id, + values = mapOf( + "name" to name, + "category" to category, + "status" to status, + "serial" to serial, + ), + ) +} From cc3755621f2dc92f6717f1c1a5300e9519257709 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Wed, 29 Jul 2026 15:01:40 +0200 Subject: [PATCH 2/3] chore(changelog): link collection navigation pull request --- changes/unreleased/250-adaptive-collection-navigation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/unreleased/250-adaptive-collection-navigation.md b/changes/unreleased/250-adaptive-collection-navigation.md index a22c54aa..4bea5710 100644 --- a/changes/unreleased/250-adaptive-collection-navigation.md +++ b/changes/unreleased/250-adaptive-collection-navigation.md @@ -1,6 +1,6 @@ category: feature issue: 250 -pull: none +pull: 257 platforms: android, desktop user-facing: yes From 3516f12d94b437f930424dbab71895d900a570d2 Mon Sep 17 00:00:00 2001 From: veryCrunchy Date: Thu, 30 Jul 2026 13:59:45 +0200 Subject: [PATCH 3/3] fix(people): preserve workflow state after process death --- .../nextcloudnative/app/NextcloudNativeApp.kt | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt index 3ee8c3d7..db91a7e2 100644 --- a/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt +++ b/ui/src/commonMain/kotlin/dev/obiente/nextcloudnative/app/NextcloudNativeApp.kt @@ -6528,7 +6528,17 @@ private fun PersonMediaScreen( var error by remember(person.id, person.backend) { mutableStateOf(null) } var loadAttempt by remember(person.id, person.backend) { mutableStateOf(0) } var actionMenuExpanded by remember(person.id) { mutableStateOf(false) } - var showFaceRectangles by rememberSaveable(currentUserId, person.id, person.backend) { mutableStateOf(false) } + // Server info is loaded again after Android restores the activity. During that window, + // currentUserId falls back to the login name and can then change to the canonical user ID. + // Keep saveable workflow state keyed to the stable session identity instead. + var showFaceRectangles by rememberSaveable( + session.serverUrl, + session.loginName, + person.id, + person.backend, + ) { + mutableStateOf(false) + } var photoSelectionMode by remember(person.id) { mutableStateOf(null) } var renameDialogVisible by remember(person.id) { mutableStateOf(false) } var renameDraft by remember(person.id) { mutableStateOf(person.name) } @@ -6542,7 +6552,8 @@ private fun PersonMediaScreen( var pendingMergeWorkflow by remember(person.id) { mutableStateOf(null) } var pendingPlan by remember(person.id) { mutableStateOf(null) } var postMutationRefreshExpectationState by rememberSaveable( - currentUserId, + session.serverUrl, + session.loginName, person.id, person.backend, ) { @@ -6551,7 +6562,12 @@ private fun PersonMediaScreen( val postMutationRefreshExpectation = remember(postMutationRefreshExpectationState) { postMutationRefreshExpectationState?.let(::decodePeoplePostMutationExpectation) } - var postMutationRefreshAttempt by rememberSaveable(currentUserId, person.id, person.backend) { + var postMutationRefreshAttempt by rememberSaveable( + session.serverUrl, + session.loginName, + person.id, + person.backend, + ) { mutableStateOf(0) } var postMutationRefreshRunning by remember(person.id, person.backend) { mutableStateOf(false) }