[Compound] Bloom (#1253)

* Add `bloom` and `avatarBloom` modifiers.

* Add `ConnectivityIndicatorContainer` to control the padding needed at the top.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
Jorge Martin Espinosa 2023-09-11 15:43:23 +02:00 committed by GitHub
parent e6ecedf7bb
commit 41061da768
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
67 changed files with 913 additions and 173 deletions

1
changelog.d/1217.feature Normal file
View file

@ -0,0 +1 @@
Implement Bloom effect modifier.

View file

@ -18,6 +18,9 @@ package io.element.android.features.networkmonitor.api.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -27,34 +30,44 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.material3.MaterialTheme
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.graphics.ColorFilter
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
/**
* A view that displays a connectivity indicator when the device is offline, adding a default
* padding to make sure the status bar is not overlapped.
*/
@Composable
fun ConnectivityIndicatorView(
isOnline: Boolean,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val isStatusBarPaddingVisible = remember { MutableTransitionState(isOnline) }.apply { targetState = isOnline }
@ -78,6 +91,46 @@ fun ConnectivityIndicatorView(
}
}
/**
* A view that displays a connectivity indicator when the device is offline, passing the padding
* needed to make sure the status bar is not overlapped to its content views.
*/
@Composable
fun ConnectivityIndicatorContainer(
isOnline: Boolean,
modifier: Modifier = Modifier,
content: @Composable (topPadding: Dp) -> Unit,
) {
val isIndicatorVisible = remember { MutableTransitionState(!isOnline) }.apply { targetState = !isOnline }
val statusBarTopPadding = if (LocalInspectionMode.current) {
// Needed to get valid UI previews
24.dp
} else {
WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 6.dp
}
val target = remember(isOnline) { if (isOnline) 0.dp else statusBarTopPadding }
val animationStateOffset by animateDpAsState(
targetValue = target,
animationSpec = spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = 1.dp,
),
label = "insets-animation",
)
content(animationStateOffset)
// Display the network indicator with an animation
AnimatedVisibility(
visibleState = isIndicatorVisible,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
Indicator(modifier)
}
}
@Composable
private fun Indicator(modifier: Modifier = Modifier) {
Row(

View file

@ -18,13 +18,13 @@ package io.element.android.features.roomlist.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
@ -45,7 +45,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import io.element.android.features.leaveroom.api.LeaveRoomView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer
import io.element.android.features.roomlist.impl.components.RequestVerificationHeader
import io.element.android.features.roomlist.impl.components.RoomListMenuAction
import io.element.android.features.roomlist.impl.components.RoomListTopBar
@ -76,8 +76,10 @@ fun RoomListView(
onMenuActionClicked: (RoomListMenuAction) -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
ConnectivityIndicatorView(isOnline = state.hasNetworkConnection)
ConnectivityIndicatorContainer(
modifier = modifier,
isOnline = state.hasNetworkConnection,
) { topPadding ->
Box {
fun onRoomLongClicked(
roomListRoomSummary: RoomListRoomSummary
@ -96,6 +98,7 @@ fun RoomListView(
LeaveRoomView(state = state.leaveRoomState)
RoomListContent(
modifier = Modifier.padding(top = topPadding),
state = state,
onVerifyClicked = onVerifyClicked,
onRoomClicked = onRoomClicked,
@ -111,6 +114,8 @@ fun RoomListView(
onRoomClicked = onRoomClicked,
onRoomLongClicked = { onRoomLongClicked(it) },
modifier = Modifier
.statusBarsPadding()
.padding(top = topPadding)
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
)

View file

@ -17,10 +17,13 @@
package io.element.android.features.roomlist.impl.components
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.BugReport
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.ExperimentalMaterial3Api
@ -30,24 +33,37 @@ import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.avatarBloom
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.text.applyScaleDown
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.text.toSp
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.DropdownMenu
import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
@ -60,6 +76,9 @@ import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.designsystem.R as CommonR
private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -89,6 +108,7 @@ fun RoomListTopBar(
DefaultRoomListTopBar(
matrixUser = matrixUser,
areSearchResultsDisplayed = areSearchResultsDisplayed,
onOpenSettings = onOpenSettings,
onSearchClicked = onToggleSearch,
onMenuActionClicked = onMenuActionClicked,
@ -101,6 +121,7 @@ fun RoomListTopBar(
@Composable
private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?,
areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior,
onOpenSettings: () -> Unit,
onSearchClicked: () -> Unit,
@ -108,94 +129,139 @@ private fun DefaultRoomListTopBar(
modifier: Modifier = Modifier,
) {
var showMenu by remember { mutableStateOf(false) }
MediumTopAppBar(
modifier = modifier
.nestedScroll(scrollBehavior.nestedScrollConnection),
title = {
val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5)
ElementTheme.typography.aliasScreenTitle
else
ElementTheme.typography.fontHeadingLgBold.copy(
// Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
// ensure that the font size will never be bigger than 28.dp.
fontSize = 28.dp.applyScaleDown().toSp()
)
Text(
style = fontStyle,
text = stringResource(id = R.string.screen_roomlist_main_space_title)
)
},
navigationIcon = {
if (matrixUser != null) {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings
) {
val avatarData by remember {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
Avatar(avatarData, contentDescription = stringResource(CommonStrings.common_settings))
// We need this to manually clip the top app bar in preview mode
val previewAppBarHeight = if (LocalInspectionMode.current) {
112.dp.roundToPx()
} else {
null
}
val collapsedFraction = scrollBehavior.state.collapsedFraction
var appBarHeight by remember {
mutableIntStateOf(previewAppBarHeight ?: 0)
}
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser?.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusBarPadding = with (LocalDensity.current) { WindowInsets.statusBars.getTop(this).toDp() }
Box(modifier = modifier) {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged {
appBarHeight = it.height
}
}
},
actions = {
IconButton(
onClick = onSearchClicked,
) {
Icon(
imageVector = Icons.Default.Search,
tint = ElementTheme.materialColors.secondary,
contentDescription = stringResource(CommonStrings.action_search),
.nestedScroll(scrollBehavior.nestedScrollConnection)
.avatarBloom(
avatarData = avatarData,
background = ElementTheme.materialColors.background,
blurSize = DpSize(avatarBloomSize, avatarBloomSize),
offset = DpOffset(24.dp, 24.dp + statusBarPadding),
clipToSize = if (appBarHeight > 0) DpSize(
avatarBloomSize,
appBarHeight.toDp()
) else DpSize.Unspecified,
bottomSoftEdgeAlpha = 1f - collapsedFraction,
alpha = if (areSearchResultsDisplayed) 0f else 1f,
)
}
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = Icons.Default.MoreVert,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
.statusBarsPadding(),
colors = TopAppBarDefaults.mediumTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent,
),
title = {
val fontStyle = if (scrollBehavior.state.collapsedFraction > 0.5)
ElementTheme.typography.aliasScreenTitle
else
ElementTheme.typography.fontHeadingLgBold.copy(
// Due to a limitation of MediumTopAppBar, and to avoid the text to be truncated,
// ensure that the font size will never be bigger than 28.dp.
fontSize = 28.dp.applyScaleDown().toSp()
)
Text(
style = fontStyle,
text = stringResource(id = R.string.screen_roomlist_main_space_title)
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
Icons.Outlined.Share,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
},
navigationIcon = {
avatarData?.let {
IconButton(
modifier = Modifier.testTag(TestTags.homeScreenSettings),
onClick = onOpenSettings
) {
Avatar(
avatarData = it,
contentDescription = stringResource(CommonStrings.common_settings),
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = {
Icon(
Icons.Outlined.BugReport,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
}
},
actions = {
IconButton(
onClick = onSearchClicked,
) {
Icon(
resourceId = CommonR.drawable.ic_search,
contentDescription = stringResource(CommonStrings.action_search),
)
}
IconButton(
onClick = { showMenu = !showMenu }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.InviteFriends)
},
text = { Text(stringResource(id = CommonStrings.action_invite)) },
leadingIcon = {
Icon(
Icons.Outlined.Share,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
DropdownMenuItem(
onClick = {
showMenu = false
onMenuActionClicked(RoomListMenuAction.ReportBug)
},
text = { Text(stringResource(id = CommonStrings.common_report_a_bug)) },
leadingIcon = {
Icon(
Icons.Outlined.BugReport,
tint = ElementTheme.materialColors.secondary,
contentDescription = null,
)
}
)
}
},
scrollBehavior = scrollBehavior,
windowInsets = WindowInsets(0.dp),
)
HorizontalDivider(modifier =
Modifier.fillMaxWidth()
.alpha(collapsedFraction)
.align(Alignment.BottomCenter),
color = ElementTheme.materialColors.outlineVariant,
)
}
}
@Preview
@ -211,6 +277,7 @@ internal fun DefaultRoomListTopBarDarkPreview() = ElementPreviewDark { DefaultRo
private fun DefaultRoomListTopBarPreview() {
DefaultRoomListTopBar(
matrixUser = MatrixUser(UserId("@id:domain"), "Alice"),
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onSearchClicked = {},

View file

@ -0,0 +1,534 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
import android.graphics.Bitmap
import android.graphics.Typeface
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.text.TextPaint
import androidx.annotation.FloatRange
import androidx.compose.foundation.Image
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.LinearGradientShader
import androidx.compose.ui.graphics.Paint
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RadialGradientShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFontFamilyResolver
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import com.airbnb.android.showkase.annotation.ShowkaseComposable
import com.vanniktech.blurhash.BlurHash
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.DayNightPreviews
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewGroup
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlin.math.max
import kotlin.math.roundToInt
/**
* Default bloom configuration values.
*/
object BloomDefaults {
/**
* Number of components to use with BlurHash to generate the blur effect.
* Larger values mean more detailed blurs.
*/
const val HASH_COMPONENTS = 8
/** Default bloom layers. */
@Composable
fun defaultLayers() = persistentListOf(
// Bottom layer
if (isSystemInDarkTheme()) {
BloomLayer(0.5f, BlendMode.Exclusion)
} else {
BloomLayer(0.2f, BlendMode.Hardlight)
},
// Top layer
BloomLayer(if (isSystemInDarkTheme()) 0.2f else 0.8f, BlendMode.Color),
)
}
/**
* Bloom layer configuration.
* @param alpha The alpha value to apply to the layer.
* @param blendMode The blend mode to apply to the layer.
*/
data class BloomLayer(
val alpha: Float,
val blendMode: BlendMode,
)
/**
* Bloom effect modifier. Applies a bloom effect to the component.
* @param hash The BlurHash to use as the bloom source.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param layerConfiguration The configuration for the bloom layers. If not specified the default layers configuration will be used.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
fun Modifier.bloom(
hash: String?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
layerConfiguration: ImmutableList<BloomLayer>? = null,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
val defaultLayers = BloomDefaults.defaultLayers()
val layers = layerConfiguration ?: defaultLayers
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
if (hash == null) return@composed this
val hashedBitmap = remember(hash) {
BlurHash.decode(hash, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)?.asImageBitmap()
} ?: return@composed this
val density = LocalDensity.current
val pixelSize = remember(blurSize, density) { blurSize.toIntSize(density) }
val clipToPixelSize = remember(clipToSize, density) { clipToSize.toIntSize(density) }
val bottomSoftEdgeHeightPixels = remember(bottomSoftEdgeHeight, density) { with(density) { bottomSoftEdgeHeight.roundToPx() } }
val isRTL = LocalLayoutDirection.current == LayoutDirection.Rtl
drawWithCache {
val dstSize = if (pixelSize != IntSize.Zero) {
pixelSize
} else {
IntSize(size.width.toInt(), size.height.toInt())
}
// Calculate where to place the center of the bloom effect
val centerOffset = if (offset.isSpecified) {
if (isRTL) {
IntOffset(
size.width.roundToInt() - offset.x.roundToPx(),
size.height.roundToInt() - offset.y.roundToPx(),
)
} else {
IntOffset(
offset.x.roundToPx(),
offset.y.roundToPx(),
)
}
} else {
IntOffset(
size.center.x.toInt(),
size.center.y.toInt(),
)
}
// Calculate the offset to draw the different layers and apply clipping
// This offset is applied to place the top left corner of the bloom effect
val layersOffset = if (offset.isSpecified) {
// Offsets the layers so the center of the bloom effect is at the provided offset value
IntOffset(
centerOffset.x - dstSize.width / 2,
centerOffset.y - dstSize.height / 2,
)
} else {
// Places the layers at the center of the component
IntOffset.Zero
}
val radius = max(dstSize.width, dstSize.height).toFloat() / 2
val circularGradientShader = RadialGradientShader(
centerOffset.toOffset(),
radius,
listOf(Color.Red, Color.Transparent),
listOf(0f, 1f)
)
val circularGradientBrush = ShaderBrush(circularGradientShader)
val bottomEdgeGradient = LinearGradientShader(
from = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeightPixels).toOffset(),
to = IntOffset(0, clipToPixelSize.height).toOffset(),
listOf(Color.Transparent, background),
listOf(0f, 1f)
)
val bottomEdgeGradientBrush = ShaderBrush(bottomEdgeGradient)
onDrawBehind {
if (dstSize != IntSize.Zero) {
val circleClipPath = Path().apply {
addOval(Rect(centerOffset.toOffset(), radius - 1))
}
// Clip the external radius of bloom gradient too, otherwise we have a 1px border
clipPath(circleClipPath, clipOp = ClipOp.Intersect) {
// Draw the bloom layers
drawWithLayer {
// Clip rect to the provided size if needed
if (clipToPixelSize != IntSize.Zero) {
drawContext.canvas.clipRect(Rect(Offset.Zero, clipToPixelSize.toSize()), ClipOp.Intersect)
}
// Draw background color for blending
drawRect(background, size = pixelSize.toSize())
// Draw layers
for (layer in layers) {
drawImage(
hashedBitmap,
srcSize = IntSize(BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS),
dstSize = dstSize,
dstOffset = layersOffset,
alpha = layer.alpha * alpha,
blendMode = layer.blendMode,
)
}
// Mask the layers erasing the outer radius using the gradient brush
drawCircle(
circularGradientBrush,
radius,
centerOffset.toOffset(),
blendMode = BlendMode.DstIn
)
}
}
// Draw the bottom soft edge
drawRect(
bottomEdgeGradientBrush,
topLeft = IntOffset(0, clipToPixelSize.height - bottomSoftEdgeHeight.roundToPx()).toOffset(),
size = IntSize(pixelSize.width, bottomSoftEdgeHeight.roundToPx()).toSize(),
alpha = bottomSoftEdgeAlpha
)
}
}
}
}
/**
* Bloom effect modifier for avatars. Applies a bloom effect to the component.
* @param avatarData The avatar data to use as the bloom source.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used. If `null` is passed, no bloom effect will be applied.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
* @param clipToSize The size to use for clipping the bloom effect. If not specified the bloom effect will not be clipped.
* @param bottomSoftEdgeHeight The height of the bottom soft edge. If not specified the bottom soft edge will not be drawn.
* @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge.
* @param alpha The alpha value to apply to the bloom effect.
*/
fun Modifier.avatarBloom(
avatarData: AvatarData?,
background: Color,
blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified,
clipToSize: DpSize = DpSize.Unspecified,
bottomSoftEdgeHeight: Dp = 40.dp,
@FloatRange(from = 0.0, to = 1.0)
bottomSoftEdgeAlpha: Float = 1.0f,
@FloatRange(from = 0.0, to = 1.0)
alpha: Float = 1f,
) = composed {
// Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
avatarData ?: return@composed this
if (avatarData.url != null) {
// Request the avatar contents to use as the bloom source
val painter = rememberAsyncImagePainter(
ImageRequest.Builder(LocalContext.current)
.data(avatarData)
// Needed to be able to read pixels from the Bitmap for the hash
.allowHardware(false)
.build()
)
var blurHash by remember { mutableStateOf<String?>(null) }
LaunchedEffect(avatarData) {
val drawable = painter.imageLoader.execute(painter.request).drawable ?: return@LaunchedEffect
val bitmap = (drawable as? BitmapDrawable)?.bitmap ?: return@LaunchedEffect
blurHash = BlurHash.encode(bitmap, BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
}
bloom(
hash = blurHash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
} else {
// There is no URL so we'll generate an avatar with the initials and use that as the bloom source
val avatarColors = AvatarColorsProvider.provide(avatarData.id, ElementTheme.isLightTheme)
val initialsBitmap = initialsBitmap(
width = avatarData.size.dp,
height = avatarData.size.dp,
text = avatarData.initial,
textColor = avatarColors.foreground,
backgroundColor = avatarColors.background,
)
val hash = remember(avatarData, avatarColors) {
BlurHash.encode(initialsBitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
}
bloom(
hash = hash,
background = background,
blurSize = blurSize,
offset = offset,
clipToSize = clipToSize,
bottomSoftEdgeHeight = bottomSoftEdgeHeight,
bottomSoftEdgeAlpha = bottomSoftEdgeAlpha,
alpha = alpha,
)
}
}
// Used to create a Bitmap version of the initials avatar
@Composable
private fun initialsBitmap(
text: String,
backgroundColor: Color,
textColor: Color,
width: Dp = 32.dp,
height: Dp = 32.dp,
): ImageBitmap = with(LocalDensity.current) {
val backgroundPaint = remember(backgroundColor) {
Paint().also { it.color = backgroundColor }
}
val resolver: FontFamily.Resolver = LocalFontFamilyResolver.current
val fontSize = remember { height.toSp() / 2 }
val typeface: Typeface = remember(resolver) {
resolver.resolve(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontStyle = FontStyle.Normal,
)
}.value as Typeface
val textPaint = remember(textColor, typeface) {
TextPaint().apply {
color = textColor.toArgb()
textSize = fontSize.toPx()
this.typeface = typeface
}
}
val textMeasurer = rememberTextMeasurer()
val result = remember(text) { textMeasurer.measure(text, TextStyle.Default.copy(fontSize = fontSize)) }
val centerPx = remember(width, height) { IntOffset(width.roundToPx() / 2, height.roundToPx() / 2) }
remember(text, width, height, backgroundColor, textColor) {
val bitmap = Bitmap.createBitmap(width.roundToPx(), height.roundToPx(), Bitmap.Config.ARGB_8888).asImageBitmap()
androidx.compose.ui.graphics.Canvas(bitmap).also { canvas ->
canvas.drawCircle(centerPx.toOffset(), width.toPx() / 2, backgroundPaint)
canvas.nativeCanvas.drawText(text, centerPx.x.toFloat() - result.size.width/2, centerPx.y * 2f - result.size.height/2 - 4, textPaint)
}
bitmap
}
}
// Translates DP sizes into pixel sizes, taking into account unspecified values
private fun DpSize.toIntSize(density: Density) = with(density) {
if (isSpecified) {
IntSize(width.roundToPx(), height.roundToPx())
} else {
IntSize.Zero
}
}
/**
* Helper to draw to a canvas using layers. This allows us to apply clipping to those layers only.
*/
fun DrawScope.drawWithLayer(block: DrawScope.() -> Unit) {
with(drawContext.canvas.nativeCanvas) {
val checkPoint = saveLayer(null, null)
block()
restoreToCount(checkPoint)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@DayNightPreviews
@ShowkaseComposable(group = PreviewGroup.Bloom)
@Composable
internal fun BloomPreview() {
val blurhash = "eePn{tI?xExEja}ooKWWodjtNJoKR,j@a|sBWpS3WDbGazoKWWWWj@"
var topAppBarHeight by remember { mutableIntStateOf(-1) }
val topAppBarState = rememberTopAppBarState()
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState)
ElementPreview {
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
Box {
MediumTopAppBar(
modifier = Modifier
.onSizeChanged { size ->
topAppBarHeight = size.height
}
.bloom(
hash = blurhash,
background = ElementTheme.materialColors.background,
blurSize = DpSize(430.dp, 430.dp),
offset = DpOffset(24.dp, 24.dp),
clipToSize = if (topAppBarHeight > 0) DpSize(430.dp, topAppBarHeight.toDp()) else DpSize.Zero,
),
colors = TopAppBarDefaults.largeTopAppBarColors(
containerColor = Color.Transparent,
scrolledContainerColor = Color.Black.copy(alpha = 0.05f),
),
navigationIcon = {
Image(
modifier = Modifier
.padding(start = 8.dp)
.size(32.dp)
.clip(CircleShape),
painter = painterResource(id = R.drawable.sample_avatar),
contentScale = ContentScale.Crop,
contentDescription = null
)
},
actions = {
IconButton(onClick = {}) {
Icon(imageVector = Icons.Default.Share, contentDescription = null)
}
},
title = {
Text("Title")
},
scrollBehavior = scrollBehavior,
)
}
},
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
.fillMaxSize()
.verticalScroll(rememberScrollState()),
) {
repeat(20) {
Text("Content", modifier = Modifier.padding(vertical = 20.dp))
}
}
}
}
}
class InitialsColorStateProvider : PreviewParameterProvider<Int> {
override val values: Sequence<Int>
get() = sequenceOf(0, 1, 2, 3, 4, 5, 6, 7)
}
@DayNightPreviews
@Composable
@ShowkaseComposable(group = PreviewGroup.Bloom)
internal fun BloomInitialsPreview(@PreviewParameter(InitialsColorStateProvider::class) color: Int) {
ElementPreview {
val avatarColors = AvatarColorsProvider.provide("$color", ElementTheme.isLightTheme)
val bitmap = initialsBitmap(text = "F", backgroundColor = avatarColors.background, textColor = avatarColors.foreground)
val hash = BlurHash.encode(bitmap.asAndroidBitmap(), BloomDefaults.HASH_COMPONENTS, BloomDefaults.HASH_COMPONENTS)
Box(
modifier = Modifier.size(256.dp)
.bloom(
hash = hash,
background = ElementTheme.materialColors.background,
blurSize = DpSize(256.dp, 256.dp),
),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.size(32.dp)
.clip(CircleShape),
painter = BitmapPainter(bitmap),
contentDescription = null
)
}
}
}

View file

@ -20,7 +20,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(28.dp),
CurrentUserTopBar(32.dp),
RoomHeader(96.dp),
RoomListItem(52.dp),

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.designsystem.preview
object PreviewGroup {
const val AppBars = "App Bars"
const val Avatars = "Avatars"
const val Bloom = "Bloom"
const val BottomSheets = "Bottom Sheets"
const val Buttons = "Buttons"
const val DateTimePickers = "DateTime pickers"

View file

@ -0,0 +1,25 @@
<!--
~ Copyright (c) 2023 New Vector Ltd
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M15.05 16.46C13.79 17.43 12.2 18 10.5 18 6.36 18 3 14.64 3 10.5 3 6.36 6.36 3 10.5 3c4.14 0 7.5 3.36 7.5 7.5 0 1.71-0.57 3.29-1.54 4.55l3.25 3.24c0.39 0.4 0.39 1.03 0 1.42-0.4 0.39-1.03 0.39-1.42 0l-3.24-3.25ZM16 10.5C16 7.46 13.54 5 10.5 5S5 7.46 5 10.5 7.46 16 10.5 16s5.5-2.46 5.5-5.5Z"/>
</vector>

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8421be9fe6518a4a610e561692352c28c63b1e588fae4f7617f6be94439852f2
size 45078
oid sha256:ee62a7f77e591d238878e72e3f2fbe489e8c4e8c6a5c121b16e120ad5c43836d
size 44185

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c30a6c0c7169044216a02dde5605d59fba9307499855ffc1fa0d3fcd1650f5e
size 43291
oid sha256:d4bdf3944d30980e9bfd7cfffbabe2dac9861748cc3006e751bfd5cbf59c86db
size 42475

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928
size 138700
oid sha256:6c0485788c8776536a01613058ab001bad02a67519cf0cb4892505c7c3715fea
size 144681

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
size 185325
oid sha256:b4a259f036e555fdc52ecc8f0c9578dae70ed677235fbfd344bc31cbabc76be5
size 181478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
size 137137
oid sha256:d5966cae164fed6a3a12c1e1dd940ae7b0601e4ba69d11f49d18740bdab8c520
size 130130

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c40273c36eb1479f284e75fa91d4a75b1ae97edd0242dda37a2d4a8f10394928
size 138700
oid sha256:6c0485788c8776536a01613058ab001bad02a67519cf0cb4892505c7c3715fea
size 144681

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d7c47c713c74766c39d3b349d9e421ea588261e043105a5475cd8e68af782806
size 185325
oid sha256:b4a259f036e555fdc52ecc8f0c9578dae70ed677235fbfd344bc31cbabc76be5
size 181478

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5d5479f3a6ea138b00c8f6a376cc4a67984385a087fb32bcdb764b2996e89402
size 137137
oid sha256:b8cb21e08faf31779111d78c2be1e4e68e6f3bc994b16c108bd654f13d39fa02
size 130130

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082
size 139282
oid sha256:f2782edd31f68b7aa27e0c35b29d7c84574326cb56c65394738357479d408f5f
size 144667

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
size 186118
oid sha256:cd352f748e3fd08225aa714181fdc18d53e91a8c62f8e5e860b67ff5aaeb7b8e
size 181969

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
size 137535
oid sha256:43da903ec671dd1cb591396306ce8c057432ffa8c1bfce7a509f28576e82b530
size 130337

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:06617cda0f93ce0ea26b2a77a243371cdaf4f9a2956d8159ecb7196f7f6fe082
size 139282
oid sha256:f2782edd31f68b7aa27e0c35b29d7c84574326cb56c65394738357479d408f5f
size 144667

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:55d2d11ba729a68b1b62e9a69c3308594839fdb3612ff3db59850f0b346c28da
size 186118
oid sha256:cd352f748e3fd08225aa714181fdc18d53e91a8c62f8e5e860b67ff5aaeb7b8e
size 181969

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:62c57a45c2cb1aac23fa0c3af44f90a5d7b9a1e1626ec49eb94a2c126f53f96d
size 137535
oid sha256:8141eaa257454258d2926df5ee970daf5ee165b774e4b948256b90e22a388d4c
size 130337

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f0ad6895582183b697732ef032f89a6a0cad32106c9bbf30925c390dcab65ee6
size 151752
oid sha256:f95fbf1b51966c1c6b7b149c771f33400455d3c999b505e1aa65f0e3eb41c0ef
size 140928

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:676b62ab782652c45a7267aac5df12943fd181bea48964d28dce664884ae4182
size 156619
oid sha256:e8d522eb99079792d010e053d488f78a95e91f5a1b614b9d512bd005f0df81cf
size 146976

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b991a34a137d82ff4dedf48316a05ebbc008233984b11af70e64889a5fb5eda8
size 127438
oid sha256:eebf95f938f483da9b087eda86ae4a1baac594bc4b6f1f893d869b759679526c
size 123267

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43b6514716d18382c8962520928d9d2ff6d0f665b2471e8a24e5d2e44a25c88c
size 132295
oid sha256:4875df98092a8ef8bd1c2fda8c4a038ae2830b9ffa57e4ab71859523f97c26c9
size 128062

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0d7bc773e70c57226840b30675f14a7fd99026100d61f48eeab64fb55d43f7c6
size 230426
oid sha256:30119e8fcb65e8cbf058191268f0d70633a62b160b9f49f71879272d0d076537
size 241818

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:87f93b06f658352e7d97617c28dc0a4bd7454263fd402abfc2676f548c41d6b2
size 231314
oid sha256:73ca2b85c0f1f8f13cd3a0aae9d394116a460aa2c511ca1f2672a356eee496d2
size 241553

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f13fbb9bc776fa230b69c41e3faab659c52083d4e4f737f84b472c966f42c8dd
size 229746
oid sha256:ff7953fabf75b346e61bd4335f3753331b498dbe5f17f9af3aad8a6a1ce051cd
size 239490

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d312c80758e940b368e0b9eb2d41efb55c158691164bfde3456d7540080e46a9
size 230651
oid sha256:e262b1128df681c9a98bbefe426a891d02e0500646319f2e1756c1b5c93f2d23
size 239303

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbfb4b373449d3ce3ff06ad67e8c4ecfcbb0ec8d0bcb400d3c010952d11e8ad5
size 10480
oid sha256:51931b01aa27677c8d29a79bf92f4e8b48fc2be05e80fdabedfb9026dcbd8899
size 31634

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1d0cc556a6c431fc0f10958a4760fe63d0dd3d7f976ce3507643308c50f37c2e
size 10381
oid sha256:5441c89fc077720b52479d4daf1ebd5fe5b98b63f35141a550dac228fe1c649f
size 10617

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66bf2e3b890ab75c77f1a9e4e80a374cbb5af35e72b8f53c86424c56f1a9f524
size 36582
oid sha256:9156202a9f621b75cff3ca45aac33d72a938620049948828fc34aab3b9e0980d
size 55382

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1aff1b446de80f7535a71fbc77d7955a679ff2846bb240b70f2d410114f29b16
size 59749
oid sha256:0c7ee3acbf4653e49633e68f896fe0f5f73591c2cbc889204cc244b319996e0d
size 78348

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:66bf2e3b890ab75c77f1a9e4e80a374cbb5af35e72b8f53c86424c56f1a9f524
size 36582
oid sha256:9156202a9f621b75cff3ca45aac33d72a938620049948828fc34aab3b9e0980d
size 55382

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0fd240006e3b08b081fccdcc29cb8911fbde7f923c4f8207de817ea6a42d3d6f
size 38475
oid sha256:55800431dfe4453fa3c11f97bfae53ceaf95f1c18b72bcc8620b8c3295b26b27
size 56388

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:77d78ad2cabaac211517c129a42d8164b02ba2cc5b75a13f27f2ae113b2b9f06
size 37924
oid sha256:b2d288f46eb76b8a71a8c1a2ec60ba7c3b8b1bf2a92be9d8daf33c06b3a0f126
size 56593

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9701408630ece8c6178e792920688eb41a8fe1a4621c45f8cf34338f134dded2
size 38290
oid sha256:c0e5bc6abe96bff50960c5ace2c4eccd5aabdd9f3ccd015fae867b4c3bc2b423
size 56902

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56f0ecce5efe141fa2181e2c16dc654e79c4de43cb11b18004b03ce2ddad991d
size 38801
oid sha256:1f385e70f3a8bda7c0c2f7c090ba665575239299472c0fbd62f2821783ceb065
size 39016

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f9d7be89279d30e82a689654eac67137678ca4eb079e80372011580bcf0e9e22
size 62757
oid sha256:4bdb22d629e4bc3273aac4944802d1c91b43065858f4f4cc90052bd78ae6dd89
size 62998

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:56f0ecce5efe141fa2181e2c16dc654e79c4de43cb11b18004b03ce2ddad991d
size 38801
oid sha256:1f385e70f3a8bda7c0c2f7c090ba665575239299472c0fbd62f2821783ceb065
size 39016

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:db76df516d6c7a4bff5b830e423145c448b076846b8b7f75032be6f65585cd31
size 40846
oid sha256:8ec1d951c96737ff8c5b401ef7c3af944f76be69348559401e40a12f60d5d459
size 40807

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:405842db16444b958899fe174ca1c0157c0c6384f8ae0be37be104de8613f140
size 40126
oid sha256:ef7d9c31a72334294e93c67af1ca068ddff0062ae909ee8b682f0117c5c0ab6c
size 40331

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:658ee0f231146446810d7c0d09d280148d68475d2411c59cf0ac70caabf4e3ec
size 40496
oid sha256:54ebfc606c04f37ed72edf5e9168a55628b59e69ea8a03cc3925cf25ca807195
size 40695

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0a2a737eb81f74c50efb44c03ac9eb528c38ff393dc1a32efa0c148e27da4668
size 17880
oid sha256:877bec872499d1a1f15b50c95c507be6233a2ea121cf3429e255b3bde66c1d7b
size 18331

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ff929a3cfa47f93176817cc844b9ef2f5ff6146f016606515efe858fadd00247
size 17199
oid sha256:84da3f320738e96b2c90724b278b0b2538e55f4089753f10e4913f7f759fd1c3
size 17533

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d9b589643f99328d44a38a263b50c4447df7104a6fc0494dd80bbb2920752cb1
size 19519
oid sha256:ce86a5fe5ee55b411bd1f76b59e12baafb0cbc963251c9e58d047b1a2a200331
size 20274

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3bd86a694456603dce070a7bef0646e553cefdb365f81c9d835b28d6b2521950
size 71026

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:67be26f120cbce266651737206a5f444b9a0605a4528cea30971f4629b340501
size 85653

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7142834262eb7db1909a5a48621979cb1a2ee00bc3b70de08e90523f53083e2d
size 85294

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3424334cf7831701633ebe3b865a5696d6843c41acc372a6dcddadaaa371160f
size 83088

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e5583fd71bfe7ae518b4b90c3d3b00f1a65829dc015c6384ddd1a560ca77bdb
size 88567

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:88902f2d5512e22e1f5cd50d0628c7f968a0ee9e88b16a9d0d55fd2e11aefcba
size 85255

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a55cef72cf73e76104b74d55fe0abe92efa60e094116d871686fc4f525ca3649
size 84298

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:70919d061d76126b09642e2d0f1b41b3d0b6a4cff68f68c6bd2e43d141732dd3
size 87805

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1b62cd8043e21bed0a323d657f79605aa9a0ad3fae632387d175fdd764baf8ce
size 81507

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:aafacbe3b1d14fdb11144d65148a24400fe964c4cd880796f8cc829abf37ae05
size 5294

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30bb9d0a709868ff29803e45265069aad6e75ea2e65cdd8524b83a490a666831
size 5272

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3c56746d71fa25bf610f041b6c72e4559282407808bedbcdcc3c71a4497b3244
size 5417

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8be31e6a338f76e28550062911766dab5e4744e3e514f1348636100885ac1fe7
size 5310

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:813856d48fdb7b168b5cd662113a54dad9286b5f4191379471866ef6eda55e84
size 5336

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:83adb2ece84370f3f7249765b391ca30dcb5ba9a2ceb04872bbb7f66101890a7
size 5340

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a71d6d556b72e3be69f0a9902771066259123f002f7fd35652a64aa8c26c9eea
size 5256

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:667ecdc1069d2111044ca8f55f2fe12ff7aff84529b65c89e1c0223d841fb455
size 5465

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9c452593117cb003aca6d7d3c547b96a879c1bb10a169926c507c57a2abdc9b1
size 77557

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e65e15c721a939ed700719cacbdee57e1ccb89d984e88bdb5ed772c4b583472a
size 81494
oid sha256:700c12595cb3df0b342b36816dd336424ae1a94245874cb1e989528e89964336
size 79384

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:49f6d3b7f93008640abf30a5a65ddca6845e16343ef97c3450faf9dd6d9b9cec
size 78788
oid sha256:9f562083dcd0746eab20db445fce2836628faf5b57077dbb63ddbc32a479a9a0
size 76138