diff --git a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/FloatingActionButtonPreview.kt b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/FloatingActionButtonPreview.kt new file mode 100644 index 0000000..b5bbb17 --- /dev/null +++ b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/FloatingActionButtonPreview.kt @@ -0,0 +1,170 @@ +package com.developerstring.jetco_library + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Delete +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.MailOutline +import androidx.compose.material.icons.outlined.Place +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.MorphFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.RadialFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.StackFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.FabSubItem + +private val items = listOf( + FabSubItem( + onClick = { println("handle home") }, + icon = Icons.Outlined.Home, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFFE46212), + size = 64.dp + ) + ), + FabSubItem( + onClick = { println("handle mail") }, + icon = Icons.Outlined.MailOutline, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFF3DBFE2), + size = 64.dp + ) + ), + FabSubItem( + onClick = { println("handle place") }, + icon = Icons.Outlined.Place, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFF4AA651), + size = 64.dp + ) + ), + FabSubItem( + onClick = { println("handle delete") }, + icon = Icons.Outlined.Delete, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFFDE3B3D), + size = 64.dp + ) + ) +) + +@Composable +fun RadialFloatingActionButtonPreview() { + var expanded by remember { mutableStateOf(false) } + + RadialFloatingActionButton( + expanded = expanded, + items = items, + onClick = { expanded = !expanded }, + config = FabMainConfig( + itemArrangement = FabMainConfig.ItemArrangement( + radius = 144.dp + ), + buttonStyle = FabMainConfig.ButtonStyle( + color = Color(0xFFE46212), + size = 84.dp + ) + ) + ) +} + +@Composable +fun StackFloatingActionButtonPreview() { + var expanded by remember { mutableStateOf(false) } + + StackFloatingActionButton( + expanded = expanded, + items = items, + onClick = { expanded = !expanded }, + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Color(0xFFE46212), + size = 84.dp + ) + ) + ) +} + +@Composable +fun MorphFloatingActionButtonPreview() { + var expanded by remember { mutableStateOf(false) } + + MorphFloatingActionButton( + expanded = expanded, + items = listOf( + FabSubItem( + onClick = { println("handle home") }, + title = "Home", + icon = Icons.Outlined.Home, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFFE7722A), + shape = RoundedCornerShape(12.dp), + size = 100.dp + ), + titleStyle = FabSubItem.TitleStyle( + weight = FontWeight.Light + ) + ), + FabSubItem( + onClick = { println("handle mail") }, + title = "Mail", + icon = Icons.Outlined.MailOutline, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFFE7722A), + shape = RoundedCornerShape(12.dp), + size = 100.dp + ), + titleStyle = FabSubItem.TitleStyle( + weight = FontWeight.Light + ) + ), + FabSubItem( + onClick = { println("handle place") }, + title = "Place", + icon = Icons.Outlined.Place, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFFE7722A), + shape = RoundedCornerShape(12.dp), + size = 100.dp + ), + titleStyle = FabSubItem.TitleStyle( + weight = FontWeight.Light + ) + ), + FabSubItem( + onClick = { println("handle delete") }, + title = "Delete", + icon = Icons.Outlined.Delete, + buttonStyle = FabSubItem.ButtonStyle( + color = Color(0xFFE7722A), + shape = RoundedCornerShape(12.dp), + size = 100.dp + ), + titleStyle = FabSubItem.TitleStyle( + weight = FontWeight.Light + ) + ) + ), + onClick = { expanded = !expanded }, + title = { + Text( + text = "Quick Actions", + color = Color.White + ) + }, + config = FabMainConfig( + buttonStyle = FabMainConfig.ButtonStyle( + color = Color(0xFFE46212), + size = 84.dp + ) + ) + ) +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt index b9194c3..824c975 100644 --- a/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt +++ b/jetco-android/JetCoLibrary/app/src/main/java/com/developerstring/jetco_library/MainActivity.kt @@ -1,9 +1,12 @@ package com.developerstring.jetco_library +import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.FabPosition +import androidx.compose.material3.Scaffold import androidx.compose.ui.graphics.Color import com.developerstring.jetco_library.ui.theme.JetCoLibraryTheme @@ -12,12 +15,18 @@ val LightBlue = Color(0xFFB5DAFF) val LightestPink = Color(0xFFF7F1FF) class MainActivity : ComponentActivity() { + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { JetCoLibraryTheme { - LineGraphScreen() + Scaffold( + floatingActionButton = { + MorphFloatingActionButtonPreview() + }, + floatingActionButtonPosition = FabPosition.End + ) {} } } } diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/MorphFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/MorphFloatingActionButton.kt new file mode 100644 index 0000000..aa40b2d --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/MorphFloatingActionButton.kt @@ -0,0 +1,162 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.components.SubFabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.FabSubItem +import kotlinx.coroutines.delay + +/** + * A Floating Action Button that morphs into an expanded card grid when activated. + * + * When collapsed, it displays a standard circular FAB. When expanded, it animates into + * a rounded card containing a title header with a close button, and a configurable grid + * of sub-action items. Sub-items fade in one by one with a staggered entrance animation. + * + * ## Example Usage: + * ```kotlin + * MorphFloatingActionButton( + * expanded = isExpanded, + * items = listOf( + * FabSubItem( + * onClick = { } + * ) + * ) + * ) + * ``` + * + * @param expanded Whether the FAB is currently expanded into card form. + * @param items List of [FabSubItem] sub-actions to display in the card grid. + * @param modifier Modifier applied to the root [Box] container. + * @param onClick Click handler for both the main FAB button and the card close button. + * @param title Optional composable rendered as the card header title. + * @param config Visual and layout configuration. See [FabMainConfig]. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun MorphFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + title: (@Composable () -> Unit)? = null, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton( + expanded = false, + onClick = onClick, + config = config + ) + } +) { + val morph = config.itemArrangement.morph + val staggerStep = config.animation.durationMillis / (items.size + 1) + + Box( + modifier = modifier + .animateContentSize( + animationSpec = tween( + durationMillis = config.animation.durationMillis, + easing = config.animation.easing + ) + ) + .clip(if (expanded) morph.cardShape else config.buttonStyle.shape) + .background(config.buttonStyle.color) + ) { + if (expanded) { + Column( + modifier = Modifier + .width(morph.width) + .padding(16.dp) + ) { + // Card header: title on the left, close button on the right + Box(modifier = Modifier.fillMaxWidth()) { + Box(modifier = Modifier.align(Alignment.CenterStart)) { + title?.invoke() + } + Box( + modifier = Modifier + .size(32.dp) + .align(Alignment.CenterEnd) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = "Close", + tint = Color.White, + modifier = Modifier.size(20.dp) + ) + } + } + + Spacer(modifier = Modifier.size(morph.headerSpace)) + + FlowRow( + maxItemsInEachRow = morph.columns, + horizontalArrangement = Arrangement.spacedBy(morph.spacedBy, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(morph.spacedBy), + modifier = Modifier.fillMaxWidth() + ) { + items.forEachIndexed { index, item -> + val alpha = remember { Animatable(0f) } + + LaunchedEffect(Unit) { + delay((index * staggerStep).toLong()) + alpha.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = (config.animation.durationMillis + ((index + 1) * 100)), + easing = FastOutSlowInEasing + ) + ) + } + + SubFabItem( + item = item, + onClick = { item.onClick() }, + modifier = Modifier.graphicsLayer { this.alpha = alpha.value } + ) + } + } + } + } else { + Box(contentAlignment = Alignment.Center) { + content() + } + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/RadialFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/RadialFloatingActionButton.kt new file mode 100644 index 0000000..98fbdca --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/RadialFloatingActionButton.kt @@ -0,0 +1,133 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.components.SubFabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.FabSubItem +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.cos +import kotlin.math.sin + +/** + * A Floating Action Button that expands sub-items radially in an arc around the main button. + * + * When expanded, sub-items fan out in a configurable arc using spring physics with a staggered + * delay, creating a natural and lively feel. When collapsed, items snap back with a clean tween. + * The arc direction and radius are fully configurable via [FabMainConfig.ItemArrangement]. + * + * ## Example Usage: + * ```kotlin + * RadialFloatingActionButton( + * expanded = isExpanded, + * items = listOf( + * FabSubItem( + * onClick = { } + * ) + * ) + * ) + * ``` + * + * @param expanded Whether the FAB is currently expanded, showing sub-items. + * @param items List of [FabSubItem] sub-actions to display when expanded. + * @param modifier Modifier applied to the root [Box] container. + * @param onClick Click handler for the main FAB button. + * @param config Visual and layout configuration. See [FabMainConfig]. + */ +@Composable +fun RadialFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton( + expanded = expanded, + onClick = onClick, + config = config + ) + } +) { + Box( + modifier = modifier + ) { + // Sub-items — laid out behind the main FAB + items.forEachIndexed { index, item -> + val animatedAlpha by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = config.animation.animationSpec, + label = "alpha_$index" + ) + val startAngle = config.itemArrangement.radial.start + val endAngle = config.itemArrangement.radial.end + + val angleDeg = if (items.size == 1) { + (startAngle + endAngle) / 2.0 // single item lands at the midpoint of the arc + } else { + startAngle + (endAngle - startAngle) * (index.toDouble() / items.lastIndex) + } + val angleRad = Math.toRadians(angleDeg) + + val targetOffsetX = if (expanded) (config.itemArrangement.radius.value * cos(angleRad)).dp else 0.dp + val targetOffsetY = if (expanded) (config.itemArrangement.radius.value * sin(angleRad)).dp else 0.dp + + val offsetX = remember { Animatable(0.dp, Dp.VectorConverter) } + val offsetY = remember { Animatable(0.dp, Dp.VectorConverter) } + + val springSpec: AnimationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMedium + ) + + val tweenSpec: AnimationSpec = tween( + durationMillis = config.animation.durationMillis, + easing = config.animation.easing + ) + + LaunchedEffect(expanded) { + val staggerDelay = (index * 60).toLong() + if (expanded) { + delay(staggerDelay) + launch { offsetX.animateTo(targetOffsetX, springSpec) } + launch { offsetY.animateTo(targetOffsetY, springSpec) } + } else { + launch { offsetX.animateTo(0.dp, tweenSpec) } + launch { offsetY.animateTo(0.dp, tweenSpec) } + } + } + + SubFabItem( + item = item, + modifier = Modifier + .offset(x = offsetX.value, y = -offsetY.value) + .padding(end = (config.buttonStyle.size - item.buttonStyle.size) / 2) + .graphicsLayer { alpha = animatedAlpha }, + onClick = { item.onClick() } + ) + } + + // Main FAB button + Box { + content() + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/StackFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/StackFloatingActionButton.kt new file mode 100644 index 0000000..7d589cb --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/StackFloatingActionButton.kt @@ -0,0 +1,150 @@ +package com.developerstring.jetco.ui.components.button.fab + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +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.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import com.developerstring.jetco.ui.components.button.fab.base.DefaultFloatingActionButton +import com.developerstring.jetco.ui.components.button.fab.components.SubFabItem +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig +import com.developerstring.jetco.ui.components.button.fab.model.FabSubItem + +/** + * A Floating Action Button that expands sub-items linearly in a stack — above, to the left, + * or to the right of the main button. + * + * Sub-items are pushed outward using animated offsets driven by tween animations. + * + * ## Example Usage: + * ```kotlin + * StackFloatingActionButton( + * expanded = isExpanded, + * items = listOf( + * FabSubItem( + * onClick = { } + * ) + * ) + * ) + * ``` + * + * @param expanded Whether the FAB is currently expanded, showing sub-items. + * @param items List of [FabSubItem] sub-actions to display when expanded. + * @param modifier Modifier applied to the root [Box] container. + * @param onClick Click handler for the main FAB button. + * @param config Visual and layout configuration. See [FabMainConfig]. + */ +@Composable +fun StackFloatingActionButton( + expanded: Boolean, + items: List, + modifier: Modifier = Modifier, + onClick: () -> Unit = {}, + config: FabMainConfig = FabMainConfig(), + content: (@Composable () -> Unit) = { + DefaultFloatingActionButton( + expanded = expanded, + onClick = onClick, + config = config + ) + } +) { + val stack = config.itemArrangement.stack + val density = LocalDensity.current + var fabWidthDp by remember { mutableStateOf(config.buttonStyle.size) } + var fabHeightDp by remember { mutableStateOf(config.buttonStyle.size) } + val spacedBy = stack.spacedBy + + // The Box anchor changes depending on which direction items spread + val alignment = when (stack) { + FabMainConfig.Orientation.Stack.TOP -> Alignment.BottomEnd + FabMainConfig.Orientation.Stack.START -> Alignment.CenterEnd + FabMainConfig.Orientation.Stack.END -> Alignment.CenterStart + } + + Box( + modifier = modifier, + contentAlignment = alignment + ) { + // Sub-items — stacked in the direction defined by Orientation.Stack + items.forEachIndexed { index, item -> + val animatedAlpha by animateFloatAsState( + targetValue = if (expanded) 1f else 0f, + animationSpec = config.animation.animationSpec, + label = "alpha_$index" + ) + + val spacing = when (stack) { + FabMainConfig.Orientation.Stack.TOP -> + fabHeightDp + spacedBy + index * (item.buttonStyle.size + spacedBy) + else -> + fabWidthDp + spacedBy + index * (item.buttonStyle.size + spacedBy) + } + + // Each orientation moves items along a different axis + val targetOffsetX = when (stack) { + FabMainConfig.Orientation.Stack.START -> if (expanded) -spacing else 0.dp + FabMainConfig.Orientation.Stack.END -> if (expanded) spacing else 0.dp + else -> 0.dp + } + + val targetOffsetY = when (stack) { + FabMainConfig.Orientation.Stack.TOP -> if (expanded) -spacing else 0.dp + else -> 0.dp + } + + val animatedOffsetX by animateDpAsState( + targetValue = targetOffsetX, + animationSpec = tween( + durationMillis = config.animation.durationMillis, + easing = config.animation.easing + ), + label = "offsetX_$index" + ) + + val animatedOffsetY by animateDpAsState( + targetValue = targetOffsetY, + animationSpec = tween( + durationMillis = config.animation.durationMillis, + easing = config.animation.easing + ), + label = "offsetY_$index" + ) + + SubFabItem( + item = item, + modifier = Modifier + .offset(x = animatedOffsetX, y = animatedOffsetY) + .padding( + end = if (stack == FabMainConfig.Orientation.Stack.TOP) { + (fabWidthDp - item.buttonStyle.size) / 2 + } else 0.dp + ).graphicsLayer { alpha = animatedAlpha }, + onClick = { item.onClick() } + ) + } + + // Main FAB + Box( + modifier = Modifier.onSizeChanged { size -> + fabWidthDp = with(density) { size.width.toDp() } + fabHeightDp = with(density) { size.height.toDp() } + } + ) { + content() + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFloatingActionButton.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFloatingActionButton.kt new file mode 100644 index 0000000..946b853 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/base/DefaultFloatingActionButton.kt @@ -0,0 +1,72 @@ +package com.developerstring.jetco.ui.components.button.fab.base + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.graphics.Color +import com.developerstring.jetco.ui.components.button.fab.model.FabMainConfig + +@Composable +internal fun DefaultFloatingActionButton( + expanded: Boolean, + modifier: Modifier = Modifier, + onClick: (() -> Unit) = {}, + config: FabMainConfig = FabMainConfig() +) { + // Rotate the main icon smoothly when toggling open/close + val mainIconRotation by animateFloatAsState( + targetValue = if (expanded) config.buttonStyle.iconRotation else 0f, + animationSpec = config.animation.animationSpec, + label = "mainIconRotation" + ) + + Box( + modifier = modifier + .defaultMinSize(minWidth = config.buttonStyle.size) + .height(config.buttonStyle.size) + .clip(config.buttonStyle.shape) + .background(config.buttonStyle.color) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ) { onClick.invoke() }, + contentAlignment = Alignment.Center + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(config.buttonStyle.horizontalSpace), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(config.buttonStyle.padding) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.rotate(mainIconRotation) + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "Base FAB icon", + tint = Color.White, + modifier = Modifier.size(config.buttonStyle.size * 0.55f) + ) + } + } + } +} \ No newline at end of file diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/components/FabSubItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/components/FabSubItem.kt new file mode 100644 index 0000000..cc1a6af --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/components/FabSubItem.kt @@ -0,0 +1,66 @@ +package com.developerstring.jetco.ui.components.button.fab.components + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.developerstring.jetco.ui.components.button.fab.model.FabSubItem + +@Composable +internal fun SubFabItem( + item: FabSubItem, + modifier: Modifier = Modifier, + onClick: () -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterVertically), + modifier = modifier + .size(item.buttonStyle.size) + .clip(item.buttonStyle.shape) + .background(item.buttonStyle.color) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current, + onClick = onClick + ) + ) { + item.icon?.let { icon -> + Icon( + imageVector = icon, + contentDescription = item.title, + tint = Color.White, + modifier = Modifier.size( + if (item.title != null) item.buttonStyle.size * 0.4f + else item.buttonStyle.size * 0.55f + ) + ) + } + + item.title?.let { title -> + Text( + text = title, + color = item.titleStyle.color, + fontSize = item.titleStyle.size.value.sp, + fontWeight = item.titleStyle.weight, + maxLines = item.titleStyle.maxLines, + overflow = TextOverflow.Ellipsis, + style = item.titleStyle.style + ) + } + } +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabMainConfig.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabMainConfig.kt new file mode 100644 index 0000000..e5a8d4e --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabMainConfig.kt @@ -0,0 +1,134 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.Easing +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Main configuration class for all Floating Action Button variants in JetCo. + * + * @param buttonStyle Visual appearance of the main FAB button. See [ButtonStyle]. + * @param itemArrangement Layout and orientation of sub-items. See [ItemArrangement]. + * @param animation Animation timing and spec used across all transitions. See [Animation]. + */ +@Stable +data class FabMainConfig( + val buttonStyle: ButtonStyle = ButtonStyle(), + val itemArrangement: ItemArrangement = ItemArrangement(), + val animation: Animation = Animation() +) { + + /** + * Sealed interface representing the available layout orientations for FAB sub-items. + * + * Use [Radial] for arc-based spreading, [Stack] for linear stacking, + * and [Morph] for the card expansion layout. + */ + sealed interface Orientation { + + /** + * Radial orientation that spreads sub-items in an arc around the main FAB. + * + * @property start Start angle of the arc in degrees (standard math convention). + * @property end End angle of the arc in degrees. + */ + enum class Radial(val start: Double, val end: Double) : Orientation { + /** Spreads items from 90° to 180° (upward and to the left). */ + END(90.0, 180.0), + /** Spreads items from 90° to 0° (upward and to the right). */ + START(90.0, 0.0), + /** Spreads items from 0° to 180° (full upper arc). */ + CENTER(0.0, 180.0) + } + + /** + * Stack orientation that spreads sub-items linearly in one direction. + * + * @property spacedBy Gap between each sub-item. Default is 40.dp. + */ + enum class Stack(val spacedBy: Dp = 40.dp) : Orientation { + /** Spreads items upward above the main FAB. */ + TOP, + /** Spreads items to the left of the main FAB. */ + START, + /** Spreads items to the right of the main FAB. */ + END + } + + /** + * Morph orientation that expands the main FAB into a card grid. + * + * @param columns Number of sub-item columns in the card grid. Default is 2. + * @param spacedBy Gap between sub-items inside the grid. Default is 12.dp. + * @param headerSpace Space between the card header and the item grid. Default is 20.dp. + * @param width Total width of the expanded card. Default is 250.dp. + * @param cardShape Shape of the expanded card. Default is [RoundedCornerShape] with 24.dp. + */ + data class Morph( + val columns: Int = 2, + val spacedBy: Dp = 12.dp, + val headerSpace: Dp = 20.dp, + val width: Dp = 250.dp, + val cardShape: Shape = RoundedCornerShape(24.dp) + ) : Orientation + } + + /** + * Animation configuration shared across all FAB variants. + * + * @param durationMillis Duration of each animation in milliseconds. Default is 300. + * @param easing Easing curve applied to tween-based animations. Default is [FastOutSlowInEasing]. + * @param animationSpec Full [AnimationSpec] used for float animations such as alpha and rotation. + */ + @Stable + open class Animation( + val durationMillis: Int = 300, + val easing: Easing = FastOutSlowInEasing, + val animationSpec: AnimationSpec = tween(durationMillis, easing = easing) + ) + + /** + * Visual style configuration for the main FAB button. + * + * @param color Background color of the main FAB. Default is Material blue. + * @param shape Shape of the main FAB button. Default is [CircleShape]. + * @param horizontalSpace Horizontal gap between icon and text when both are present. Default is 12.dp. + * @param size Diameter (and height) of the main FAB. Width expands via [defaultMinSize] when text is added. Default is 72.dp. + * @param iconRotation Target rotation angle of the icon when the FAB is expanded. Default is 45f. + * @param padding Internal padding applied inside the FAB button row. Default is no padding. + */ + @Stable + data class ButtonStyle( + val color: Color = Color(0xFF1976D2), + val shape: Shape = CircleShape, + val horizontalSpace: Dp = 12.dp, + val size: Dp = 72.dp, + val iconRotation: Float = 45f, + val padding: PaddingValues = PaddingValues() + ) + + /** + * Layout and orientation configuration for FAB sub-items. + * + * @param radius Distance from the main FAB center to each sub-item in radial layout. Default is 80.dp. + * @param radial Radial arc orientation. Default is [Orientation.Radial.END]. + * @param stack Stack direction orientation. Default is [Orientation.Stack.TOP]. + * @param morph Morph card configuration. Default is [Orientation.Morph]. + */ + @Stable + data class ItemArrangement( + val radius: Dp = 80.dp, + val radial: Orientation.Radial = Orientation.Radial.END, + val stack: Orientation.Stack = Orientation.Stack.TOP, + val morph: Orientation.Morph = Orientation.Morph() + ) +} diff --git a/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabSubItem.kt b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabSubItem.kt new file mode 100644 index 0000000..5b6c3e5 --- /dev/null +++ b/jetco-android/JetCoLibrary/jetco/ui/src/main/java/com/developerstring/jetco/ui/components/button/fab/model/FabSubItem.kt @@ -0,0 +1,66 @@ +package com.developerstring.jetco.ui.components.button.fab.model + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Data model representing a single sub-action item displayed by a FAB variant. + * + * Each [FabSubItem] carries an action callback, an optional icon, an optional title, + * and independent style configurations for both the button and the title label. + * The title is only rendered when the FAB variant explicitly enables it + * + * @param onClick Action invoked when the sub-item is clicked. + * @param title Optional label displayed below the icon. + * @param icon Optional icon displayed inside the sub-item button. + * @param buttonStyle Visual style of the sub-item button. See [ButtonStyle]. + * @param titleStyle Visual style of the title label. See [TitleStyle]. + */ +@Stable +data class FabSubItem( + val onClick: () -> Unit, + val title: String? = null, + val icon: ImageVector? = null, + val buttonStyle: ButtonStyle = ButtonStyle(), + val titleStyle: TitleStyle = TitleStyle() +) { + + /** + * Visual style configuration for the sub-item button. + * + * @param color Background color of the sub-item button. Default is Material blue. + * @param shape Shape of the sub-item button. Default is [CircleShape]. + * @param size Diameter of the sub-item button. Default is 52.dp. + */ + @Stable + data class ButtonStyle( + val color: Color = Color(0xFF1976D2), + val shape: Shape = CircleShape, + val size: Dp = 52.dp + ) + + /** + * Visual style configuration for the sub-item title label. + * + * @param color Text color of the title. Default is [Color.White]. + * @param size Font size of the title in Dp. Default is 12.dp. + * @param weight Font weight of the title. Default is [FontWeight.Light]. + * @param maxLines Maximum number of lines before the text is ellipsized. Default is 1. + * @param style Base [TextStyle] applied to the title. Default is [TextStyle.Default]. + */ + @Stable + data class TitleStyle( + val color: Color = Color.White, + val size: Dp = 12.dp, + val weight: FontWeight = FontWeight.Light, + val maxLines: Int = 1, + val style: TextStyle = TextStyle.Default + ) +}