Refactor the whole project structure

This commit is contained in:
2026-06-08 21:26:55 +03:00
Unverified
parent 70a9f8635e
commit 712e4a9ed4
364 changed files with 7174 additions and 7335 deletions
@@ -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