Skip to content
Draft
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
7 changes: 7 additions & 0 deletions changes/unreleased/250-adaptive-collection-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
category: feature
issue: 250
pull: 257
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -436,6 +440,8 @@ fun NextcloudNativeMarketingCapture(
MarketingCaptureScenario.AdaptiveApp,
MarketingCaptureScenario.AdaptiveAppMobile,
-> MarketingAdaptiveAppScenario(scenario)
MarketingCaptureScenario.AdaptiveCollectionNavigationMobile ->
MarketingAdaptiveCollectionNavigationScenario(scenario)
MarketingCaptureScenario.PhotoTimelineRevalidationErrorMobile,
MarketingCaptureScenario.PhotoTimelineReturnToNewestErrorMobile,
MarketingCaptureScenario.PhotoTimelineRawRetryMobile,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -6496,7 +6528,17 @@ private fun PersonMediaScreen(
var error by remember(person.id, person.backend) { mutableStateOf<String?>(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<PersonPhotoSelectionMode?>(null) }
var renameDialogVisible by remember(person.id) { mutableStateOf(false) }
var renameDraft by remember(person.id) { mutableStateOf(person.name) }
Expand All @@ -6510,7 +6552,8 @@ private fun PersonMediaScreen(
var pendingMergeWorkflow by remember(person.id) { mutableStateOf<PersonMergeWorkflow?>(null) }
var pendingPlan by remember(person.id) { mutableStateOf<PeopleActionPlan?>(null) }
var postMutationRefreshExpectationState by rememberSaveable(
currentUserId,
session.serverUrl,
session.loginName,
person.id,
person.backend,
) {
Expand All @@ -6519,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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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<NextcloudCollectionNavigationItem>,
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()
},
)
}
}
}
}
Loading
Loading