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 @@ -14,35 +14,23 @@ import de.davis.keygo.feature.password_health.presentation.ACTION_OPEN_PASSWORD_
import de.davis.keygo.feature.password_health.presentation.PasswordHealthRoute
import de.davis.keygo.feature.settings.presentation.SettingsRoute

/** The navigation bar's destinations. */
enum class AppDestinations(
val route: NavKey,
@StringRes val label: Int,
val icon: ImageVector,
@StringRes val contentDescription: Int,
val intentAction: String? = null,
@StringRes val shortLabel: Int = label,
) {
HOME(RouteDestination.Home, R.string.home, Icons.Default.Home, R.string.home),
HOME(RouteDestination.Home, R.string.home, Icons.Default.Home),
PASSWORD_HEALTH(
PasswordHealthRoute,
R.string.password_health,
Icons.Default.HealthAndSafety,
R.string.password_health,
ACTION_OPEN_PASSWORD_HEALTH,
R.string.password_health_short,
),
CONNECTIVITY(
RouteDestination.Connectivity,
R.string.connectivity,
Icons.Default.Cast,
R.string.connectivity
),
SETTINGS(
SettingsRoute,
R.string.settings,
Icons.Default.Settings,
R.string.settings
),
;
CONNECTIVITY(RouteDestination.Connectivity, R.string.connectivity, Icons.Default.Cast),
SETTINGS(SettingsRoute, R.string.settings, Icons.Default.Settings);

companion object {
fun fromIntentAction(action: String?): AppDestinations? =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package de.davis.keygo.app.presentation.component

import androidx.compose.material3.Badge
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteItem
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.navigation3.runtime.NavKey
import de.davis.keygo.app.presentation.AppDestinations
import de.davis.keygo.feature.password_health.R as PasswordHealthR

@Composable
internal fun AppDestinationItems(
navigationSuiteType: NavigationSuiteType,
selectedRoute: NavKey?,
needsAttentionCount: Int,
onDestinationSelected: (NavKey) -> Unit,
) {
AppDestinations.entries.forEach { destination ->
NavigationSuiteItem(
navigationSuiteType = navigationSuiteType,
selected = destination.route == selectedRoute,
onClick = { onDestinationSelected(destination.route) },
icon = { Icon(imageVector = destination.icon, contentDescription = null) },
label = {
val label = stringResource(destination.label)
Text(
text = if (navigationSuiteType == NavigationSuiteType.WideNavigationRailExpanded) label
else stringResource(destination.shortLabel),
modifier = Modifier.semantics { contentDescription = label },
textAlign = TextAlign.Center,
)
},
badge = if (destination == AppDestinations.PASSWORD_HEALTH && needsAttentionCount > 0)
({ AttentionBadge(needsAttentionCount) })
else null,
)
}
}

@Composable
private fun AttentionBadge(count: Int) {
val description = pluralStringResource(
PasswordHealthR.plurals.password_health_needs_attention,
count,
count,
)
Badge(modifier = Modifier.semantics { contentDescription = description }) {
Text(text = if (count > MaxBadgeCount) "$MaxBadgeCount+" else count.toString())
}
}

private const val MaxBadgeCount = 99
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package de.davis.keygo.app.presentation.component

import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButtonMenu
import androidx.compose.material3.FloatingActionButtonMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.ToggleFloatingActionButton
import androidx.compose.material3.ToggleFloatingActionButtonDefaults
import androidx.compose.material3.ToggleFloatingActionButtonDefaults.animateIcon
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.animateFloatingActionButton
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import de.davis.keygo.R
import de.davis.keygo.core.item.generated.domain.model.VaultItemType
import de.davis.keygo.core.item.generated.presentation.presentation

@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CreateItemMenu(
visible: Boolean,
onItemSelected: (VaultItemType) -> Unit,
) {
var expanded by rememberSaveable { mutableStateOf(false) }

// The open menu draws no scrim and consumes nothing outside its items, so the destination
// underneath keeps taking taps and can navigate away while the menu is still open. The menu
// belongs to the shell and outlives that navigation, so a destination that drops the button
// takes the menu with it.
LaunchedEffect(visible) {
if (!visible) expanded = false
}

FloatingActionButtonMenu(
expanded = expanded,
modifier = Modifier.animateFloatingActionButton(
visible = visible,
alignment = Alignment.BottomEnd,
),
button = {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
if (expanded) TooltipAnchorPosition.Start else TooltipAnchorPosition.Above
),
tooltip = { PlainTooltip { Text(stringResource(R.string.add_element_content_description)) } },
state = rememberTooltipState(),
) {
ToggleFloatingActionButton(
checked = expanded,
onCheckedChange = { expanded = it },
modifier = Modifier.semantics { traversalIndex = -1f },
) {
val imageVector by remember {
derivedStateOf {
if (checkedProgress > 0.5f) Icons.Filled.Close else Icons.Filled.Add
}
}
Icon(
painter = rememberVectorPainter(imageVector),
contentDescription = null,
modifier = Modifier.animateIcon({ checkedProgress }),
)
}
}
},
) {
VaultItemType.entries.forEach { type ->
val (text, icon) = type.presentation
FloatingActionButtonMenuItem(
onClick = {
expanded = false
onItemSelected(type)
},
icon = { Icon(imageVector = icon, contentDescription = null) },
text = { Text(text = text) },
)
}
}
}

@OptIn(ExperimentalMaterial3ExpressiveApi::class)
internal val CreateItemMenuButtonSize = ToggleFloatingActionButtonDefaults.containerSize()(0f)
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package de.davis.keygo.app.presentation.component

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.MenuOpen
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Menu
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.ModalWideNavigationRail
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.WideNavigationRail
import androidx.compose.material3.WideNavigationRailValue
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteType
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberTooltipState
import androidx.compose.material3.rememberWideNavigationRailState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation3.runtime.NavKey
import de.davis.keygo.R
import kotlinx.coroutines.launch
import de.davis.keygo.core.ui.R as CoreUiR

@Composable
internal fun KeyGoNavigationRail(
modal: Boolean,
visible: Boolean,
selectedRoute: NavKey?,
needsAttentionCount: Int,
onDestinationSelected: (NavKey) -> Unit,
onCreateClicked: () -> Unit,
) {
// Keyed so a window crossing between the two modes starts from the new mode's default.
val state = key(modal) {
rememberWideNavigationRailState(
if (modal) WideNavigationRailValue.Collapsed else WideNavigationRailValue.Expanded
)
}
val scope = rememberCoroutineScope()
val expanded = state.targetValue == WideNavigationRailValue.Expanded

LaunchedEffect(state, visible) {
if (modal && !visible) state.snapTo(WideNavigationRailValue.Collapsed)
}

val dismissModal: () -> Unit = {
if (modal) scope.launch { state.collapse() }
}

val header = @Composable {
RailHeader(
expanded = expanded,
onToggle = { scope.launch { state.toggle() } },
onCreateClicked = {
dismissModal()
onCreateClicked()
},
)
}

val items = @Composable {
AppDestinationItems(
navigationSuiteType = if (expanded) NavigationSuiteType.WideNavigationRailExpanded
else NavigationSuiteType.WideNavigationRailCollapsed,
selectedRoute = selectedRoute,
needsAttentionCount = needsAttentionCount,
onDestinationSelected = { route ->
dismissModal()
onDestinationSelected(route)
},
)
}

if (modal) ModalWideNavigationRail(state = state, header = header, content = items)
else WideNavigationRail(state = state, header = header, content = items)
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RailHeader(
expanded: Boolean,
onToggle: () -> Unit,
onCreateClicked: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
val toggleDescription = stringResource(
if (expanded) R.string.collapse_navigation_content_description
else R.string.expand_navigation_content_description
)
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.End
),
tooltip = { PlainTooltip { Text(toggleDescription) } },
state = rememberTooltipState(),
) {
IconButton(
onClick = onToggle,
modifier = Modifier.padding(start = 24.dp),
) {
Icon(
imageVector = if (expanded) Icons.AutoMirrored.Filled.MenuOpen
else Icons.Filled.Menu,
contentDescription = toggleDescription,
)
}
}

val fabContainerColor = MaterialTheme.colorScheme.tertiaryContainer
ExtendedFloatingActionButton(
onClick = onCreateClicked,
expanded = expanded,
modifier = Modifier.padding(start = 20.dp),
containerColor = fabContainerColor,
contentColor = contentColorFor(fabContainerColor),
icon = {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.add_element_content_description),
)
},
text = { Text(text = stringResource(CoreUiR.string.add)) },
)
}
}
Loading
Loading