改包名com.aiosman.ravenow
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
//import androidx.compose.foundation.layout.ColumnScopeInstance.weight
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
color: Color? = null,
|
||||
backgroundColor: Color? = null,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
expandText: Boolean = false,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 16.dp),
|
||||
isLoading: Boolean = false,
|
||||
loadingTextColor: Color? = null,
|
||||
loadingText: String = "Loading",
|
||||
loadingBackgroundColor: Color? = null,
|
||||
disabledBackgroundColor: Color? = null,
|
||||
enabled: Boolean = true,
|
||||
fullWidth: Boolean = false,
|
||||
roundCorner: Float = 24f,
|
||||
fontSize: TextUnit = 17.sp,
|
||||
fontWeight: FontWeight = FontWeight.W900,
|
||||
click: () -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val animatedBackgroundColor by animateColorAsState(
|
||||
targetValue = run {
|
||||
if (enabled) {
|
||||
if (isLoading) {
|
||||
loadingBackgroundColor ?: AppColors.loadingMain
|
||||
} else {
|
||||
backgroundColor ?: AppColors.basicMain
|
||||
}
|
||||
} else {
|
||||
disabledBackgroundColor ?: AppColors.disabledBackground
|
||||
}
|
||||
},
|
||||
animationSpec = tween(300), label = ""
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(roundCorner.dp))
|
||||
.background(animatedBackgroundColor)
|
||||
.noRippleClickable {
|
||||
if (enabled && !isLoading) {
|
||||
click()
|
||||
}
|
||||
}
|
||||
.padding(contentPadding),
|
||||
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (!isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.let {
|
||||
if (fullWidth) {
|
||||
it.fillMaxWidth()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(modifier = Modifier.align(Alignment.CenterStart)) {
|
||||
leading?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text,
|
||||
fontSize = fontSize,
|
||||
color = color ?: AppColors.text,
|
||||
fontWeight = fontWeight,
|
||||
textAlign = if (expandText) TextAlign.Center else TextAlign.Start
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.let {
|
||||
if (fullWidth) {
|
||||
it.fillMaxWidth()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = AppColors.text
|
||||
)
|
||||
Text(
|
||||
loadingText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = loadingTextColor ?: AppColors.loadingText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
|
||||
@Composable
|
||||
fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 24) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
// Compare the incoming number with the previous number.
|
||||
if (targetState > initialState) {
|
||||
// If the target number is larger, it slides up and fades in
|
||||
// while the initial (smaller) number slides up and fades out.
|
||||
(slideInVertically { height -> height } + fadeIn()).togetherWith(slideOutVertically { height -> -height } + fadeOut())
|
||||
} else {
|
||||
// If the target number is smaller, it slides down and fades in
|
||||
// while the initial number slides down and fades out.
|
||||
(slideInVertically { height -> -height } + fadeIn()).togetherWith(slideOutVertically { height -> height } + fadeOut())
|
||||
}.using(
|
||||
// Disable clipping since the faded slide-in/out should
|
||||
// be displayed out of bounds.
|
||||
SizeTransform(clip = false)
|
||||
)
|
||||
}
|
||||
) { targetCount ->
|
||||
Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AnimatedFavouriteIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
isFavourite: Boolean = false,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val animatableRotation = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
suspend fun shake() {
|
||||
repeat(2) {
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = -10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(100)
|
||||
)
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
|
||||
onClick?.invoke()
|
||||
// Trigger shake animation
|
||||
scope.launch {
|
||||
shake()
|
||||
}
|
||||
}) {
|
||||
Image(
|
||||
painter = if (isFavourite) {
|
||||
painterResource(id = R.drawable.rider_pro_favourited)
|
||||
} else {
|
||||
painterResource(id = R.drawable.rider_pro_favourite)
|
||||
},
|
||||
contentDescription = "Favourite",
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationZ = animatableRotation.value
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AnimatedLikeIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
liked: Boolean = false,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
val animatableRotation = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
suspend fun shake() {
|
||||
repeat(2) {
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = -10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(100)
|
||||
)
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
|
||||
onClick?.invoke()
|
||||
// Trigger shake animation
|
||||
scope.launch {
|
||||
shake()
|
||||
}
|
||||
}) {
|
||||
Image(
|
||||
painter = if (!liked) painterResource(id = R.drawable.rider_pro_moment_like) else painterResource(
|
||||
id = R.drawable.rider_pro_moment_liked
|
||||
),
|
||||
contentDescription = "Like",
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationZ = animatableRotation.value
|
||||
},
|
||||
colorFilter = if (!liked) ColorFilter.tint(AppColors.text) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aiosman.ravenow.utils.BlurHashDecoder
|
||||
import com.aiosman.ravenow.utils.Utils.getImageLoader
|
||||
|
||||
const val DEFAULT_HASHED_BITMAP_WIDTH = 4
|
||||
const val DEFAULT_HASHED_BITMAP_HEIGHT = 3
|
||||
|
||||
/**
|
||||
* This function is used to load an image asynchronously and blur it using BlurHash.
|
||||
* @param imageUrl The URL of the image to be loaded.
|
||||
* @param modifier The modifier to be applied to the image.
|
||||
* @param imageModifier The modifier to be applied to the image.
|
||||
* @param contentDescription The content description to be applied to the image.
|
||||
* @param contentScale The content scale to be applied to the image.
|
||||
* @param isCrossFadeRequired Whether cross-fade is required or not.
|
||||
* @param onImageLoadSuccess The callback to be called when the image is loaded successfully.
|
||||
* @param onImageLoadFailure The callback to be called when the image is failed to load.
|
||||
* @see AsyncImage
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@ExperimentalCoilApi
|
||||
@Composable
|
||||
fun AsyncBlurImage(
|
||||
imageUrl: String,
|
||||
blurHash: String,
|
||||
modifier: Modifier = Modifier,
|
||||
imageModifier: Modifier? = null,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
isCrossFadeRequired: Boolean = false,
|
||||
onImageLoadSuccess: () -> Unit = {},
|
||||
onImageLoadFailure: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
val imageLoader = getImageLoader(context)
|
||||
|
||||
val blurBitmap by remember(blurHash) {
|
||||
mutableStateOf(
|
||||
BlurHashDecoder.decode(
|
||||
blurHash = blurHash,
|
||||
width = DEFAULT_HASHED_BITMAP_WIDTH,
|
||||
height = DEFAULT_HASHED_BITMAP_HEIGHT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
modifier = imageModifier ?: modifier,
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.crossfade(isCrossFadeRequired)
|
||||
.placeholder(
|
||||
blurBitmap?.toDrawable(resources)
|
||||
)
|
||||
.fallback(blurBitmap?.toDrawable(resources))
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
onSuccess = { onImageLoadSuccess() },
|
||||
onError = { onImageLoadFailure() },
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@Composable
|
||||
fun BottomNavigationPlaceholder(
|
||||
color: Color? = null
|
||||
) {
|
||||
val navigationBarHeight = with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.height(navigationBarHeight).fillMaxWidth().background(color ?: Color.Transparent)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
|
||||
@Composable
|
||||
fun Checkbox(
|
||||
size: Int = 24,
|
||||
checked: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val backgroundColor by animateColorAsState(if (checked) AppColors.checkedBackground else Color.Transparent)
|
||||
val borderColor by animateColorAsState(if (checked) Color.Transparent else AppColors.secondaryText)
|
||||
val borderWidth by animateDpAsState(if (checked) 0.dp else 2.dp)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size.dp)
|
||||
.noRippleClickable {
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(color = backgroundColor)
|
||||
.border(width = borderWidth, color = borderColor, shape = CircleShape)
|
||||
.padding(2.dp)
|
||||
) {
|
||||
if (checked) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Checked",
|
||||
tint = AppColors.checkedText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
|
||||
@Composable
|
||||
fun CheckboxWithLabel(
|
||||
checked: Boolean = false,
|
||||
checkSize: Int = 16,
|
||||
label: String = "",
|
||||
fontSize: Int = 12,
|
||||
error: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Row(
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
onCheckedChange(it)
|
||||
},
|
||||
size = checkSize
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
fontSize = fontSize.sp,
|
||||
style = TextStyle(
|
||||
color = if (error) AppColors.error else AppColors.text
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.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.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.api.CaptchaResponseBody
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ClickCaptchaView(
|
||||
captchaData: CaptchaResponseBody,
|
||||
onPositionClicked: (Offset) -> Unit
|
||||
) {
|
||||
var clickPositions by remember { mutableStateOf(listOf<Offset>()) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val imageBitmap = remember(captchaData.masterBase64) {
|
||||
val decodedString = Base64.decode(captchaData.masterBase64, Base64.DEFAULT)
|
||||
val inputStream = ByteArrayInputStream(decodedString)
|
||||
BitmapFactory.decodeStream(inputStream).asImageBitmap()
|
||||
}
|
||||
val thumbnailBitmap = remember(captchaData.thumbBase64) {
|
||||
val decodedString = Base64.decode(captchaData.thumbBase64, Base64.DEFAULT)
|
||||
val inputStream = ByteArrayInputStream(decodedString)
|
||||
BitmapFactory.decodeStream(inputStream).asImageBitmap()
|
||||
}
|
||||
var boxWidth by remember { mutableStateOf(0) }
|
||||
var boxHeightInDp by remember { mutableStateOf(0.dp) }
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
val density = LocalDensity.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
|
||||
Text(stringResource(R.string.captcha_hint))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Image(
|
||||
bitmap = thumbnailBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned {
|
||||
boxWidth = it.size.width
|
||||
scale = imageBitmap.width.toFloat() / boxWidth
|
||||
boxHeightInDp = with(density) { (imageBitmap.height.toFloat() / scale).toDp() }
|
||||
}
|
||||
.background(Color.Gray)
|
||||
) {
|
||||
if (boxWidth != 0 && boxHeightInDp != 0.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(boxHeightInDp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInteropFilter { event ->
|
||||
if (event.action == android.view.MotionEvent.ACTION_DOWN) {
|
||||
val newPosition = Offset(event.x, event.y)
|
||||
clickPositions = clickPositions + newPosition
|
||||
// 计算出点击的位置在图片上的坐标
|
||||
val imagePosition = Offset(
|
||||
newPosition.x * scale,
|
||||
newPosition.y * scale
|
||||
)
|
||||
onPositionClicked(imagePosition)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Draw markers at click positions
|
||||
clickPositions.forEachIndexed { index, position ->
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
color = Color(0xaada3832).copy(),
|
||||
radius = 40f,
|
||||
center = position
|
||||
)
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
drawText(
|
||||
(index + 1).toString(),
|
||||
position.x,
|
||||
position.y + 15f, // Adjusting the y position to center the text
|
||||
android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.WHITE
|
||||
textSize = 50f
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickCaptchaDialog(
|
||||
captchaData: CaptchaResponseBody,
|
||||
onLoadCaptcha: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPositionClicked: (Offset) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
onDismissRequest()
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.captcha))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
ClickCaptchaView(
|
||||
captchaData = captchaData,
|
||||
onPositionClicked = onPositionClicked
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ActionButton(
|
||||
text = stringResource(R.string.refresh),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
onLoadCaptcha()
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@Composable
|
||||
fun CustomClickableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
onLongPress: () -> Unit = {},
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val pressIndicator = Modifier.pointerInput(onClick) {
|
||||
detectTapGestures(
|
||||
onLongPress = { onLongPress() }
|
||||
) { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.then(pressIndicator),
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
onTextLayout(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toOffset
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun <T : Any> DraggableGrid(
|
||||
items: List<T>,
|
||||
getItemId: (T) -> String,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragModeStart: () -> Unit, // New parameter for drag start
|
||||
onDragModeEnd: () -> Unit, // New parameter for drag end,
|
||||
additionalItems: List<@Composable () -> Unit> = emptyList(), // New parameter for additional items
|
||||
lockedIndices: List<Int> = emptyList(), // New parameter for locked indices
|
||||
content: @Composable (T, Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
val gridState = rememberLazyGridState()
|
||||
val dragDropState =
|
||||
rememberGridDragDropState(gridState, onMove, onDragModeStart, onDragModeEnd, lockedIndices)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.dragContainer(dragDropState),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
||||
) {
|
||||
itemsIndexed(items, key = { _, item ->
|
||||
getItemId(item)
|
||||
}) { index, item ->
|
||||
DraggableItem(dragDropState, index) { isDragging ->
|
||||
content(item, isDragging)
|
||||
}
|
||||
}
|
||||
additionalItems.forEach { additionalItem ->
|
||||
item {
|
||||
additionalItem()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier {
|
||||
return pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun LazyGridItemScope.DraggableItem(
|
||||
dragDropState: GridDragDropState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val dragging = index == dragDropState.draggingItemIndex
|
||||
val draggingModifier = if (dragging) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer {
|
||||
translationX = dragDropState.draggingItemOffset.x
|
||||
translationY = dragDropState.draggingItemOffset.y
|
||||
}
|
||||
} else if (index == dragDropState.previousIndexOfDraggedItem) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer {
|
||||
translationX = dragDropState.previousItemOffset.value.x
|
||||
translationY = dragDropState.previousItemOffset.value.y
|
||||
}
|
||||
} else {
|
||||
Modifier.animateItemPlacement()
|
||||
}
|
||||
Box(modifier = modifier.then(draggingModifier), propagateMinConstraints = true) {
|
||||
content(dragging)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun rememberGridDragDropState(
|
||||
gridState: LazyGridState,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragModeStart: () -> Unit,
|
||||
onDragModeEnd: () -> Unit,
|
||||
lockedIndices: List<Int> // New parameter for locked indices
|
||||
): GridDragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember(gridState) {
|
||||
GridDragDropState(
|
||||
state = gridState,
|
||||
onMove = onMove,
|
||||
scope = scope,
|
||||
onDragModeStart = onDragModeStart,
|
||||
onDragModeEnd = onDragModeEnd,
|
||||
lockedIndices = lockedIndices // Pass the locked indices
|
||||
)
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
val diff = state.scrollChannel.receive()
|
||||
gridState.scrollBy(diff)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class GridDragDropState internal constructor(
|
||||
private val state: LazyGridState,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit,
|
||||
private val onDragModeStart: () -> Unit,
|
||||
private val onDragModeEnd: () -> Unit,
|
||||
private val lockedIndices: List<Int> // New parameter for locked indices
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal val scrollChannel = Channel<Float>()
|
||||
|
||||
private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
|
||||
private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
|
||||
internal val draggingItemOffset: Offset
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
|
||||
} ?: Offset.Zero
|
||||
|
||||
private val draggingItemLayoutInfo: LazyGridItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter)
|
||||
private set
|
||||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item ->
|
||||
offset.x.toInt() in item.offset.x..item.offsetEnd.x &&
|
||||
offset.y.toInt() in item.offset.y..item.offsetEnd.y
|
||||
}?.also {
|
||||
if (it.index !in lockedIndices) { // Check if the item is not locked
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset.toOffset()
|
||||
onDragModeStart() // Notify drag start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDragInterrupted() {
|
||||
if (draggingItemIndex != null) {
|
||||
previousIndexOfDraggedItem = draggingItemIndex
|
||||
val startOffset = draggingItemOffset
|
||||
scope.launch {
|
||||
previousItemOffset.snapTo(startOffset)
|
||||
previousItemOffset.animateTo(
|
||||
Offset.Zero,
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Offset.VisibilityThreshold
|
||||
)
|
||||
)
|
||||
previousIndexOfDraggedItem = null
|
||||
}
|
||||
}
|
||||
draggingItemDraggedDelta = Offset.Zero
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = Offset.Zero
|
||||
onDragModeEnd() // Notify drag end
|
||||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size.toSize()
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x &&
|
||||
middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y &&
|
||||
draggingItem.index != item.index &&
|
||||
item.index !in lockedIndices // Check if the target item is not locked
|
||||
}
|
||||
if (targetItem != null) {
|
||||
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
|
||||
draggingItem.index
|
||||
} else if (draggingItem.index == state.firstVisibleItemIndex) {
|
||||
targetItem.index
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (scrollToIndex != null) {
|
||||
scope.launch {
|
||||
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
}
|
||||
} else {
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
}
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll = when {
|
||||
draggingItemDraggedDelta.y > 0 ->
|
||||
(endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
|
||||
draggingItemDraggedDelta.y < 0 ->
|
||||
(startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
|
||||
else -> 0f
|
||||
}
|
||||
if (overscroll != 0f) {
|
||||
scrollChannel.trySend(overscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LazyGridItemInfo.offsetEnd: IntOffset
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
|
||||
operator fun IntOffset.plus(size: IntSize): IntOffset {
|
||||
return IntOffset(x + size.width, y + size.height)
|
||||
}
|
||||
|
||||
operator fun Offset.plus(size: Size): Offset {
|
||||
return Offset(x + size.width, y + size.height)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
data class MenuItem(
|
||||
val title: String,
|
||||
val icon: Int,
|
||||
val action: () -> Unit
|
||||
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DropdownMenu(
|
||||
expanded: Boolean = false,
|
||||
menuItems: List<MenuItem> = emptyList(),
|
||||
width: Int? = null,
|
||||
onDismissRequest: () -> Unit = {},
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
MaterialTheme(
|
||||
shapes = MaterialTheme.shapes.copy(
|
||||
extraSmall = RoundedCornerShape(
|
||||
16.dp
|
||||
)
|
||||
)
|
||||
) {
|
||||
androidx.compose.material3.DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = Modifier
|
||||
.let {
|
||||
if (width != null) it.width(width.dp) else it
|
||||
}
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
for (item in menuItems) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 14.dp, horizontal = 24.dp)
|
||||
.noRippleClickable {
|
||||
item.action()
|
||||
}) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
item.title,
|
||||
fontWeight = FontWeight.W500,
|
||||
color = AppColors.text,
|
||||
)
|
||||
if (width != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(id = item.icon),
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun EditCommentBottomModal(
|
||||
replyComment: CommentEntity? = null,
|
||||
onSend: (String) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
if (replyComment == null) "Comment" else "Reply",
|
||||
fontWeight = FontWeight.W600,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 20.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = AppColors.text
|
||||
)
|
||||
Crossfade(
|
||||
targetState = text.isNotEmpty(), animationSpec = tween(500),
|
||||
label = ""
|
||||
) { isNotEmpty ->
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_video_share),
|
||||
contentDescription = "Emoji",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.noRippleClickable {
|
||||
if (text.isNotEmpty()) {
|
||||
onSend(text)
|
||||
text = ""
|
||||
}
|
||||
|
||||
},
|
||||
tint = if (isNotEmpty) AppColors.main else AppColors.nonActive
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (replyComment != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
replyComment.avatar,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "Avatar",
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
replyComment.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
replyComment.comment,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = AppColors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = {
|
||||
text = it
|
||||
},
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontWeight = FontWeight.Normal
|
||||
),
|
||||
minLines = 5
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(navBarHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun FollowButton(
|
||||
isFollowing: Boolean,
|
||||
fontSize: TextUnit = 12.sp,
|
||||
onFollowClick: () -> Unit,
|
||||
){
|
||||
val AppColors = LocalAppTheme.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
color = if (isFollowing) AppColors.main else AppColors.nonActive
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.noRippleClickable {
|
||||
onFollowClick()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isFollowing) stringResource(R.string.following_upper) else stringResource(
|
||||
R.string.follow_upper
|
||||
),
|
||||
fontSize = fontSize,
|
||||
color = if (isFollowing) AppColors.mainText else AppColors.nonActiveText,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import com.aiosman.ravenow.utils.Utils.getImageLoader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
|
||||
val context = LocalContext.current
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(imageUrl) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
(imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap()
|
||||
}
|
||||
|
||||
bitmap = result
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomAsyncImage(
|
||||
context: Context? = null,
|
||||
imageUrl: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
blurHash: String? = null,
|
||||
@DrawableRes
|
||||
placeholderRes: Int? = null,
|
||||
contentScale: ContentScale = ContentScale.Crop
|
||||
) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val imageLoader = getImageLoader(context ?: localContext)
|
||||
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context ?: localContext)
|
||||
.data(imageUrl)
|
||||
.crossfade(200)
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
}
|
||||
540
app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt
Normal file
540
app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt
Normal file
@@ -0,0 +1,540 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
||||
import com.aiosman.ravenow.exp.timeAgo
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
|
||||
@Composable
|
||||
fun MomentCard(
|
||||
momentEntity: MomentEntity,
|
||||
onLikeClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
onAddComment: () -> Unit = {},
|
||||
onFollowClick: () -> Unit = {},
|
||||
hideAction: Boolean = false,
|
||||
showFollowButton: Boolean = true
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var imageIndex by remember { mutableStateOf(0) }
|
||||
val navController = LocalNavController.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
) {
|
||||
MomentTopRowGroup(
|
||||
momentEntity = momentEntity,
|
||||
onFollowClick = onFollowClick,
|
||||
showFollowButton = showFollowButton
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.noRippleClickable {
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
highlightCommentId = 0,
|
||||
initImagePagerIndex = imageIndex
|
||||
)
|
||||
}
|
||||
) {
|
||||
MomentContentGroup(
|
||||
momentEntity = momentEntity,
|
||||
onPageChange = { index -> imageIndex = index }
|
||||
)
|
||||
}
|
||||
if (!hideAction) {
|
||||
MomentBottomOperateRowGroup(
|
||||
momentEntity = momentEntity,
|
||||
onLikeClick = onLikeClick,
|
||||
onAddComment = onAddComment,
|
||||
onFavoriteClick = onFavoriteClick,
|
||||
imageIndex = imageIndex,
|
||||
onCommentClick = {
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
highlightCommentId = 0,
|
||||
initImagePagerIndex = imageIndex
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModificationListHeader() {
|
||||
val navController = LocalNavController.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFF8F8F8))
|
||||
.padding(4.dp)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
navController.navigate("ModificationList")
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFFEB4869))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Build,
|
||||
contentDescription = "Modification Icon",
|
||||
tint = Color.White, // Assuming the icon should be white
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Modification List",
|
||||
color = Color(0xFF333333),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Left
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentName(name: String, modifier: Modifier = Modifier) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Text(
|
||||
modifier = modifier,
|
||||
textAlign = TextAlign.Start,
|
||||
text = name,
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp, style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentFollowBtn() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 53.dp, height = 18.dp)
|
||||
.padding(start = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
painter = painterResource(id = R.drawable.follow_bg),
|
||||
contentDescription = ""
|
||||
)
|
||||
Text(
|
||||
text = "Follow",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentPostLocation(location: String) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Text(
|
||||
text = location,
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentPostTime(time: String) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Text(
|
||||
modifier = Modifier,
|
||||
text = time, color = AppColors.text,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentTopRowGroup(
|
||||
momentEntity: MomentEntity,
|
||||
showFollowButton: Boolean = true,
|
||||
onFollowClick: () -> Unit = {}
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
momentEntity.avatar,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(40.dp))
|
||||
.noRippleClickable {
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
"{id}",
|
||||
momentEntity.authorId.toString()
|
||||
)
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MomentName(
|
||||
modifier = Modifier.weight(1f),
|
||||
name = momentEntity.nickname
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(21.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MomentPostTime(momentEntity.time.timeAgo(context))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
MomentPostLocation(momentEntity.location)
|
||||
}
|
||||
}
|
||||
val isFollowing = momentEntity.followStatus
|
||||
if (showFollowButton && !isFollowing) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
if (AppState.UserId != momentEntity.authorId) {
|
||||
FollowButton(
|
||||
isFollowing = false
|
||||
) {
|
||||
onFollowClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PostImageView(
|
||||
images: List<MomentImageEntity>,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { images.size })
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
onPageChange(pagerState.currentPage)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
) { page ->
|
||||
val image = images[page]
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
image.thumbnail,
|
||||
contentDescription = "Image",
|
||||
blurHash = image.blurHash,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentContentGroup(
|
||||
momentEntity: MomentEntity,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
if (momentEntity.momentTextContent.isNotEmpty()) {
|
||||
Text(
|
||||
text = momentEntity.momentTextContent,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
if (momentEntity.relMoment != null) {
|
||||
RelPostCard(
|
||||
momentEntity = momentEntity.relMoment!!,
|
||||
modifier = Modifier.background(Color(0xFFF8F8F8))
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
PostImageView(
|
||||
images = momentEntity.images,
|
||||
onPageChange = onPageChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.size(width = 24.dp, height = 24.dp),
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = "",
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
Text(
|
||||
text = count,
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
AnimatedCounter(
|
||||
count = count.toInt(),
|
||||
fontSize = 14,
|
||||
modifier = Modifier
|
||||
.padding(start = 7.dp)
|
||||
.width(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MomentBottomOperateRowGroup(
|
||||
onLikeClick: () -> Unit = {},
|
||||
onAddComment: () -> Unit = {},
|
||||
onCommentClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
momentEntity: MomentEntity,
|
||||
imageIndex: Int = 0
|
||||
) {
|
||||
var showCommentModal by remember { mutableStateOf(false) }
|
||||
if (showCommentModal) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showCommentModal = false },
|
||||
containerColor = Color.White,
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
windowInsets = WindowInsets(0),
|
||||
dragHandle = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
|
||||
}
|
||||
}
|
||||
) {
|
||||
CommentModalContent(
|
||||
postId = momentEntity.id,
|
||||
commentCount = momentEntity.commentCount,
|
||||
onCommentAdded = {
|
||||
onAddComment()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(start = 16.dp, end = 0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MomentOperateBtn(count = momentEntity.likeCount.toString()) {
|
||||
AnimatedLikeIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
liked = momentEntity.liked
|
||||
) {
|
||||
onLikeClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.noRippleClickable {
|
||||
onCommentClick()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MomentOperateBtn(
|
||||
icon = R.drawable.rider_pro_comment,
|
||||
count = momentEntity.commentCount.toString()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.noRippleClickable {
|
||||
onFavoriteClick()
|
||||
},
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
|
||||
AnimatedFavouriteIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
isFavourite = momentEntity.isFavorite
|
||||
) {
|
||||
onFavoriteClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (momentEntity.images.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
momentEntity.images.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (imageIndex == index) Color.Red else Color.Gray.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
.padding(1.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentListLoading() {
|
||||
CircularProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.CenterHorizontally),
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.aiosman.ravenow.utils.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 选择图片并压缩
|
||||
*/
|
||||
@Composable
|
||||
fun pickupAndCompressLauncher(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
maxSize: Int = 512,
|
||||
quality: Int = 85,
|
||||
onImagePicked: (Uri, File) -> Unit
|
||||
) = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val uri = result.data?.data
|
||||
uri?.let {
|
||||
scope.launch {
|
||||
// Compress the image
|
||||
val file = Utils.compressImage(context, it, maxSize = maxSize, quality = quality)
|
||||
// Check the compressed image size
|
||||
onImagePicked(it, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.net.http.SslError
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.DictService
|
||||
import com.aiosman.ravenow.data.DictServiceImpl
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PolicyCheckbox(
|
||||
checked: Boolean = false,
|
||||
error: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var modalSheetState = androidx.compose.material3.rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
)
|
||||
var scope = rememberCoroutineScope()
|
||||
val dictService: DictService = DictServiceImpl()
|
||||
var policyUrl by remember { mutableStateOf("") }
|
||||
val appColor = LocalAppTheme.current
|
||||
fun openPolicyModel() {
|
||||
scope.launch {
|
||||
try {
|
||||
val resp = dictService.getDictByKey(ConstVars.DICT_KEY_PRIVATE_POLICY_URL)
|
||||
policyUrl = resp.value
|
||||
showModal = true
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if (showModal) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showModal = false
|
||||
},
|
||||
sheetState = modalSheetState,
|
||||
windowInsets = WindowInsets(0),
|
||||
containerColor = Color.White,
|
||||
) {
|
||||
WebViewDisplay(
|
||||
url = policyUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
onCheckedChange(it)
|
||||
},
|
||||
size = 16
|
||||
)
|
||||
val text = buildAnnotatedString {
|
||||
val keyword = stringResource(R.string.private_policy_keyword)
|
||||
val template = stringResource(R.string.private_policy_template)
|
||||
append(template)
|
||||
append(" ")
|
||||
withStyle(style = SpanStyle(color = if (error) appColor.error else appColor.text)) {
|
||||
append(keyword)
|
||||
}
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = appColor.main,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = template.length + 1,
|
||||
end = template.length + keyword.length + 1
|
||||
)
|
||||
append(".")
|
||||
}
|
||||
ClickableText(
|
||||
text = text,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
onClick = {
|
||||
openPolicyModel()
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = if (error) appColor.error else appColor.text
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WebViewDisplay(modifier: Modifier = Modifier, url: String) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
handler?.proceed() // 忽略证书错误
|
||||
}
|
||||
}
|
||||
settings.apply {
|
||||
domStorageEnabled = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
}
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
|
||||
@Composable
|
||||
fun RelPostCard(
|
||||
momentEntity: MomentEntity,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val image = momentEntity.images.firstOrNull()
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
MomentTopRowGroup(momentEntity = momentEntity)
|
||||
Box(
|
||||
modifier=Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
image?.let {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
image.thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(100.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
@Composable
|
||||
fun StatusBarMask(darkIcons: Boolean = true) {
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
val systemUiController = rememberSystemUiController()
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusBarMaskLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
darkIcons: Boolean = true,
|
||||
useNavigationBarMask: Boolean = true,
|
||||
maskBoxBackgroundColor: Color = Color.Transparent,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val navigationBarPaddings =
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(paddingValues.calculateTopPadding())
|
||||
.fillMaxWidth()
|
||||
.background(maskBoxBackgroundColor)
|
||||
) {
|
||||
|
||||
}
|
||||
content()
|
||||
if (navigationBarPaddings > 24.dp && useNavigationBarMask) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(navigationBarPaddings).fillMaxWidth().background(AppColors.background)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun StatusBarSpacer() {
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun TextInputField(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
password: Boolean = false,
|
||||
label: String? = null,
|
||||
hint: String? = null,
|
||||
error: String? = null,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var showPassword by remember { mutableStateOf(!password) }
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
Column(modifier = modifier) {
|
||||
label?.let {
|
||||
Text(it, color = AppColors.secondaryText)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (error == null) Color.Transparent else AppColors.error,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically){
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused = focusState.isFocused
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
color = AppColors.text
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (password) KeyboardType.Password else KeyboardType.Text
|
||||
),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
if (password) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_eye),
|
||||
contentDescription = "Password",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.noRippleClickable {
|
||||
showPassword = !showPassword
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isEmpty()) {
|
||||
hint?.let {
|
||||
Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
||||
contentDescription = "Error",
|
||||
modifier = Modifier.size(8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
AnimatedContent(targetState = error) { targetError ->
|
||||
Text(targetError ?: "", color = AppColors.text, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.aiosman.ravenow.ui.composables.form
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
|
||||
@Composable
|
||||
fun FormTextInput(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
label: String? = null,
|
||||
error: String? = null,
|
||||
hint: String? = null,
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.let {
|
||||
if (error != null) {
|
||||
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(17.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
label?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier
|
||||
.widthIn(100.dp),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.text
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = hint ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.inputHint
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
maxLines = 1,
|
||||
value = value,
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.text
|
||||
),
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
||||
contentDescription = "Error",
|
||||
modifier = Modifier.size(8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
AnimatedContent(targetState = error) { targetError ->
|
||||
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
@RequiresOptIn(
|
||||
message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " +
|
||||
"the annotation might be removed or changed in some way without any notice.",
|
||||
level = RequiresOptIn.Level.WARNING
|
||||
)
|
||||
@Target(
|
||||
AnnotationTarget.FUNCTION,
|
||||
AnnotationTarget.PROPERTY,
|
||||
AnnotationTarget.CLASS
|
||||
)
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ExperimentalToolbarApi
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kotlin.math.max
|
||||
|
||||
@Deprecated(
|
||||
"Use AppBarContainer for naming consistency",
|
||||
replaceWith = ReplaceWith(
|
||||
"AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)",
|
||||
"me.onebone.toolbar"
|
||||
)
|
||||
)
|
||||
@Composable
|
||||
fun AppbarContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
collapsingToolbarState: CollapsingToolbarState,
|
||||
content: @Composable AppbarContainerScope.() -> Unit
|
||||
) {
|
||||
AppBarContainer(
|
||||
modifier = modifier,
|
||||
scrollStrategy = scrollStrategy,
|
||||
collapsingToolbarState = collapsingToolbarState,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"AppBarContainer is replaced with CollapsingToolbarScaffold",
|
||||
replaceWith = ReplaceWith(
|
||||
"CollapsingToolbarScaffold",
|
||||
"me.onebone.toolbar"
|
||||
)
|
||||
)
|
||||
@Composable
|
||||
fun AppBarContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
/** The state of a connected collapsing toolbar */
|
||||
collapsingToolbarState: CollapsingToolbarState,
|
||||
content: @Composable AppbarContainerScope.() -> Unit
|
||||
) {
|
||||
val offsetY = remember { mutableStateOf(0) }
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
|
||||
val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) {
|
||||
AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to
|
||||
AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY)
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = { scope.content() },
|
||||
measurePolicy = measurePolicy,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
interface AppbarContainerScope {
|
||||
fun Modifier.appBarBody(): Modifier
|
||||
}
|
||||
|
||||
internal class AppbarContainerScopeImpl(
|
||||
private val nestedScrollConnection: NestedScrollConnection
|
||||
): AppbarContainerScope {
|
||||
override fun Modifier.appBarBody(): Modifier {
|
||||
return this
|
||||
.then(AppBarBodyMarkerModifier)
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
}
|
||||
}
|
||||
|
||||
private object AppBarBodyMarkerModifier: ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return AppBarBodyMarker
|
||||
}
|
||||
}
|
||||
|
||||
private object AppBarBodyMarker
|
||||
|
||||
private class AppbarMeasurePolicy(
|
||||
private val scrollStrategy: ScrollStrategy,
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val offsetY: State<Int>
|
||||
): MeasurePolicy {
|
||||
override fun MeasureScope.measure(
|
||||
measurables: List<Measurable>,
|
||||
constraints: Constraints
|
||||
): MeasureResult {
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
var toolbarPlaceable: Placeable? = null
|
||||
|
||||
val nonToolbars = measurables.filter {
|
||||
val data = it.parentData
|
||||
if(data != AppBarBodyMarker) {
|
||||
if(toolbarPlaceable != null)
|
||||
throw IllegalStateException("There cannot exist multiple toolbars under single parent")
|
||||
|
||||
val placeable = it.measure(constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
))
|
||||
width = max(width, placeable.width)
|
||||
height = max(height, placeable.height)
|
||||
|
||||
toolbarPlaceable = placeable
|
||||
|
||||
false
|
||||
}else{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val placeables = nonToolbars.map { measurable ->
|
||||
val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) {
|
||||
constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight)
|
||||
)
|
||||
}else{
|
||||
constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
)
|
||||
}
|
||||
|
||||
val placeable = measurable.measure(childConstraints)
|
||||
|
||||
width = max(width, placeable.width)
|
||||
height = max(height, placeable.height)
|
||||
|
||||
placeable
|
||||
}
|
||||
|
||||
height += (toolbarPlaceable?.height ?: 0)
|
||||
|
||||
return layout(
|
||||
width.coerceIn(constraints.minWidth, constraints.maxWidth),
|
||||
height.coerceIn(constraints.minHeight, constraints.maxHeight)
|
||||
) {
|
||||
toolbarPlaceable?.place(x = 0, y = offsetY.value)
|
||||
|
||||
placeables.forEach { placeable ->
|
||||
placeable.place(
|
||||
x = 0,
|
||||
y = offsetY.value + (toolbarPlaceable?.height ?: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollScope
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
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.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class CollapsingToolbarState(
|
||||
initial: Int = Int.MAX_VALUE
|
||||
): ScrollableState {
|
||||
/**
|
||||
* [height] indicates current height of the toolbar.
|
||||
*/
|
||||
var height: Int by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
/**
|
||||
* [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar
|
||||
* may collapse its height to [minHeight] but not smaller. This size is determined by
|
||||
* the smallest child.
|
||||
*/
|
||||
var minHeight: Int
|
||||
get() = minHeightState
|
||||
internal set(value) {
|
||||
minHeightState = value
|
||||
|
||||
if(height < value) {
|
||||
height = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar
|
||||
* may expand its height to [maxHeight] but not larger. This size is determined by
|
||||
* the largest child.
|
||||
*/
|
||||
var maxHeight: Int
|
||||
get() = maxHeightState
|
||||
internal set(value) {
|
||||
maxHeightState = value
|
||||
|
||||
if(value < height) {
|
||||
height = value
|
||||
}
|
||||
}
|
||||
|
||||
private var maxHeightState by mutableStateOf(Int.MAX_VALUE)
|
||||
private var minHeightState by mutableStateOf(0)
|
||||
|
||||
val progress: Float
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
get() =
|
||||
if(minHeight == maxHeight) {
|
||||
0f
|
||||
}else{
|
||||
((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private val scrollableState = ScrollableState { value ->
|
||||
val consume = if(value < 0) {
|
||||
max(minHeight.toFloat() - height, value)
|
||||
}else{
|
||||
min(maxHeight.toFloat() - height, value)
|
||||
}
|
||||
|
||||
val current = consume + deferredConsumption
|
||||
val currentInt = current.toInt()
|
||||
|
||||
if(current.absoluteValue > 0) {
|
||||
height += currentInt
|
||||
deferredConsumption = current - currentInt
|
||||
}
|
||||
|
||||
consume
|
||||
}
|
||||
|
||||
private var deferredConsumption: Float = 0f
|
||||
|
||||
/**
|
||||
* @return consumed scroll value is returned
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "feedScroll() is deprecated, use dispatchRawDelta() instead.",
|
||||
replaceWith = ReplaceWith("dispatchRawDelta(value)")
|
||||
)
|
||||
fun feedScroll(value: Float): Float = dispatchRawDelta(value)
|
||||
|
||||
@ExperimentalToolbarApi
|
||||
suspend fun expand(duration: Int = 200) {
|
||||
val anim = AnimationState(height.toFloat())
|
||||
|
||||
scroll {
|
||||
var prev = anim.value
|
||||
anim.animateTo(maxHeight.toFloat(), tween(duration)) {
|
||||
scrollBy(value - prev)
|
||||
prev = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalToolbarApi
|
||||
suspend fun collapse(duration: Int = 200) {
|
||||
val anim = AnimationState(height.toFloat())
|
||||
|
||||
scroll {
|
||||
var prev = anim.value
|
||||
anim.animateTo(minHeight.toFloat(), tween(duration)) {
|
||||
scrollBy(value - prev)
|
||||
prev = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Remaining velocity after fling
|
||||
*/
|
||||
suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float {
|
||||
var left = velocity
|
||||
scroll {
|
||||
with(flingBehavior) {
|
||||
left = performFling(left)
|
||||
}
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
override val isScrollInProgress: Boolean
|
||||
get() = scrollableState.isScrollInProgress
|
||||
|
||||
override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
|
||||
|
||||
override suspend fun scroll(
|
||||
scrollPriority: MutatePriority,
|
||||
block: suspend ScrollScope.() -> Unit
|
||||
) = scrollableState.scroll(scrollPriority, block)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCollapsingToolbarState(
|
||||
initial: Int = Int.MAX_VALUE
|
||||
): CollapsingToolbarState {
|
||||
return remember {
|
||||
CollapsingToolbarState(
|
||||
initial = initial
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollapsingToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
clipToBounds: Boolean = true,
|
||||
collapsingToolbarState: CollapsingToolbarState,
|
||||
content: @Composable CollapsingToolbarScope.() -> Unit
|
||||
) {
|
||||
val measurePolicy = remember(collapsingToolbarState) {
|
||||
CollapsingToolbarMeasurePolicy(collapsingToolbarState)
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = { CollapsingToolbarScopeInstance.content() },
|
||||
measurePolicy = measurePolicy,
|
||||
modifier = modifier.then(
|
||||
if (clipToBounds) {
|
||||
Modifier.clipToBounds()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private class CollapsingToolbarMeasurePolicy(
|
||||
private val collapsingToolbarState: CollapsingToolbarState
|
||||
): MeasurePolicy {
|
||||
override fun MeasureScope.measure(
|
||||
measurables: List<Measurable>,
|
||||
constraints: Constraints
|
||||
): MeasureResult {
|
||||
val placeables = measurables.map {
|
||||
it.measure(
|
||||
constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = Constraints.Infinity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val placeStrategy = measurables.map { it.parentData }
|
||||
|
||||
val minHeight = placeables.minOfOrNull { it.height }
|
||||
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
|
||||
|
||||
val maxHeight = placeables.maxOfOrNull { it.height }
|
||||
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
|
||||
|
||||
val maxWidth = placeables.maxOfOrNull{ it.width }
|
||||
?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0
|
||||
|
||||
collapsingToolbarState.also {
|
||||
it.minHeight = minHeight
|
||||
it.maxHeight = maxHeight
|
||||
}
|
||||
|
||||
val height = collapsingToolbarState.height
|
||||
return layout(maxWidth, height) {
|
||||
val progress = collapsingToolbarState.progress
|
||||
|
||||
placeables.forEachIndexed { i, placeable ->
|
||||
val strategy = placeStrategy[i]
|
||||
if(strategy is CollapsingToolbarData) {
|
||||
strategy.progressListener?.onProgressUpdate(progress)
|
||||
}
|
||||
|
||||
when(strategy) {
|
||||
is CollapsingToolbarRoadData -> {
|
||||
val collapsed = strategy.whenCollapsed
|
||||
val expanded = strategy.whenExpanded
|
||||
|
||||
val collapsedOffset = collapsed.align(
|
||||
size = IntSize(placeable.width, placeable.height),
|
||||
space = IntSize(maxWidth, height),
|
||||
layoutDirection = layoutDirection
|
||||
)
|
||||
|
||||
val expandedOffset = expanded.align(
|
||||
size = IntSize(placeable.width, placeable.height),
|
||||
space = IntSize(maxWidth, height),
|
||||
layoutDirection = layoutDirection
|
||||
)
|
||||
|
||||
val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress
|
||||
|
||||
placeable.place(offset.x, offset.y)
|
||||
}
|
||||
is CollapsingToolbarParallaxData ->
|
||||
placeable.placeRelative(
|
||||
x = 0,
|
||||
y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt()
|
||||
)
|
||||
else -> placeable.placeRelative(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CollapsingToolbarScope {
|
||||
fun Modifier.progress(listener: ProgressListener): Modifier
|
||||
|
||||
fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier
|
||||
|
||||
fun Modifier.parallax(ratio: Float = 0.2f): Modifier
|
||||
|
||||
fun Modifier.pin(): Modifier
|
||||
}
|
||||
|
||||
internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope {
|
||||
override fun Modifier.progress(listener: ProgressListener): Modifier {
|
||||
return this.then(ProgressUpdateListenerModifier(listener))
|
||||
}
|
||||
|
||||
override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier {
|
||||
return this.then(RoadModifier(whenCollapsed, whenExpanded))
|
||||
}
|
||||
|
||||
override fun Modifier.parallax(ratio: Float): Modifier {
|
||||
return this.then(ParallaxModifier(ratio))
|
||||
}
|
||||
|
||||
override fun Modifier.pin(): Modifier {
|
||||
return this.then(PinModifier())
|
||||
}
|
||||
}
|
||||
|
||||
internal class RoadModifier(
|
||||
private val whenCollapsed: Alignment,
|
||||
private val whenExpanded: Alignment
|
||||
): ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarRoadData(
|
||||
this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded,
|
||||
(parentData as? CollapsingToolbarData)?.progressListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ParallaxModifier(
|
||||
private val ratio: Float
|
||||
): ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener)
|
||||
}
|
||||
}
|
||||
|
||||
internal class PinModifier: ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ProgressUpdateListenerModifier(
|
||||
private val listener: ProgressListener
|
||||
): ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarProgressData(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface ProgressListener {
|
||||
fun onProgressUpdate(value: Float)
|
||||
}
|
||||
|
||||
internal sealed class CollapsingToolbarData(
|
||||
var progressListener: ProgressListener?
|
||||
)
|
||||
|
||||
internal class CollapsingToolbarProgressData(
|
||||
progressListener: ProgressListener?
|
||||
): CollapsingToolbarData(progressListener)
|
||||
|
||||
internal class CollapsingToolbarRoadData(
|
||||
var whenCollapsed: Alignment,
|
||||
var whenExpanded: Alignment,
|
||||
progressListener: ProgressListener? = null
|
||||
): CollapsingToolbarData(progressListener)
|
||||
|
||||
internal class CollapsingToolbarPinData(
|
||||
progressListener: ProgressListener? = null
|
||||
): CollapsingToolbarData(progressListener)
|
||||
|
||||
internal class CollapsingToolbarParallaxData(
|
||||
var ratio: Float,
|
||||
progressListener: ProgressListener? = null
|
||||
): CollapsingToolbarData(progressListener)
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.max
|
||||
|
||||
@Stable
|
||||
class CollapsingToolbarScaffoldState(
|
||||
val toolbarState: CollapsingToolbarState,
|
||||
initialOffsetY: Int = 0
|
||||
) {
|
||||
val offsetY: Int
|
||||
get() = offsetYState.value
|
||||
|
||||
internal val offsetYState = mutableStateOf(initialOffsetY)
|
||||
}
|
||||
|
||||
private class CollapsingToolbarScaffoldStateSaver: Saver<CollapsingToolbarScaffoldState, List<Any>> {
|
||||
override fun restore(value: List<Any>): CollapsingToolbarScaffoldState =
|
||||
CollapsingToolbarScaffoldState(
|
||||
CollapsingToolbarState(value[0] as Int),
|
||||
value[1] as Int
|
||||
)
|
||||
|
||||
override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List<Any> =
|
||||
listOf(
|
||||
value.toolbarState.height,
|
||||
value.offsetY
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCollapsingToolbarScaffoldState(
|
||||
toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState()
|
||||
): CollapsingToolbarScaffoldState {
|
||||
return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) {
|
||||
CollapsingToolbarScaffoldState(toolbarState)
|
||||
}
|
||||
}
|
||||
|
||||
interface CollapsingToolbarScaffoldScope {
|
||||
@ExperimentalToolbarApi
|
||||
fun Modifier.align(alignment: Alignment): Modifier
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollapsingToolbarScaffold(
|
||||
modifier: Modifier,
|
||||
state: CollapsingToolbarScaffoldState,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
enabled: Boolean = true,
|
||||
toolbarModifier: Modifier = Modifier,
|
||||
toolbarClipToBounds: Boolean = true,
|
||||
toolbarScrollable: Boolean = false,
|
||||
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
|
||||
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
|
||||
) {
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
val nestedScrollConnection = remember(scrollStrategy, state) {
|
||||
scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior)
|
||||
}
|
||||
|
||||
val toolbarState = state.toolbarState
|
||||
val toolbarScrollState = rememberScrollState()
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
CollapsingToolbar(
|
||||
modifier = toolbarModifier,
|
||||
clipToBounds = toolbarClipToBounds,
|
||||
collapsingToolbarState = toolbarState,
|
||||
) {
|
||||
ToolbarScrollableBox(
|
||||
enabled,
|
||||
toolbarScrollable,
|
||||
toolbarState,
|
||||
toolbarScrollState
|
||||
)
|
||||
toolbar(toolbarScrollState)
|
||||
}
|
||||
|
||||
CollapsingToolbarScaffoldScopeInstance.body()
|
||||
},
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier.nestedScroll(nestedScrollConnection)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) { measurables, constraints ->
|
||||
check(measurables.size >= 2) {
|
||||
"the number of children should be at least 2: toolbar, (at least one) body"
|
||||
}
|
||||
|
||||
val toolbarConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
)
|
||||
val bodyConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = when (scrollStrategy) {
|
||||
ScrollStrategy.ExitUntilCollapsed ->
|
||||
(constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0)
|
||||
|
||||
ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed ->
|
||||
constraints.maxHeight
|
||||
}
|
||||
)
|
||||
|
||||
val toolbarPlaceable = measurables[0].measure(toolbarConstraints)
|
||||
|
||||
val bodyMeasurables = measurables.subList(1, measurables.size)
|
||||
val childrenAlignments = bodyMeasurables.map {
|
||||
(it.parentData as? ScaffoldParentData)?.alignment
|
||||
}
|
||||
val bodyPlaceables = bodyMeasurables.map {
|
||||
it.measure(bodyConstraints)
|
||||
}
|
||||
|
||||
val toolbarHeight = toolbarPlaceable.height
|
||||
|
||||
val width = max(
|
||||
toolbarPlaceable.width,
|
||||
bodyPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
).coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||
val height = max(
|
||||
toolbarHeight,
|
||||
bodyPlaceables.maxOfOrNull { it.height } ?: 0
|
||||
).coerceIn(constraints.minHeight, constraints.maxHeight)
|
||||
|
||||
layout(width, height) {
|
||||
bodyPlaceables.forEachIndexed { index, placeable ->
|
||||
val alignment = childrenAlignments[index]
|
||||
|
||||
if (alignment == null) {
|
||||
placeable.placeRelative(0, toolbarHeight + state.offsetY)
|
||||
} else {
|
||||
val offset = alignment.align(
|
||||
size = IntSize(placeable.width, placeable.height),
|
||||
space = IntSize(width, height),
|
||||
layoutDirection = layoutDirection
|
||||
)
|
||||
placeable.place(offset)
|
||||
}
|
||||
}
|
||||
toolbarPlaceable.placeRelative(0, state.offsetY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarScrollableBox(
|
||||
enabled: Boolean,
|
||||
toolbarScrollable: Boolean,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
toolbarScrollState: ScrollState
|
||||
) {
|
||||
val toolbarScrollableEnabled = enabled && toolbarScrollable
|
||||
if (toolbarScrollableEnabled && toolbarState.height != Constraints.Infinity) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(with(LocalDensity.current) { toolbarState.height.toDp() })
|
||||
.verticalScroll(state = toolbarScrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope {
|
||||
@ExperimentalToolbarApi
|
||||
override fun Modifier.align(alignment: Alignment): Modifier =
|
||||
this.then(ScaffoldChildAlignmentModifier(alignment))
|
||||
}
|
||||
|
||||
private class ScaffoldChildAlignmentModifier(
|
||||
private val alignment: Alignment
|
||||
) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ScaffoldParentData(
|
||||
var alignment: Alignment? = null
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
class FabPlacement(
|
||||
val left: Int,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
enum class FabPosition {
|
||||
Center,
|
||||
End
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
|
||||
enum class ScrollStrategy {
|
||||
EnterAlways {
|
||||
override fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection =
|
||||
EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior)
|
||||
},
|
||||
EnterAlwaysCollapsed {
|
||||
override fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection =
|
||||
EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior)
|
||||
},
|
||||
ExitUntilCollapsed {
|
||||
override fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection =
|
||||
ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior)
|
||||
};
|
||||
|
||||
internal abstract fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection
|
||||
}
|
||||
|
||||
private class ScrollDelegate(
|
||||
private val offsetY: MutableState<Int>
|
||||
) {
|
||||
private var scrollToBeConsumed: Float = 0f
|
||||
|
||||
fun doScroll(delta: Float) {
|
||||
val scroll = scrollToBeConsumed + delta
|
||||
val scrollInt = scroll.toInt()
|
||||
|
||||
scrollToBeConsumed = scroll - scrollInt
|
||||
|
||||
offsetY.value += scrollInt
|
||||
}
|
||||
}
|
||||
|
||||
internal class EnterAlwaysNestedScrollConnection(
|
||||
private val offsetY: MutableState<Int>,
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection {
|
||||
private val scrollDelegate = ScrollDelegate(offsetY)
|
||||
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val toolbar = toolbarState.height.toFloat()
|
||||
val offset = offsetY.value.toFloat()
|
||||
|
||||
// -toolbarHeight <= offsetY + dy <= 0
|
||||
val consume = if(dy < 0) {
|
||||
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
|
||||
val remaining = dy - toolbarConsumption
|
||||
val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset)
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
toolbarConsumption + offsetConsumption
|
||||
}else{
|
||||
val offsetConsumption = dy.coerceAtMost(-offset)
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption)
|
||||
|
||||
offsetConsumption + toolbarConsumption
|
||||
}
|
||||
|
||||
return Offset(0f, consume)
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val left = if(available.y > 0) {
|
||||
toolbarState.fling(flingBehavior, available.y)
|
||||
}else{
|
||||
// If velocity < 0, the main content should have a remaining scroll space
|
||||
// so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do
|
||||
// not need to process it at onPostFling() manually.
|
||||
available.y
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
}
|
||||
|
||||
internal class EnterAlwaysCollapsedNestedScrollConnection(
|
||||
private val offsetY: MutableState<Int>,
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection {
|
||||
private val scrollDelegate = ScrollDelegate(offsetY)
|
||||
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar
|
||||
val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat())
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
offsetConsumption
|
||||
}else{ // collapsing: toolbar -> offset -> body
|
||||
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
|
||||
val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value)
|
||||
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
toolbarConsumption + offsetConsumption
|
||||
}
|
||||
|
||||
return Offset(0f, consumed)
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val dy = available.y
|
||||
|
||||
return if(dy > 0) {
|
||||
Offset(0f, toolbarState.dispatchRawDelta(dy))
|
||||
}else{
|
||||
Offset(0f, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
val dy = available.y
|
||||
|
||||
val left = if(dy > 0) {
|
||||
// onPostFling() has positive available scroll value only called if the main scroll
|
||||
// has leftover scroll, i.e. the scroll of the main content has done. So we just process
|
||||
// fling if the available value is positive.
|
||||
toolbarState.fling(flingBehavior, dy)
|
||||
}else{
|
||||
dy
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ExitUntilCollapsedNestedScrollConnection(
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val consume = if(dy < 0) { // collapsing: toolbar -> body
|
||||
toolbarState.dispatchRawDelta(dy)
|
||||
}else{
|
||||
0f
|
||||
}
|
||||
|
||||
return Offset(0f, consume)
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val consume = if(dy > 0) { // expanding: body -> toolbar
|
||||
toolbarState.dispatchRawDelta(dy)
|
||||
}else{
|
||||
0f
|
||||
}
|
||||
|
||||
return Offset(0f, consume)
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val left = if(available.y < 0) {
|
||||
toolbarState.fling(flingBehavior, available.y)
|
||||
}else{
|
||||
available.y
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
val velocity = available.y
|
||||
|
||||
val left = if(velocity > 0) {
|
||||
toolbarState.fling(flingBehavior, velocity)
|
||||
}else{
|
||||
velocity
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@ExperimentalToolbarApi
|
||||
@Composable
|
||||
fun ToolbarWithFabScaffold(
|
||||
modifier: Modifier,
|
||||
state: CollapsingToolbarScaffoldState,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
toolbarModifier: Modifier = Modifier,
|
||||
toolbarClipToBounds: Boolean = true,
|
||||
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
|
||||
toolbarScrollable: Boolean = false,
|
||||
fab: @Composable () -> Unit,
|
||||
fabPosition: FabPosition = FabPosition.End,
|
||||
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
|
||||
) {
|
||||
SubcomposeLayout(
|
||||
modifier = modifier
|
||||
) { constraints ->
|
||||
|
||||
val toolbarScaffoldConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = constraints.maxHeight
|
||||
)
|
||||
|
||||
val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) {
|
||||
CollapsingToolbarScaffold(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
scrollStrategy = scrollStrategy,
|
||||
toolbarModifier = toolbarModifier,
|
||||
toolbarClipToBounds = toolbarClipToBounds,
|
||||
toolbar = toolbar,
|
||||
body = body,
|
||||
toolbarScrollable = toolbarScrollable
|
||||
)
|
||||
}.map { it.measure(toolbarScaffoldConstraints) }
|
||||
|
||||
val fabConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
)
|
||||
|
||||
val fabPlaceables = subcompose(
|
||||
ToolbarWithFabScaffoldContent.Fab,
|
||||
fab
|
||||
).mapNotNull { measurable ->
|
||||
measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 }
|
||||
}
|
||||
|
||||
val fabPlacement = if (fabPlaceables.isNotEmpty()) {
|
||||
val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0
|
||||
// FAB distance from the left of the layout, taking into account LTR / RTL
|
||||
val fabLeftOffset = if (fabPosition == FabPosition.End) {
|
||||
if (layoutDirection == LayoutDirection.Ltr) {
|
||||
constraints.maxWidth - 16.dp.roundToPx() - fabWidth
|
||||
} else {
|
||||
16.dp.roundToPx()
|
||||
}
|
||||
} else {
|
||||
(constraints.maxWidth - fabWidth) / 2
|
||||
}
|
||||
|
||||
FabPlacement(
|
||||
left = fabLeftOffset,
|
||||
width = fabWidth,
|
||||
height = fabHeight
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val fabOffsetFromBottom = fabPlacement?.let {
|
||||
it.height + 16.dp.roundToPx()
|
||||
}
|
||||
|
||||
val width = constraints.maxWidth
|
||||
val height = constraints.maxHeight
|
||||
|
||||
layout(width, height) {
|
||||
toolbarScaffoldPlaceables.forEach {
|
||||
it.place(0, 0)
|
||||
}
|
||||
|
||||
fabPlacement?.let { placement ->
|
||||
fabPlaceables.forEach {
|
||||
it.place(placement.left, height - fabOffsetFromBottom!!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ToolbarWithFabScaffoldContent {
|
||||
ToolbarScaffold, Fab
|
||||
}
|
||||
Reference in New Issue
Block a user