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
168 changes: 5 additions & 163 deletions app/src/main/kotlin/org/cru/godtools/ui/banner/Banner.kt
Original file line number Diff line number Diff line change
@@ -1,169 +1,11 @@
package org.cru.godtools.ui.banner

import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.constrain
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.offset
import com.slack.circuit.runtime.CircuitUiState

@Composable
internal fun Banner(
text: String,
primaryButton: String,
modifier: Modifier = Modifier,
primaryAction: () -> Unit = {},
secondaryButton: String? = null,
secondaryAction: () -> Unit = {},
icon: Painter? = null,
iconTint: Color = if (icon != null) LocalContentColor.current else Color.Unspecified,
) = Surface(modifier = modifier.fillMaxWidth()) {
Column {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
val iconNode = "icon"
val textNode = "text"
val primaryActionNode = "primaryAction"
val secondaryActionNode = "secondaryAction"
object Banner {
enum class Type { TOOL_LIST_FAVORITES, TUTORIAL_FEATURES }

Layout({
Text(text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.layoutId(textNode))
TextButton(
onClick = primaryAction,
modifier = Modifier
.layoutId(primaryActionNode)
.heightIn(min = 36.dp)
) { Text(primaryButton) }
if (secondaryButton != null) {
TextButton(
onClick = secondaryAction,
modifier = Modifier
.layoutId(secondaryActionNode)
.heightIn(min = 36.dp)
) { Text(secondaryButton) }
}
if (icon != null) {
Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.layoutId(iconNode))
}
}) { measurables, constraints ->
require(constraints.hasBoundedWidth) { "Banner requires a bounded width" }

val textMargin = 16.dp.roundToPx()
val actionMargin = 8.dp.roundToPx()
val iconSize = 40.dp.roundToPx()

val iconPlaceable = measurables.firstOrNull { it.layoutId == iconNode }
?.measure(constraints.constrain(Constraints.fixed(iconSize, iconSize)))
val textPlaceable = measurables.first { it.layoutId == textNode }.measure(
constraints
.offset(horizontal = iconPlaceable?.let { 0 - textMargin - it.width } ?: 0)
.offset(horizontal = -2 * textMargin)
)
val primaryActionPlaceable = measurables.first { it.layoutId == primaryActionNode }
.measure(constraints.offset(horizontal = -2 * actionMargin))
val secondaryActionPlaceable = measurables.firstOrNull { it.layoutId == secondaryActionNode }
?.measure(constraints.offset(horizontal = -2 * actionMargin))

val bannerWidth = constraints.maxWidth

when {
// single line layout
iconPlaceable == null &&
textMargin + textPlaceable.width + 36.dp.roundToPx() +
primaryActionPlaceable.width + actionMargin +
(secondaryActionPlaceable?.let { it.width + actionMargin } ?: 0) < bannerWidth -> {
val bannerHeight = maxOf(
textPlaceable.height,
primaryActionPlaceable.height,
secondaryActionPlaceable?.height ?: 0
) + 10.dp.roundToPx() + actionMargin

// calculate placeable positions
val centerLine = (bannerHeight + 2.dp.roundToPx()) / 2
val primaryActionPosition = IntOffset(
bannerWidth - actionMargin - primaryActionPlaceable.width,
centerLine - (primaryActionPlaceable.height / 2)
)
val secondaryActionPosition = IntOffset(
primaryActionPosition.x - actionMargin - (secondaryActionPlaceable?.width ?: 0),
centerLine - ((secondaryActionPlaceable?.height ?: 0) / 2)
)

layout(bannerWidth, bannerHeight) {
textPlaceable.placeRelative(textMargin, centerLine - (textPlaceable.height / 2))
primaryActionPlaceable.placeRelative(primaryActionPosition)
secondaryActionPlaceable?.placeRelative(secondaryActionPosition)
}
}

// default layout
else -> {
val iconPosition = when (iconPlaceable) {
null -> IntOffset.Zero
else -> IntOffset(textMargin, textMargin)
}
val textPosition = when (iconPlaceable) {
null -> IntOffset(textMargin, textMargin)
else -> IntOffset(iconPosition.x + iconPlaceable.width + textMargin, textMargin)
}
val primaryActionPosition = IntOffset(
bannerWidth - actionMargin - primaryActionPlaceable.width,
maxOf(
iconPlaceable?.let { iconPosition.y + it.height } ?: 0,
textPosition.y + textPlaceable.height,
) + 12.dp.roundToPx()
)
val secondaryActionPosition = when (secondaryActionPlaceable) {
null -> IntOffset.Zero

else -> {
val sameLinePosition = IntOffset(
primaryActionPosition.x - actionMargin - secondaryActionPlaceable.width,
primaryActionPosition.y
)
val nextLinePosition = IntOffset(
bannerWidth - actionMargin - secondaryActionPlaceable.width,
primaryActionPosition.y + primaryActionPlaceable.height + actionMargin
)
sameLinePosition.takeUnless { it.x < actionMargin } ?: nextLinePosition
}
}

val bannerHeight = maxOf(
iconPlaceable?.let { iconPosition.y + it.height + textMargin } ?: 0,
textPosition.y + textPlaceable.height + textMargin,
primaryActionPosition.y + primaryActionPlaceable.height + actionMargin,
secondaryActionPlaceable?.let { secondaryActionPosition.y + it.height + actionMargin } ?: 0,
)

layout(bannerWidth, bannerHeight) {
iconPlaceable?.placeRelative(iconPosition)
textPlaceable.placeRelative(textPosition)
primaryActionPlaceable.placeRelative(primaryActionPosition)
secondaryActionPlaceable?.placeRelative(secondaryActionPosition)
}
}
}
}
}
HorizontalDivider(modifier = Modifier.alpha(0.12f))
interface UiState : CircuitUiState {
val type: Type
}
}
22 changes: 22 additions & 0 deletions app/src/main/kotlin/org/cru/godtools/ui/banner/BannerModule.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package org.cru.godtools.ui.banner

import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter
import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter

@Module
@InstallIn(SingletonComponent::class)
interface BannerModule {
@Binds
fun favoriteToolsBannerPresenter(
presenter: FavoriteToolsBannerPresenter,
): BannerPresenter<FavoriteToolsBannerPresenter.UiState>

@Binds
fun tutorialFeaturesBannerPresenter(
presenter: TutorialFeaturesBannerPresenter,
): BannerPresenter<TutorialFeaturesBannerPresenter.UiState>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.cru.godtools.ui.banner

import androidx.compose.runtime.Composable

interface BannerPresenter<T : Banner.UiState> {
@Composable
fun present(): T?
}
3 changes: 0 additions & 3 deletions app/src/main/kotlin/org/cru/godtools/ui/banner/BannerType.kt

This file was deleted.

13 changes: 9 additions & 4 deletions app/src/main/kotlin/org/cru/godtools/ui/banner/Banners.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerLayout
import org.cru.godtools.ui.banner.favoritetools.FavoriteToolsBannerPresenter
import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerLayout
import org.cru.godtools.ui.banner.tutorial.TutorialFeaturesBannerPresenter

@Composable
internal fun Banners(banner: () -> BannerType?, modifier: Modifier = Modifier) = Box(modifier.heightIn(min = 1.dp)) {
internal fun Banners(state: Banner.UiState?, modifier: Modifier = Modifier) = Box(modifier.heightIn(min = 1.dp)) {
AnimatedContent(
targetState = banner(),
targetState = state,
transitionSpec = {
slideInVertically(initialOffsetY = { -it }) togetherWith slideOutVertically(targetOffsetY = { -it })
},
label = "Banner Visibility",
contentKey = { it?.type },
) {
when (it) {
BannerType.TOOL_LIST_FAVORITES -> FavoriteToolsBanner()
BannerType.TUTORIAL_FEATURES -> TutorialFeaturesBanner()
is FavoriteToolsBannerPresenter.UiState -> FavoriteToolsBannerLayout(it)
is TutorialFeaturesBannerPresenter.UiState -> TutorialFeaturesBannerLayout(it)
else -> Unit
}
}
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,31 @@ import org.cru.godtools.R

@Composable
@Preview(showBackground = true)
private fun SingleLineBanner() = Banner("Single Line", primaryButton = "Confirm", secondaryButton = "Dismiss")
private fun SingleLineMaterialBanner() = MaterialBanner(
"Single Line",
primaryButton = "Confirm",
secondaryButton = "Dismiss"
)

@Composable
@Preview(showBackground = true)
private fun DefaultBanner() = Banner(
private fun DefaultMaterialBanner() = MaterialBanner(
LoremIpsum(20).values.first().replace("\n", " "),
primaryButton = "Confirm",
secondaryButton = "Dismiss",
)

@Composable
@Preview(showBackground = true)
private fun BannerWithActionsOnSeparateLines() = Banner(
private fun MaterialBannerWithActionsOnSeparateLines() = MaterialBanner(
"Short Message",
primaryButton = "Confirm Primary Action",
secondaryButton = LoremIpsum(7).values.first(),
)

@Composable
@Preview(showBackground = true)
private fun BannerIcon() = Banner(
private fun MaterialBannerIcon() = MaterialBanner(
LoremIpsum(20).values.first().replace("\n", " "),
primaryButton = "Confirm",
secondaryButton = "Dismiss",
Expand All @@ -39,7 +43,7 @@ private fun BannerIcon() = Banner(

@Composable
@Preview(showBackground = true)
private fun BannerIconShortText() = Banner(
private fun MaterialBannerIconShortText() = MaterialBanner(
"Single Line",
primaryButton = "Confirm",
secondaryButton = "Dismiss",
Expand All @@ -49,7 +53,7 @@ private fun BannerIconShortText() = Banner(

@Composable
@Preview(showBackground = true)
private fun BannerIconShortTextLongActions() = Banner(
private fun MaterialBannerIconShortTextLongActions() = MaterialBanner(
"Single Line",
primaryButton = "Confirm Primary Action",
secondaryButton = "Dismiss Secondary Action",
Expand Down
Loading