mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Refactor the whole project structure
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.imeNestedScroll
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
actual fun Modifier.imeScrollWithKeyboard(): Modifier = this.imeNestedScroll()
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
@Composable
|
||||
actual fun rememberSystemBarsController(): ((Boolean) -> Unit)? {
|
||||
val view = LocalView.current
|
||||
return remember(view) {
|
||||
val activity = view.context as? Activity ?: return@remember null
|
||||
val window = activity.window ?: return@remember null
|
||||
val controller = WindowCompat.getInsetsController(window, view);
|
||||
|
||||
{ visible: Boolean ->
|
||||
if (visible) {
|
||||
controller.show(WindowInsetsCompat.Type.statusBars())
|
||||
} else {
|
||||
controller.hide(WindowInsetsCompat.Type.statusBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -830,4 +830,29 @@ inline fun String.decodeWindows1251() = String(
|
||||
)
|
||||
|
||||
@get:ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S)
|
||||
actual val materialYouAvailable get() = Build.VERSION.SDK_INT >= 31
|
||||
actual val materialYouAvailable get() = Build.VERSION.SDK_INT >= 31
|
||||
|
||||
private fun String?.trimIfNotBlank(): String? =
|
||||
this?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
actual fun currentDeviceInfo(): CurrentDeviceInfo {
|
||||
val osName = "Android"
|
||||
val osVersion = Build.VERSION.RELEASE.trimIfNotBlank() ?: Build.VERSION.SDK_INT.toString()
|
||||
val brand = Build.BRAND.trimIfNotBlank() ?: Build.MANUFACTURER.trimIfNotBlank()
|
||||
val model = Build.MODEL.trimIfNotBlank()
|
||||
|
||||
return CurrentDeviceInfo(
|
||||
osName = osName,
|
||||
osVersion = osVersion,
|
||||
deviceType = "mobile",
|
||||
deviceName = model?.let {
|
||||
if (it.contains(
|
||||
brand.orEmpty(),
|
||||
ignoreCase = true
|
||||
)
|
||||
) it else listOfNotNull(brand, it).joinToString(" ")
|
||||
},
|
||||
brand = brand,
|
||||
model = model
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
@file:Suppress("UnusedReceiverParameter", "NOTHING_TO_INLINE")
|
||||
|
||||
package com.pr0gramm3r101.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.calculateEndPadding
|
||||
import androidx.compose.foundation.layout.calculateStartPadding
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults.cardColors
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
object CategoryDefaults {
|
||||
val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp)
|
||||
val containerColor @Composable get() = MaterialTheme.colorScheme.surfaceContainer
|
||||
val dividerThickness: Dp = 3.dp
|
||||
val shape @Composable get() = MaterialTheme.shapes.extraLarge
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryBase(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = CategoryDefaults.containerColor,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalDividerColor provides Color.Transparent,
|
||||
LocalDividerThickness provides CategoryDefaults.dividerThickness,
|
||||
LocalContainerColor provides containerColor
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(
|
||||
bottom = 10.dp,
|
||||
start = margin.calculateStartPadding(
|
||||
LocalLayoutDirection.current
|
||||
) + 16.dp,
|
||||
end = margin.calculateEndPadding(
|
||||
LocalLayoutDirection.current,
|
||||
) + 16.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(margin)
|
||||
.fillMaxWidth(),
|
||||
shape = CategoryDefaults.shape,
|
||||
colors = cardColors(containerColor = backgroundColor),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.Category(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = CategoryDefaults.containerColor,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
CategoryBase(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
margin = margin,
|
||||
containerColor = containerColor,
|
||||
backgroundColor = backgroundColor,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ColumnScope.Category(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = CategoryDefaults.containerColor,
|
||||
backgroundColor: Color = Color.Transparent,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
CategoryBase(
|
||||
modifier = modifier,
|
||||
title = title,
|
||||
margin = margin,
|
||||
containerColor = containerColor,
|
||||
backgroundColor = backgroundColor,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
class CategoryScope {
|
||||
internal val items = mutableListOf<@Composable () -> Unit>()
|
||||
|
||||
fun item(content: @Composable () -> Unit) {
|
||||
items.add(content)
|
||||
}
|
||||
}
|
||||
|
||||
fun LazyListScope.Category(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color? = null,
|
||||
content: CategoryScope.() -> Unit
|
||||
) {
|
||||
val scope = CategoryScope().apply(content)
|
||||
val items = scope.items
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(margin.calculateTopPadding()))
|
||||
}
|
||||
|
||||
if (title != null) {
|
||||
item {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(
|
||||
bottom = 10.dp,
|
||||
start = margin.calculateStartPadding(LocalLayoutDirection.current) + 16.dp,
|
||||
end = margin.calculateEndPadding(LocalLayoutDirection.current) + 16.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items.forEachIndexed { index, composableItem ->
|
||||
item {
|
||||
CompositionLocalProvider(
|
||||
LocalDividerColor provides Color.Transparent,
|
||||
LocalDividerThickness provides CategoryDefaults.dividerThickness,
|
||||
LocalContainerColor provides (containerColor ?: CategoryDefaults.containerColor)
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(
|
||||
start = margin.calculateStartPadding(LocalLayoutDirection.current),
|
||||
end = margin.calculateEndPadding(LocalLayoutDirection.current)
|
||||
)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart =
|
||||
if (index == 0)
|
||||
CategoryDefaults.shape.topStart
|
||||
else CornerSize(0.dp),
|
||||
topEnd =
|
||||
if (index == 0)
|
||||
CategoryDefaults.shape.topEnd
|
||||
else CornerSize(0.dp),
|
||||
bottomStart =
|
||||
if (index == items.lastIndex)
|
||||
CategoryDefaults.shape.bottomStart
|
||||
else CornerSize(0.dp),
|
||||
bottomEnd =
|
||||
if (index == items.lastIndex)
|
||||
CategoryDefaults.shape.bottomEnd
|
||||
else CornerSize(0.dp)
|
||||
)
|
||||
)
|
||||
) {
|
||||
composableItem()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.height(margin.calculateBottomPadding()))
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButtonColors
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.ListItemDefaults
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ProvideTextStyle
|
||||
@@ -68,6 +67,7 @@ import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -77,12 +77,14 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.pr0gramm3r101.utils.conditional
|
||||
import com.pr0gramm3r101.utils.invoke
|
||||
import com.pr0gramm3r101.utils.left
|
||||
import com.pr0gramm3r101.utils.link
|
||||
@@ -95,6 +97,18 @@ import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope
|
||||
import tech.annexflow.constraintlayout.compose.Dimension
|
||||
import kotlin.random.Random
|
||||
|
||||
val LocalDividerColor = compositionLocalOf<Color?> { null }
|
||||
val LocalDividerThickness = compositionLocalOf<Dp?> { null }
|
||||
val LocalBeforeDividerRadius = compositionLocalOf<Dp?> { null }
|
||||
val LocalContainerColor = compositionLocalOf<Color?> { null }
|
||||
|
||||
object ListItemDefaults {
|
||||
val dividerColor @Composable get() = DividerDefaults.color
|
||||
val thickness = DividerDefaults.Thickness
|
||||
val beforeDividerRadius = 8.dp
|
||||
val containerColor = Color.Transparent
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ListItem(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -103,11 +117,13 @@ fun ListItem(
|
||||
supportingText: String? = null,
|
||||
leadingContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: (@Composable ConstraintLayoutScope.() -> Unit)? = null,
|
||||
containerColor: Color = LocalContainerColor.current ?: ListItemDefaults.containerColor,
|
||||
enabled: Boolean = true,
|
||||
divider: Boolean = false,
|
||||
dividerColor: Color = DividerDefaults.color,
|
||||
dividerThickness: Dp = DividerDefaults.Thickness,
|
||||
dividerColor: Color = LocalDividerColor.current ?: ListItemDefaults.dividerColor,
|
||||
dividerThickness: Dp = LocalDividerThickness.current ?: ListItemDefaults.thickness,
|
||||
dividerAnimated: Boolean = false,
|
||||
beforeDividerRadius: Dp = LocalBeforeDividerRadius.current ?: ListItemDefaults.beforeDividerRadius,
|
||||
onClick: (() -> Unit)? = null,
|
||||
bodyOnClick: (() -> Unit)? = null,
|
||||
leadingAndBodyShared: Boolean = false,
|
||||
@@ -134,13 +150,14 @@ fun ListItem(
|
||||
|
||||
ProvideStyle {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier.let {
|
||||
var mod = it.fillMaxWidth()
|
||||
if (onClick != null) {
|
||||
mod += Modifier.clickable(onClick = onClick)
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(beforeDividerRadius))
|
||||
.background(containerColor)
|
||||
.conditional(onClick != null) {
|
||||
clickable(onClick = onClick!!)
|
||||
}
|
||||
mod + modifier
|
||||
}
|
||||
.then(modifier)
|
||||
) {
|
||||
val (leading, listItem, trailing, btm) = createRefs()
|
||||
|
||||
@@ -167,31 +184,30 @@ fun ListItem(
|
||||
leadingContent()
|
||||
}
|
||||
}
|
||||
|
||||
ListItem(
|
||||
headlineContent = { Text(headline) },
|
||||
supportingContent = { if (supportingText != null) Text(supportingText) },
|
||||
modifier = Modifier
|
||||
.let {
|
||||
var mod = it.constrainAs(listItem) {
|
||||
top link parent.top
|
||||
bottom link
|
||||
if (bottomContent != null) btm.top
|
||||
else parent.bottom
|
||||
.constrainAs(listItem) {
|
||||
top link parent.top
|
||||
bottom link
|
||||
if (bottomContent != null) btm.top
|
||||
else parent.bottom
|
||||
left link
|
||||
if (leadingContent != null) leading.right
|
||||
else parent.left
|
||||
right link
|
||||
if (trailingContent != null) trailing.left
|
||||
else parent.right
|
||||
|
||||
left link
|
||||
if (leadingContent != null) leading.right
|
||||
else parent.left
|
||||
right link
|
||||
if (trailingContent != null) trailing.left
|
||||
else parent.right
|
||||
width = Dimension.fillToConstraints
|
||||
}
|
||||
if (bodyOnClick != null && !leadingAndBodyShared) {
|
||||
mod += Modifier.clickable(onClick = bodyOnClick)
|
||||
}
|
||||
mod + bodyModifier
|
||||
},
|
||||
colors = ListItemDefaults.colors(
|
||||
width = Dimension.fillToConstraints
|
||||
}
|
||||
.conditional(bodyOnClick != null && !leadingAndBodyShared) {
|
||||
Modifier.clickable(onClick = bodyOnClick!!)
|
||||
}
|
||||
.then(bodyModifier),
|
||||
colors = androidx.compose.material3.ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
@@ -203,19 +219,17 @@ fun ListItem(
|
||||
.constrainAs(listItem) {
|
||||
top link parent.top
|
||||
bottom link
|
||||
if (bottomContent != null) btm.top
|
||||
else parent.bottom
|
||||
if (bottomContent != null) btm.top
|
||||
else parent.bottom
|
||||
left link parent.left
|
||||
right link
|
||||
if (trailingContent != null) trailing.left
|
||||
else parent.right
|
||||
if (trailingContent != null) trailing.left
|
||||
else parent.right
|
||||
width = Dimension.fillToConstraints
|
||||
}
|
||||
+
|
||||
if (bodyOnClick != null)
|
||||
Modifier.clickable(onClick = bodyOnClick)
|
||||
else
|
||||
Modifier,
|
||||
.conditional(bodyOnClick != null) {
|
||||
Modifier.clickable(onClick = bodyOnClick!!)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
leadingBody()
|
||||
@@ -243,6 +257,7 @@ fun ListItem(
|
||||
content = trailingContent
|
||||
)
|
||||
}
|
||||
|
||||
if (bottomContent != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -259,22 +274,26 @@ fun ListItem(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (dividerAnimated) {
|
||||
AnimatedVisibility(
|
||||
visible = divider,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
|
||||
when {
|
||||
dividerAnimated && divider -> {
|
||||
AnimatedVisibility(
|
||||
visible = divider,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
HorizontalDivider(
|
||||
color = dividerColor,
|
||||
thickness = dividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
divider -> {
|
||||
HorizontalDivider(
|
||||
color = dividerColor,
|
||||
thickness = dividerThickness
|
||||
)
|
||||
}
|
||||
} else if (divider) {
|
||||
HorizontalDivider(
|
||||
color = dividerColor,
|
||||
thickness = dividerThickness
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,8 +308,8 @@ inline fun SwitchListItem(
|
||||
noinline onCheckedChange: (Boolean) -> Unit,
|
||||
crossinline listItemOnClick: () -> Unit = { onCheckedChange(!checked) },
|
||||
divider: Boolean = false,
|
||||
dividerColor: Color = DividerDefaults.color,
|
||||
dividerThickness: Dp = DividerDefaults.Thickness,
|
||||
dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
|
||||
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
|
||||
dividerAnimated: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
@@ -337,8 +356,8 @@ inline fun SeparatedSwitchListItem(
|
||||
noinline onCheckedChange: (Boolean) -> Unit,
|
||||
noinline bodyOnClick: () -> Unit,
|
||||
divider: Boolean = false,
|
||||
dividerColor: Color = DividerDefaults.color,
|
||||
dividerThickness: Dp = DividerDefaults.Thickness,
|
||||
dividerColor: Color = LocalDividerColor.current ?: DividerDefaults.color,
|
||||
dividerThickness: Dp = LocalDividerThickness.current ?: DividerDefaults.Thickness,
|
||||
dividerAnimated: Boolean = false,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
@@ -1062,34 +1081,3 @@ inline fun SwipeToDismissBackground(
|
||||
}
|
||||
}
|
||||
|
||||
object CategoryDefaults {
|
||||
val margin = PaddingValues(start = 16.dp, end = 16.dp, bottom = 20.dp)
|
||||
val dividerColor @Composable get() = MaterialTheme.colorScheme.surface
|
||||
val dividerThickness: Dp = 2.dp
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun ColumnScope.Category(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
margin: PaddingValues = CategoryDefaults.margin,
|
||||
containerColor: Color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
noinline content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
if (title != null) {
|
||||
Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(bottom = 10.dp, start = 32.dp, end = 32.dp)
|
||||
)
|
||||
}
|
||||
Card(
|
||||
modifier = modifier
|
||||
.padding(margin)
|
||||
.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
colors = cardColors(containerColor = containerColor),
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,41 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.animation.core.AnimationSpec
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.Indication
|
||||
import androidx.compose.foundation.OverscrollEffect
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.exclude
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.composed
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.navigation.NavController
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Navigates to the specified route and clears the entire back stack.
|
||||
@@ -44,4 +76,107 @@ inline fun Modifier.conditional(
|
||||
} else {
|
||||
this + `else`(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline fun Dp.toPx(density: Density) = with(density) { this@toPx.toPx() }
|
||||
|
||||
inline fun Modifier.verticalScroll(
|
||||
enabled: Boolean = true,
|
||||
flingBehavior: FlingBehavior? = null,
|
||||
reverseScrolling: Boolean = false
|
||||
) = composed {
|
||||
this@verticalScroll.verticalScroll(
|
||||
rememberScrollState(),
|
||||
enabled,
|
||||
flingBehavior,
|
||||
reverseScrolling
|
||||
)
|
||||
}
|
||||
|
||||
inline fun Modifier.verticalScroll(
|
||||
overscrollEffect: OverscrollEffect?,
|
||||
enabled: Boolean = true,
|
||||
flingBehavior: FlingBehavior? = null,
|
||||
reverseScrolling: Boolean = false
|
||||
) = composed {
|
||||
this@verticalScroll.verticalScroll(
|
||||
rememberScrollState(),
|
||||
overscrollEffect,
|
||||
enabled,
|
||||
flingBehavior,
|
||||
reverseScrolling
|
||||
)
|
||||
}
|
||||
|
||||
inline fun Int.toDp(density: Density) = with(density) { this@toDp.toDp() }
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun WindowInsets.exclude(sides: WindowInsetsSides) = exclude(this.only(sides))
|
||||
|
||||
/**
|
||||
* Scale-down touch effect: scales to [scale] on press and back to 1f on release/cancel.
|
||||
* When [interactionSource] is null: optionally wraps with [clickable] when [onClick] is non-null.
|
||||
* When [interactionSource] is non-null: uses it for scale only (caller adds clickable on child so ripple scales with content).
|
||||
* [indication] when non-null is used for the clickable (e.g. ripple); null = no indication.
|
||||
* [clipShape] when non-null clips the scaled result.
|
||||
* [animationSpec] drives the scale transition (default is a spring).
|
||||
*/
|
||||
fun Modifier.scaleOnPress(
|
||||
scale: Float = 0.96f,
|
||||
onClick: (() -> Unit)? = null,
|
||||
indication: Indication? = null,
|
||||
clipShape: Shape? = null,
|
||||
interactionSource: MutableInteractionSource? = null,
|
||||
animationSpec: AnimationSpec<Float> = spring()
|
||||
): Modifier = composed {
|
||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
||||
var pressed by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(source) {
|
||||
var releaseJob: Job? = null
|
||||
source.interactions.collect { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> {
|
||||
releaseJob?.cancel()
|
||||
releaseJob = null
|
||||
pressed = true
|
||||
}
|
||||
|
||||
is PressInteraction.Release, is PressInteraction.Cancel -> {
|
||||
releaseJob = scope.launch {
|
||||
delay(80)
|
||||
pressed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scaleValue by animateFloatAsState(
|
||||
targetValue = if (pressed) scale else 1f,
|
||||
animationSpec = animationSpec,
|
||||
label = "scaleOnPress"
|
||||
)
|
||||
|
||||
Modifier
|
||||
.conditional(onClick != null && interactionSource == null) {
|
||||
clickable(
|
||||
interactionSource = source,
|
||||
indication = indication,
|
||||
onClick = onClick!!
|
||||
)
|
||||
}
|
||||
.conditional(clipShape != null) { clip(clipShape!!) }
|
||||
.graphicsLayer(
|
||||
scaleX = scaleValue,
|
||||
scaleY = scaleValue,
|
||||
transformOrigin = TransformOrigin.Center
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Android: ties scroll to IME insets for smoother keyboard transitions (see Compose keyboard animations).
|
||||
* Other platforms: no-op.
|
||||
*/
|
||||
expect fun Modifier.imeScrollWithKeyboard(): Modifier
|
||||
@@ -0,0 +1,89 @@
|
||||
@file:Suppress("NOTHING_TO_INLINE")
|
||||
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import io.ktor.client.HttpClientConfig
|
||||
import io.ktor.client.plugins.ClientRequestException
|
||||
import io.ktor.client.plugins.DefaultRequest
|
||||
import io.ktor.client.plugins.ResponseException
|
||||
import io.ktor.client.plugins.ServerResponseException
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.client.plugins.logging.LogLevel
|
||||
import io.ktor.client.plugins.logging.Logger
|
||||
import io.ktor.client.plugins.logging.Logging
|
||||
import io.ktor.client.plugins.logging.LoggingConfig
|
||||
import io.ktor.client.plugins.logging.SIMPLE
|
||||
import io.ktor.client.statement.HttpResponse
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.serialization.Configuration
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonBuilder
|
||||
|
||||
/**
|
||||
* Configures the Ktor client for kotlinx.serialization JSON with custom settings.
|
||||
* @param settings Lambda to configure the [JsonBuilder].
|
||||
*/
|
||||
@PublishedApi
|
||||
internal inline fun Configuration.json(crossinline settings: JsonBuilder.() -> Unit) {
|
||||
json(
|
||||
Json {
|
||||
settings()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs [io.ktor.client.plugins.contentnegotiation.ContentNegotiation] with JSON support and custom settings in a Ktor client config.
|
||||
* @param settings Lambda to configure the [JsonBuilder].
|
||||
*/
|
||||
inline fun HttpClientConfig<*>.jsonConfig(
|
||||
crossinline settings: JsonBuilder.() -> Unit
|
||||
) = install(ContentNegotiation) {
|
||||
json {
|
||||
settings()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs [io.ktor.client.plugins.DefaultRequest] in a Ktor client config.
|
||||
* @param settings Lambda to configure the [io.ktor.client.plugins.DefaultRequest.DefaultRequestBuilder].
|
||||
*/
|
||||
inline fun HttpClientConfig<*>.defaultRequest(
|
||||
crossinline settings: DefaultRequest.DefaultRequestBuilder.() -> Unit
|
||||
) = install(DefaultRequest) {
|
||||
settings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs [io.ktor.client.plugins.logging.Logging] in a Ktor client config.
|
||||
* @param settings Lambda to configure the [io.ktor.client.plugins.logging.LoggingConfig].
|
||||
*/
|
||||
inline fun HttpClientConfig<*>.logging(
|
||||
crossinline settings: LoggingConfig.() -> Unit
|
||||
) = install(Logging) {
|
||||
settings()
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs [io.ktor.client.plugins.logging.Logging] in a Ktor client config with default settings (all logs).
|
||||
*/
|
||||
inline fun HttpClientConfig<*>.logging() = logging {
|
||||
logger = Logger.SIMPLE
|
||||
level = LogLevel.ALL
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws if the HTTP response status code is an error (>=400).
|
||||
* @return The [io.ktor.client.statement.HttpResponse] if successful.
|
||||
* @throws io.ktor.client.plugins.ClientRequestException on 4xx error code.
|
||||
* @throws io.ktor.client.plugins.ServerResponseException on 5xx error code.
|
||||
* @throws io.ktor.client.plugins.ResponseException on unknown error code.
|
||||
*/
|
||||
suspend fun HttpResponse.failOnError() =
|
||||
when (status.value) {
|
||||
in 100..399 -> this
|
||||
in 400..499 -> throw ClientRequestException(this, bodyAsText())
|
||||
in 500..599 -> throw ServerResponseException(this, bodyAsText())
|
||||
else -> throw ResponseException(this, bodyAsText())
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
|
||||
val LocalSystemBarsVisibility = compositionLocalOf<((Boolean) -> Unit)?> { null }
|
||||
|
||||
@Composable
|
||||
expect fun rememberSystemBarsController(): ((Boolean) -> Unit)?
|
||||
@@ -167,4 +167,15 @@ expect val materialYouAvailable: Boolean
|
||||
inline fun <T> T.require( message: String, condition: (T) -> Boolean): T {
|
||||
require(condition(this)) { message }
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
data class CurrentDeviceInfo(
|
||||
val osName: String? = null,
|
||||
val osVersion: String? = null,
|
||||
val deviceType: String? = null,
|
||||
val deviceName: String? = null,
|
||||
val brand: String? = null,
|
||||
val model: String? = null
|
||||
)
|
||||
|
||||
expect fun currentDeviceInfo(): CurrentDeviceInfo
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
actual fun Modifier.imeScrollWithKeyboard(): Modifier = this
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.CoreCrypto.CCHmac
|
||||
import platform.CoreCrypto.kCCHmacAlgSHA256
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
internal fun iosHmacSha256(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val mac = ByteArray(32)
|
||||
|
||||
key.usePinned { keyPinned ->
|
||||
data.usePinned { dataPinned ->
|
||||
mac.usePinned { macPinned ->
|
||||
CCHmac(
|
||||
algorithm = kCCHmacAlgSHA256,
|
||||
key = keyPinned.addressOf(0),
|
||||
keyLength = key.size.convert(),
|
||||
data = dataPinned.addressOf(0),
|
||||
dataLength = data.size.convert(),
|
||||
macOut = macPinned.addressOf(0),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mac
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.posix.SEEK_SET
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fread
|
||||
import platform.posix.fseek
|
||||
import platform.posix.fwrite
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun iosFileSize(path: String): Long {
|
||||
if (!NSFileManager.defaultManager.fileExistsAtPath(path)) return 0L
|
||||
val attrs = NSFileManager.defaultManager.attributesOfItemAtPath(path, null) ?: return 0L
|
||||
return (attrs["NSFileSize"] as? NSNumber)?.longValue ?: 0L
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun iosReadFileRange(path: String, offset: Long, length: Int): ByteArray {
|
||||
if (length <= 0) return ByteArray(0)
|
||||
val file = fopen(path, "rb") ?: error("Failed to open file")
|
||||
try {
|
||||
fseek(file, offset, SEEK_SET)
|
||||
val buffer = ByteArray(length)
|
||||
buffer.usePinned { pinned ->
|
||||
val read = fread(pinned.addressOf(0), 1.convert(), length.convert(), file).toInt()
|
||||
if (read < length) {
|
||||
error("File truncated")
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
} finally {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun iosAppendFile(path: String, bytes: ByteArray) {
|
||||
if (bytes.isEmpty()) return
|
||||
val file = fopen(path, "ab") ?: error("Failed to open file for append")
|
||||
try {
|
||||
bytes.usePinned { pinned ->
|
||||
val written = fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file).toInt()
|
||||
if (written != bytes.size) {
|
||||
error("Short write")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun iosCopyFile(sourcePath: String, destinationPath: String) {
|
||||
val input = fopen(sourcePath, "rb") ?: error("Failed to open source file")
|
||||
val output = fopen(destinationPath, "wb") ?: run {
|
||||
fclose(input)
|
||||
error("Failed to open destination file")
|
||||
}
|
||||
val buffer = ByteArray(256 * 1024)
|
||||
try {
|
||||
while (true) {
|
||||
val read = buffer.usePinned { pinned ->
|
||||
fread(pinned.addressOf(0), 1.convert(), buffer.size.convert(), input).toInt()
|
||||
}
|
||||
if (read <= 0) break
|
||||
buffer.usePinned { pinned ->
|
||||
val written = fwrite(pinned.addressOf(0), 1.convert(), read.convert(), output).toInt()
|
||||
if (written != read) {
|
||||
error("Short write")
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fclose(input)
|
||||
fclose(output)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
class IosPosixFileReader(
|
||||
private val path: String,
|
||||
) {
|
||||
private val file = fopen(path, "rb") ?: error("Failed to open file")
|
||||
|
||||
fun read(buffer: ByteArray, offset: Int, length: Int): Int {
|
||||
if (length <= 0) return 0
|
||||
return buffer.usePinned { pinned ->
|
||||
fread(pinned.addressOf(offset), 1.convert(), length.convert(), file).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
// TODO iOS 13+ uses scene-based API; status bar control requires native Swift/ObjC bridge
|
||||
@Composable
|
||||
actual fun rememberSystemBarsController(): ((Boolean) -> Unit)? {
|
||||
return remember {
|
||||
{ _ -> }
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package com.pr0gramm3r101.utils
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import platform.UIKit.UIDevice
|
||||
import platform.posix._OSSwapInt16
|
||||
import platform.posix._OSSwapInt32
|
||||
import platform.posix._OSSwapInt64
|
||||
@@ -22,4 +23,20 @@ inline fun htonl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data
|
||||
inline fun htonll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data
|
||||
inline fun ntohs(data: UShort) = if (isLittleEndian) _OSSwapInt16(data) else data
|
||||
inline fun ntohl(data: UInt) = if (isLittleEndian) _OSSwapInt32(data) else data
|
||||
inline fun ntohll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data
|
||||
inline fun ntohll(data: ULong) = if (isLittleEndian) _OSSwapInt64(data) else data
|
||||
|
||||
private fun String?.trimIfNotBlank(): String? =
|
||||
this?.trim()?.takeIf { it.isNotEmpty() }
|
||||
|
||||
actual fun currentDeviceInfo(): CurrentDeviceInfo {
|
||||
val current = UIDevice.currentDevice
|
||||
|
||||
return CurrentDeviceInfo(
|
||||
osName = current.systemName.trimIfNotBlank(),
|
||||
osVersion = current.systemVersion.trimIfNotBlank(),
|
||||
deviceType = "mobile",
|
||||
deviceName = current.name.trimIfNotBlank(),
|
||||
brand = "Apple",
|
||||
model = current.model.trimIfNotBlank()
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user