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,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()
@@ -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()
)
}