Introduce Expandable floating action button (#6)

This commit is contained in:
Patrick Honkonen
2024-04-02 10:34:13 -04:00
committed by GitHub
parent 5fd8593095
commit d28a754ee9
9 changed files with 300 additions and 28 deletions
@@ -10,8 +10,8 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults
@@ -81,7 +81,7 @@ fun ItemScreen(
enter = scaleIn(),
exit = scaleOut(),
) {
FloatingActionButton(
ExtendedFloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) { { onNavigateToEditItem(state.itemId) } },
modifier = Modifier
@@ -21,9 +21,10 @@ fun NavGraphBuilder.itemListingGraph(
) {
itemListingDestination(
onNavigateBack = { navController.popBackStack() },
onNavigateToAddItemScreen = { /*navController.navigateToAddItem()*/ },
onNavigateToQrCodeScanner = { /*navController.navigateToQrCodeScanner()*/ },
onNavigateToItemScreen = { navController.navigateToItem(itemId = it) },
onNavigateToEditItemScreen = { /*navController.navigateToEditItem(itemId = it)*/ },
onNavigateToManualKeyEntry = { /*navController.navigateToManualKeySetup()*/ },
)
itemDestination(
onNavigateBack = { navController.popBackStack() },
@@ -10,7 +10,8 @@ const val ITEM_LIST_ROUTE = "item_list"
*/
fun NavGraphBuilder.itemListingDestination(
onNavigateBack: () -> Unit = { },
onNavigateToAddItemScreen: () -> Unit = { },
onNavigateToQrCodeScanner: () -> Unit = { },
onNavigateToManualKeyEntry: () -> Unit = { },
onNavigateToItemScreen: (id: String) -> Unit = { },
onNavigateToEditItemScreen: (id: String) -> Unit = { },
) {
@@ -19,7 +20,8 @@ fun NavGraphBuilder.itemListingDestination(
) {
ItemListingScreen(
onNavigateBack = onNavigateBack,
onNavigateToAddItemScreen = onNavigateToAddItemScreen,
onNavigateToQrCodeScanner = onNavigateToQrCodeScanner,
onNavigateToManualKeyEntry = onNavigateToManualKeyEntry,
onNavigateToItemScreen = onNavigateToItemScreen,
onNavigateToEditItemScreen = onNavigateToEditItemScreen
)
@@ -9,16 +9,12 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
@@ -30,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model.ItemListingExpandableFabAction
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.EventsEffect
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.asText
import com.x8bit.bitwarden.authenticator.ui.platform.components.appbar.BitwardenTopAppBar
@@ -37,6 +34,9 @@ import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BasicDial
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenBasicDialog
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.BitwardenLoadingDialog
import com.x8bit.bitwarden.authenticator.ui.platform.components.dialog.LoadingDialogState
import com.x8bit.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabIcon
import com.x8bit.bitwarden.authenticator.ui.platform.components.fab.ExpandableFloatingActionButton
import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.BitwardenScaffold
/**
@@ -47,7 +47,8 @@ import com.x8bit.bitwarden.authenticator.ui.platform.components.scaffold.Bitward
fun ItemListingScreen(
viewModel: ItemListingViewModel = hiltViewModel(),
onNavigateBack: () -> Unit,
onNavigateToAddItemScreen: () -> Unit,
onNavigateToQrCodeScanner: () -> Unit,
onNavigateToManualKeyEntry: () -> Unit,
onNavigateToItemScreen: (id: String) -> Unit,
onNavigateToEditItemScreen: (id: String) -> Unit,
) {
@@ -60,7 +61,8 @@ fun ItemListingScreen(
when (event) {
ItemListingEvent.NavigateBack -> onNavigateBack()
ItemListingEvent.DismissPullToRefresh -> pullToRefreshState.endRefresh()
ItemListingEvent.NavigateToAddItem -> onNavigateToAddItemScreen()
ItemListingEvent.NavigateToQrCodeScanner -> onNavigateToQrCodeScanner()
ItemListingEvent.NavigateToManualAddItem -> onNavigateToManualKeyEntry()
is ItemListingEvent.NavigateToItem -> onNavigateToItemScreen(event.id)
is ItemListingEvent.ShowToast -> {
Toast.makeText(context, event.message(context.resources), Toast.LENGTH_LONG)
@@ -83,20 +85,48 @@ fun ItemListingScreen(
)
},
floatingActionButton = {
FloatingActionButton(
containerColor = MaterialTheme.colorScheme.primaryContainer,
onClick = remember(viewModel) {
{ viewModel.trySendAction(ItemListingAction.AddItemClick) }
},
ExpandableFloatingActionButton(
modifier = Modifier
.semantics { testTag = "AddItemButton" }
.padding(bottom = 16.dp),
) {
Icon(
painter = painterResource(id = R.drawable.ic_plus),
contentDescription = stringResource(id = R.string.add_item),
)
}
label = R.string.add_item.asText(),
items = listOf(
ItemListingExpandableFabAction.ScanQrCode(
label = R.string.scan_a_qr_code.asText(),
icon = IconResource(
iconPainter = painterResource(id = R.drawable.ic_camera),
contentDescription = stringResource(id = R.string.scan_a_qr_code),
testTag = "ScanQRCodeButton"
),
onScanQrCodeClick = {
viewModel.trySendAction(
ItemListingAction.ScanQrCodeClick
)
}
),
ItemListingExpandableFabAction.EnterSetupKey(
label = R.string.enter_a_setup_key.asText(),
icon = IconResource(
iconPainter = painterResource(id = R.drawable.ic_copy),
contentDescription = stringResource(id = R.string.enter_a_setup_key),
testTag = "EnterSetupKeyButton"
),
onEnterSetupKeyClick = {
viewModel.trySendAction(
ItemListingAction.EnterSetupKeyClick
)
}
)
),
expandableFabIcon = ExpandableFabIcon(
iconData = IconResource(
iconPainter = painterResource(id = R.drawable.ic_plus),
contentDescription = stringResource(id = R.string.add_item),
testTag = "AddItemButton",
),
iconRotation = 45f,
),
)
},
floatingActionButtonPosition = FabPosition.EndOverlay,
) { paddingValues ->
@@ -91,9 +91,9 @@ class ItemListingViewModel @Inject constructor(
override fun handleAction(action: ItemListingAction) {
when (action) {
ItemListingAction.AddItemClick -> sendEvent(ItemListingEvent.NavigateToAddItem)
ItemListingAction.ScanQrCodeClick -> sendEvent(ItemListingEvent.NavigateToQrCodeScanner)
ItemListingAction.EnterSetupKeyClick -> sendEvent(ItemListingEvent.NavigateToManualAddItem)
ItemListingAction.BackClick -> sendEvent(ItemListingEvent.NavigateBack)
is ItemListingAction.ItemClick -> sendEvent(ItemListingEvent.NavigateToItem(action.id))
ItemListingAction.DialogDismiss -> handleDialogDismiss()
is ItemListingAction.Internal.AuthenticatorDataReceive -> handleAuthenticatorDataReceive(
@@ -276,9 +276,14 @@ sealed class ItemListingEvent {
data object NavigateBack : ItemListingEvent()
/**
* Navigate to the Add Item screen.
* Navigate to the QR Code Scanner screen.
*/
data object NavigateToAddItem : ItemListingEvent()
data object NavigateToQrCodeScanner : ItemListingEvent()
/**
* Navigate to the Manual Add Item screen.
*/
data object NavigateToManualAddItem : ItemListingEvent()
/**
* Navigate to the View Item screen.
@@ -314,9 +319,14 @@ sealed class ItemListingAction {
data object BackClick : ItemListingAction()
/**
* The user clicked the Add Item button.
* The user clicked the Scan QR Code button.
*/
data object AddItemClick : ItemListingAction()
data object ScanQrCodeClick : ItemListingAction()
/**
* The user clicked the Enter Setup Key button.
*/
data object EnterSetupKeyClick : ItemListingAction()
/**
* The user clicked a list item.
@@ -0,0 +1,33 @@
package com.x8bit.bitwarden.authenticator.ui.authenticator.feature.itemlisting.model
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.components.fab.ExpandableFabOption
import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconResource
sealed class ItemListingExpandableFabAction(
label: Text?,
icon: IconResource,
onFabOptionClick: () -> Unit,
) : ExpandableFabOption(label, icon, onFabOptionClick) {
class ScanQrCode(
label: Text?,
icon: IconResource,
onScanQrCodeClick: () -> Unit,
) : ItemListingExpandableFabAction(
label,
icon,
onScanQrCodeClick
)
class EnterSetupKey(
label: Text?,
icon: IconResource,
onEnterSetupKeyClick: () -> Unit,
) : ItemListingExpandableFabAction(
label,
icon,
onEnterSetupKeyClick
)
}
@@ -0,0 +1,180 @@
package com.x8bit.bitwarden.authenticator.ui.platform.components.fab
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.x8bit.bitwarden.authenticator.R
import com.x8bit.bitwarden.authenticator.ui.platform.base.util.Text
import com.x8bit.bitwarden.authenticator.ui.platform.components.model.IconResource
import com.x8bit.bitwarden.authenticator.ui.platform.theme.Typography
@Composable
fun <T : ExpandableFabOption> ExpandableFloatingActionButton(
modifier: Modifier = Modifier,
label: Text?,
items: List<T>,
expandableFabState: MutableState<ExpandableFabState> = rememberExpandableFabState(),
expandableFabIcon: ExpandableFabIcon,
onStateChange: (expandableFabState: ExpandableFabState) -> Unit = { },
) {
val rotation by animateFloatAsState(
targetValue = if (expandableFabState.value == ExpandableFabState.Expanded) {
expandableFabIcon.iconRotation ?: 0f
} else {
0f
},
label = stringResource(R.string.add_item_rotation),
)
Column(
modifier = modifier.wrapContentSize(),
horizontalAlignment = Alignment.End,
) {
AnimatedVisibility(
visible = expandableFabState.value.isExpanded(),
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
LazyColumn(
modifier = Modifier
.wrapContentSize()
.padding(bottom = 16.dp),
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(items) { expandableFabOption ->
ExpandableFabOption(
onFabOptionClick = {
expandableFabState.value = expandableFabState.value.toggleValue()
onStateChange(expandableFabState.value)
expandableFabOption.onFabOptionClick()
},
expandableFabOption = expandableFabOption
)
}
}
}
ExtendedFloatingActionButton(
onClick = {
expandableFabState.value = expandableFabState.value.toggleValue()
onStateChange(expandableFabState.value)
},
containerColor = MaterialTheme.colorScheme.primaryContainer,
) {
if (label != null) {
AnimatedVisibility(
visible = expandableFabState.value.isExpanded(),
enter = fadeIn() + expandHorizontally(),
exit = fadeOut() + shrinkHorizontally(),
) {
Text(
modifier = Modifier.padding(end = 8.dp),
text = label(),
)
}
}
Icon(
modifier = Modifier
.rotate(rotation)
.semantics { expandableFabIcon.iconData.testTag },
painter = expandableFabIcon.iconData.iconPainter,
contentDescription = expandableFabIcon.iconData.contentDescription,
)
}
}
}
@Composable
private fun <T : ExpandableFabOption> ExpandableFabOption(
expandableFabOption: T,
onFabOptionClick: (option: T) -> Unit,
) {
SmallFloatingActionButton(
onClick = { onFabOptionClick(expandableFabOption) },
) {
Row(
modifier = Modifier
.wrapContentSize()
.padding(end = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
expandableFabOption.label?.let { label ->
Text(
modifier = Modifier
.clip(RoundedCornerShape(size = 8.dp))
.padding(all = 8.dp),
text = label(),
style = Typography.labelSmall,
)
}
Icon(
painter = expandableFabOption.iconData.iconPainter,
contentDescription = expandableFabOption.iconData.contentDescription,
)
}
}
}
@Composable
fun rememberExpandableFabState() =
remember { mutableStateOf<ExpandableFabState>(ExpandableFabState.Collapsed) }
abstract class ExpandableFabOption(
val label: Text?,
val iconData: IconResource,
val onFabOptionClick: () -> Unit,
)
data class ExpandableFabIcon(
val iconData: IconResource,
val iconRotation: Float?,
)
sealed class ExpandableFabState {
fun isExpanded() = this is Expanded
fun toggleValue() = if (isExpanded()) {
Collapsed
} else {
Expanded
}
data object Collapsed : ExpandableFabState()
data object Expanded : ExpandableFabState()
}
+13
View File
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="18dp"
android:viewportWidth="20"
android:viewportHeight="18">
<path
android:pathData="M3.125,5.25C2.78,5.25 2.5,5.53 2.5,5.875C2.5,6.22 2.78,6.5 3.125,6.5H4.375C4.72,6.5 5,6.22 5,5.875C5,5.53 4.72,5.25 4.375,5.25H3.125Z"
android:fillColor="#000000"/>
<path
android:pathData="M14.219,0.25H7.969C6.588,0.25 5.469,1.369 5.469,2.75H2.5C1.119,2.75 0,3.869 0,5.25V14C0,15.381 1.119,16.5 2.5,16.5H7.396C8.479,17.286 9.81,17.75 11.25,17.75C12.69,17.75 14.021,17.286 15.104,16.5H17.5C18.881,16.5 20,15.381 20,14V5.25C20,3.869 18.881,2.75 17.5,2.75H16.719C16.719,1.369 15.599,0.25 14.219,0.25ZM4.688,11.188C4.688,12.722 5.214,14.133 6.096,15.25H2.5C1.81,15.25 1.25,14.69 1.25,14V5.25C1.25,4.56 1.81,4 2.5,4H5.469C6.159,4 6.719,3.44 6.719,2.75C6.719,2.06 7.278,1.5 7.969,1.5H14.219C14.909,1.5 15.469,2.06 15.469,2.75C15.469,3.44 16.028,4 16.719,4H17.5C18.19,4 18.75,4.56 18.75,5.25V14C18.75,14.69 18.19,15.25 17.5,15.25H16.404C16.376,15.286 16.347,15.322 16.318,15.357C17.252,14.224 17.813,12.771 17.813,11.188C17.813,7.563 14.874,4.625 11.25,4.625C7.626,4.625 4.688,7.563 4.688,11.188ZM11.25,16.5C14.184,16.5 16.563,14.122 16.563,11.188C16.563,8.253 14.184,5.875 11.25,5.875C8.316,5.875 5.938,8.253 5.938,11.188C5.938,14.122 8.316,16.5 11.25,16.5Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>
+3
View File
@@ -18,4 +18,7 @@
<string name="view_item">View Item</string>
<string name="bitwarden_authenticator">Bitwarden Authenticator</string>
<string name="name">Name</string>
<string name="add_item_rotation">Add Item Rotation</string>
<string name="scan_a_qr_code">Scan a QR code</string>
<string name="enter_a_setup_key">Enter a setup key</string>
</resources>