Initial import of f7cloud-mobile native Android app.
Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support). Current version: 0.5.113 (build 121).
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
/**
|
||||
* Updated from [F7MobileApp] via ProcessLifecycleOwner.
|
||||
* Background polling loops should check this before hitting the network.
|
||||
*/
|
||||
object AppForegroundTracker {
|
||||
@Volatile
|
||||
var isForeground: Boolean = true
|
||||
private set
|
||||
|
||||
fun setForeground(foreground: Boolean) {
|
||||
isForeground = foreground
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
private val BottomBarReserve: Dp = 90.dp
|
||||
private val MenuIconSize = 62.dp
|
||||
private val MenuGridGap = 20.dp
|
||||
|
||||
data class F7AppMenuItem(
|
||||
val label: String,
|
||||
val iconUrl: String,
|
||||
val selected: Boolean,
|
||||
val externalUrl: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F7AppMenuSheet(
|
||||
visible: Boolean,
|
||||
serverUrl: String,
|
||||
items: List<F7AppMenuItem>,
|
||||
onDismiss: () -> Unit,
|
||||
onItemClick: (Int) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var searchQuery by remember(visible) { mutableStateOf("") }
|
||||
val filteredItems = remember(items, searchQuery) {
|
||||
val query = searchQuery.trim()
|
||||
if (query.isBlank()) {
|
||||
items
|
||||
} else {
|
||||
items.filter { it.label.contains(query, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
val base = serverUrl.trimEnd('/')
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)) + slideInVertically(
|
||||
animationSpec = tween(350),
|
||||
initialOffsetY = { it },
|
||||
),
|
||||
exit = fadeOut(tween(200)) + slideOutVertically(
|
||||
animationSpec = tween(300),
|
||||
targetOffsetY = { it },
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = BottomBarReserve)
|
||||
.navigationBarsPadding()
|
||||
.background(F7Colors.Background)
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
|
||||
) {
|
||||
F7AppMenuSearchField(
|
||||
serverUrl = base,
|
||||
value = searchQuery,
|
||||
onValueChange = { searchQuery = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 24.dp),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(4),
|
||||
horizontalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
verticalArrangement = Arrangement.spacedBy(MenuGridGap),
|
||||
contentPadding = PaddingValues(horizontal = 2.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = filteredItems,
|
||||
key = { index, item -> "${item.label}-$index" },
|
||||
) { index, item ->
|
||||
val originalIndex = items.indexOf(item)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter,
|
||||
) {
|
||||
F7AppMenuGridItem(
|
||||
item = item,
|
||||
onClick = {
|
||||
if (originalIndex >= 0) {
|
||||
onItemClick(originalIndex)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F7AppMenuSearchField(
|
||||
serverUrl: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White)
|
||||
.border(1.dp, Color(0xFFE6E6E6), RoundedCornerShape(20.dp)),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = "$serverUrl/themes/forbion/images/header/search-glass.svg",
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.size(18.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 14.sp,
|
||||
color = F7Colors.TextPrimary,
|
||||
),
|
||||
cursorBrush = SolidColor(F7Colors.Primary),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 40.dp, end = 14.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
if (value.isBlank()) {
|
||||
Text(
|
||||
text = "Поиск...",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF808080),
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F7AppMenuGridItem(
|
||||
item: F7AppMenuItem,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(MenuIconSize)
|
||||
.clickable(onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.labelLarge.copy(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 14.sp,
|
||||
color = Color(0xFF151515),
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
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.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val BottomBarFloatOffset = 6.dp
|
||||
|
||||
/**
|
||||
* Bottom bar visibility — mirrors forbion [mobileBottomBarAutoHide] (4s idle hide).
|
||||
*/
|
||||
@Composable
|
||||
fun F7AutoHideBottomBar(
|
||||
enabled: Boolean,
|
||||
pinned: Boolean,
|
||||
activityNonce: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
hideDelayMs: Long = 4000L,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
if (!enabled) return
|
||||
|
||||
var visible by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(enabled, pinned, activityNonce) {
|
||||
if (pinned) {
|
||||
visible = true
|
||||
return@LaunchedEffect
|
||||
}
|
||||
visible = true
|
||||
delay(hideDelayMs)
|
||||
if (!pinned) {
|
||||
visible = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.navigationBarsPadding()
|
||||
.padding(bottom = BottomBarFloatOffset),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 2 }),
|
||||
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 2 }),
|
||||
modifier = Modifier.wrapContentWidth(),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.BottomCenter) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
enum class F7BottomBarSlot {
|
||||
Chats,
|
||||
NavBack,
|
||||
Create,
|
||||
Profile,
|
||||
Notifications,
|
||||
Settings,
|
||||
Menu,
|
||||
}
|
||||
|
||||
data class F7BottomBarConfig(
|
||||
val slots: List<F7BottomBarSlot>,
|
||||
) {
|
||||
val buttonCount: Int get() = slots.size
|
||||
|
||||
companion object {
|
||||
fun forContext(
|
||||
tabKey: String,
|
||||
talkInRoom: Boolean,
|
||||
): F7BottomBarConfig = when (tabKey) {
|
||||
"Talk" -> if (talkInRoom) {
|
||||
F7BottomBarConfig(listOf(F7BottomBarSlot.Profile, F7BottomBarSlot.Notifications, F7BottomBarSlot.Menu))
|
||||
} else {
|
||||
F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Chats,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
"Files" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Settings,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Contacts" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Tasks" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Support" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Create,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
"Mail", "Calendar" -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.NavBack,
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Settings,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
else -> F7BottomBarConfig(
|
||||
listOf(
|
||||
F7BottomBarSlot.Profile,
|
||||
F7BottomBarSlot.Notifications,
|
||||
F7BottomBarSlot.Menu,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Palette from themes/forbion (mobile + f7support), light theme.
|
||||
*/
|
||||
object F7Colors {
|
||||
val Primary = Color(0xFF70B62B)
|
||||
val PrimaryHover = Color(0xFF6FAF2E)
|
||||
val PrimaryDark = Color(0xFF5E922B)
|
||||
val PrimaryLight = Color(0xFFECF9DE)
|
||||
val PrimaryGradientStart = Color(0xFFC0FF7B)
|
||||
val PrimaryGradientEnd = Color(0xFF7CBC3D)
|
||||
|
||||
val Background = Color(0xFFFBFBFB)
|
||||
val Surface = Color(0xFFFFFFFF)
|
||||
val SurfaceMuted = Color(0xFFF5F5F5)
|
||||
|
||||
val TextPrimary = Color(0xFF151515)
|
||||
val TextSecondary = Color(0xFF808080)
|
||||
val TextMuted = Color(0xFF8C8C8C)
|
||||
val TextOnPrimary = Color(0xFFFFFFFF)
|
||||
|
||||
val Border = Color(0xFFE6E6E6)
|
||||
val BorderLight = Color(0xFFE0E0E0)
|
||||
val SecondaryButtonBg = Color(0xFFFDFDFD)
|
||||
val SecondaryButtonBorder = Color(0xFFE6E6E6)
|
||||
|
||||
val Error = Color(0xFFD74642)
|
||||
val ErrorBg = Color(0xFFFFE2E2)
|
||||
|
||||
val StatusNew = Color(0xFF2B9AB6)
|
||||
val StatusProgress = Color(0xFF70B62B)
|
||||
val StatusClosed = Color(0xFF808080)
|
||||
|
||||
val ChatBubbleIn = Color(0xFFFDFDFD)
|
||||
val ChatBubbleOut = Color(0xFFE0F8C9)
|
||||
val ChatBubbleSupport = Color(0xFFECF9DE)
|
||||
val ChatText = Color(0xFF3F3F3F)
|
||||
val ChatBackground = Color(0xFFE8EFE0)
|
||||
val ChatDatePill = Color(0xFFF5F5F5)
|
||||
val TalkComposerBorder = Color(0x3370B62B)
|
||||
val TalkTopBarBorder = Color(0x3370B62B)
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.input.key.Key
|
||||
import androidx.compose.ui.input.key.KeyEventType
|
||||
import androidx.compose.ui.input.key.key
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
|
||||
/**
|
||||
* Кнопки forbion (mobile):
|
||||
* - [F7PrimaryButton] — градиент, CTA («Создать», «Войти», «Отправить»)
|
||||
* - [F7SolidPrimaryButton] — сплошной зелёный, диалоги NC
|
||||
* - [F7SecondaryButton] — outline, «Обновить», «Назад», «Отмена»
|
||||
* - [F7TextButton] — tertiary, текст без фона
|
||||
*/
|
||||
|
||||
@Composable
|
||||
fun F7ScreenBackground(modifier: Modifier = Modifier, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(F7Colors.Background),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7ModuleScreen(
|
||||
title: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
loading: Boolean = false,
|
||||
error: String? = null,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
headerActions: @Composable RowScope.() -> Unit = {},
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val showTitle = !title.isNullOrBlank()
|
||||
val showHeader = showTitle || onRefresh != null
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (showHeader) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (showTitle) {
|
||||
Text(
|
||||
text = title.orEmpty(),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 8.dp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
headerActions()
|
||||
if (onRefresh != null) {
|
||||
F7HeaderActionButton(
|
||||
text = "↻",
|
||||
onClick = onRefresh,
|
||||
enabled = !loading,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (loading) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
}
|
||||
if (!error.isNullOrBlank()) {
|
||||
Text(text = error, color = F7Colors.Error, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7PrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val shape = RoundedCornerShape(100.dp)
|
||||
val gradient = Brush.linearGradient(
|
||||
colors = listOf(F7Colors.PrimaryGradientStart, F7Colors.PrimaryGradientEnd),
|
||||
)
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
.heightIn(min = 44.dp)
|
||||
.shadow(
|
||||
elevation = 4.dp,
|
||||
shape = shape,
|
||||
spotColor = F7Colors.Primary.copy(alpha = 0.18f),
|
||||
ambientColor = F7Colors.Primary.copy(alpha = 0.10f),
|
||||
),
|
||||
shape = shape,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color.Transparent,
|
||||
disabledContainerColor = Color.Transparent,
|
||||
),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(0.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, F7Colors.Primary.copy(alpha = 0.22f), shape)
|
||||
.background(brush = gradient, shape = shape)
|
||||
.padding(horizontal = 18.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = F7Colors.TextOnPrimary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7SolidPrimaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 40.dp),
|
||||
shape = RoundedCornerShape(100.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = F7Colors.Primary,
|
||||
contentColor = F7Colors.TextOnPrimary,
|
||||
disabledContainerColor = F7Colors.Border,
|
||||
disabledContentColor = F7Colors.TextSecondary,
|
||||
),
|
||||
) {
|
||||
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7HeaderActionButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier
|
||||
.heightIn(min = 36.dp)
|
||||
.widthIn(min = 36.dp, max = 52.dp),
|
||||
shape = RoundedCornerShape(100.dp),
|
||||
border = BorderStroke(1.dp, F7Colors.SecondaryButtonBorder),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = F7Colors.SecondaryButtonBg,
|
||||
contentColor = F7Colors.TextPrimary,
|
||||
),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7SecondaryButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier.heightIn(min = 40.dp),
|
||||
shape = RoundedCornerShape(100.dp),
|
||||
border = BorderStroke(1.dp, F7Colors.SecondaryButtonBorder),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = F7Colors.SecondaryButtonBg,
|
||||
contentColor = F7Colors.TextPrimary,
|
||||
),
|
||||
) {
|
||||
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7TextButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = modifier,
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = F7Colors.TextPrimary,
|
||||
disabledContentColor = F7Colors.TextSecondary,
|
||||
),
|
||||
) {
|
||||
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7AlertDialog(
|
||||
title: String,
|
||||
onDismiss: () -> Unit,
|
||||
confirmText: String,
|
||||
onConfirm: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
dismissText: String = "Отмена",
|
||||
confirmEnabled: Boolean = true,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Surface(
|
||||
modifier = modifier.widthIn(max = 400.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = F7Colors.Surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
||||
content()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
) {
|
||||
F7SecondaryButton(text = dismissText, onClick = onDismiss)
|
||||
F7SolidPrimaryButton(
|
||||
text = confirmText,
|
||||
onClick = onConfirm,
|
||||
enabled = confirmEnabled,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7MessageComposer(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
sending: Boolean = false,
|
||||
label: String = "Сообщение",
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
F7OutlinedField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = label,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
F7PrimaryButton(
|
||||
text = if (sending) "…" else "Отправить",
|
||||
onClick = onSend,
|
||||
enabled = value.isNotBlank() && !sending,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val LINE_BREAK_CHARS = Regex("[\\r\\n\\u000B\\u000C\\u2028\\u2029\\u0085]")
|
||||
|
||||
private fun stripLineBreaks(text: String): String = text.replace(LINE_BREAK_CHARS, "")
|
||||
|
||||
private fun Modifier.consumeEnterKey(onEnter: () -> Unit): Modifier = this
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.isEnterKey()) {
|
||||
onEnter()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.isEnterKey()) {
|
||||
onEnter()
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.input.key.KeyEvent.isEnterKey(): Boolean =
|
||||
key == Key.Enter || key == Key.NumPadEnter
|
||||
|
||||
@Composable
|
||||
fun F7OutlinedField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
minLines: Int = 1,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
onEnter: (() -> Unit)? = null,
|
||||
) {
|
||||
val singleLine = minLines <= 1
|
||||
val mergedKeyboardOptions = if (singleLine) {
|
||||
keyboardOptions.copy(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
autoCorrectEnabled = false,
|
||||
)
|
||||
} else {
|
||||
keyboardOptions
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { newValue ->
|
||||
if (!singleLine) {
|
||||
onValueChange(newValue)
|
||||
return@OutlinedTextField
|
||||
}
|
||||
val hadLineBreak = LINE_BREAK_CHARS.containsMatchIn(newValue)
|
||||
val stripped = stripLineBreaks(newValue)
|
||||
if (stripped != value) {
|
||||
onValueChange(stripped)
|
||||
} else if (stripped != newValue) {
|
||||
onValueChange(stripped)
|
||||
}
|
||||
if (hadLineBreak) {
|
||||
onEnter?.invoke()
|
||||
}
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onEnter != null && singleLine) Modifier.consumeEnterKey(onEnter) else Modifier),
|
||||
label = { Text(label) },
|
||||
minLines = if (singleLine) 1 else minLines,
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = mergedKeyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
singleLine = singleLine,
|
||||
maxLines = if (singleLine) 1 else minLines,
|
||||
shape = RoundedCornerShape(100.dp),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = F7Colors.Primary,
|
||||
unfocusedBorderColor = F7Colors.Border,
|
||||
focusedContainerColor = Color(0xFFFDFDFD),
|
||||
unfocusedContainerColor = Color(0xFFFDFDFD),
|
||||
cursorColor = F7Colors.Primary,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7ListCard(
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(shape)
|
||||
.background(F7Colors.Surface)
|
||||
.border(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) F7Colors.PrimaryGradientEnd else F7Colors.Border,
|
||||
shape = shape,
|
||||
)
|
||||
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||
.padding(12.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7ChatBubble(
|
||||
text: String,
|
||||
outgoing: Boolean,
|
||||
subtitle: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val bg = if (outgoing) F7Colors.ChatBubbleOut else F7Colors.ChatBubbleIn
|
||||
val align = if (outgoing) Alignment.CenterEnd else Alignment.CenterStart
|
||||
Box(modifier = modifier.fillMaxWidth(), contentAlignment = align) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.88f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(bg)
|
||||
.border(1.dp, F7Colors.Border.copy(alpha = 0.5f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
) {
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
Text(text = text, style = MaterialTheme.typography.bodyMedium, color = F7Colors.ChatText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7TicketCard(
|
||||
ticketNumber: String,
|
||||
subject: String,
|
||||
status: String,
|
||||
preview: String,
|
||||
hasUnread: Boolean,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val statusColor = when {
|
||||
status.equals("Новый", ignoreCase = true) -> F7Colors.StatusNew
|
||||
status.equals("В работе", ignoreCase = true) -> F7Colors.StatusProgress
|
||||
else -> F7Colors.StatusClosed
|
||||
}
|
||||
F7ListCard(modifier = modifier, selected = selected, onClick = onClick) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(F7Colors.SurfaceMuted)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = subject,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = F7Colors.TextPrimary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
F7StatusChip(text = status, color = statusColor)
|
||||
}
|
||||
Text(
|
||||
text = "#$ticketNumber",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
if (preview.isNotBlank()) {
|
||||
Text(
|
||||
text = preview,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (hasUnread) {
|
||||
Text(
|
||||
text = "Новое",
|
||||
color = F7Colors.Primary,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7StatusChip(text: String, color: Color) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(100.dp))
|
||||
.background(color)
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
color = F7Colors.TextOnPrimary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7AppScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
bottomBar: @Composable () -> Unit = {},
|
||||
content: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
F7ScreenBackground(modifier = Modifier.fillMaxSize()) {
|
||||
content(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.f7SafeTopInsets(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
bottomBar()
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Popup panel above the bottom bar — matches forbion mobile web
|
||||
* (#header-menu-notifications, #header-menu-user-menu).
|
||||
*/
|
||||
@Composable
|
||||
fun F7FloatingPanel(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bottomOffset: Dp = 80.dp,
|
||||
fullHeight: Boolean = false,
|
||||
contentPadding: PaddingValues = PaddingValues(16.dp),
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn() + slideInVertically(initialOffsetY = { it / 4 }),
|
||||
exit = fadeOut() + slideOutVertically(targetOffsetY = { it / 4 }),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = bottomOffset)
|
||||
.background(Color.Black.copy(alpha = 0.18f))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = 16.dp, end = 12.dp, bottom = bottomOffset)
|
||||
.then(if (fullHeight) Modifier.statusBarsPadding() else Modifier)
|
||||
.navigationBarsPadding()
|
||||
.fillMaxWidth()
|
||||
.then(if (fullHeight) Modifier.fillMaxHeight() else Modifier)
|
||||
.shadow(
|
||||
elevation = 12.dp,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
spotColor = Color.Black.copy(alpha = 0.12f),
|
||||
)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(F7Colors.SecondaryButtonBg)
|
||||
.border(1.dp, F7Colors.Border, RoundedCornerShape(16.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
)
|
||||
.padding(contentPadding),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
|
||||
data class F7BottomBarActions(
|
||||
val onChatsClick: () -> Unit = {},
|
||||
val onNavBackClick: () -> Unit = {},
|
||||
val onCreateClick: () -> Unit = {},
|
||||
val onProfileClick: () -> Unit = {},
|
||||
val onNotificationsClick: () -> Unit = {},
|
||||
val onSettingsClick: () -> Unit = {},
|
||||
val onMenuClick: () -> Unit = {},
|
||||
)
|
||||
|
||||
private val BottomBarButtonSize = 55.dp
|
||||
private val BottomBarIconSize = 24.dp
|
||||
private val BottomBarGap = 8.dp
|
||||
private val BottomBarOuterPaddingH = 6.dp
|
||||
private val BottomBarOuterPaddingV = 6.dp
|
||||
private val BottomBarButtonShape = RoundedCornerShape(100.dp)
|
||||
private val BottomBarBorderBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
private val BottomBarHighlightBrush = Brush.linearGradient(
|
||||
listOf(Color(0x33C0FF7B), Color(0x3370B62B)),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F7MobileBottomBar(
|
||||
serverUrl: String,
|
||||
userId: String,
|
||||
config: F7BottomBarConfig,
|
||||
actions: F7BottomBarActions,
|
||||
menuOpen: Boolean = false,
|
||||
chatsHighlighted: Boolean = false,
|
||||
navBackHighlighted: Boolean = false,
|
||||
showNotificationBadge: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val base = serverUrl.trimEnd('/')
|
||||
val pillShape = RoundedCornerShape(percent = 50)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.wrapContentWidth()
|
||||
.shadow(
|
||||
elevation = 2.dp,
|
||||
shape = pillShape,
|
||||
spotColor = Color(0xFFE6E6E6),
|
||||
)
|
||||
.clip(pillShape)
|
||||
.background(Color(0xFFF5F5F5))
|
||||
.padding(
|
||||
horizontal = BottomBarOuterPaddingH,
|
||||
vertical = BottomBarOuterPaddingV,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(BottomBarGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
config.slots.forEach { slot ->
|
||||
when (slot) {
|
||||
F7BottomBarSlot.Chats -> F7BottomBarIconSlot(
|
||||
iconUrl = if (chatsHighlighted) {
|
||||
"$base/themes/forbion/images/header/chat-icon-green.svg"
|
||||
} else {
|
||||
"$base/themes/forbion/images/header/chat-icon-gray.svg"
|
||||
},
|
||||
contentDescription = "Чаты",
|
||||
highlighted = chatsHighlighted,
|
||||
onClick = actions.onChatsClick,
|
||||
)
|
||||
F7BottomBarSlot.NavBack -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/sidebar-chevron-left.svg",
|
||||
contentDescription = "Папки",
|
||||
highlighted = navBackHighlighted,
|
||||
iconRotation = if (navBackHighlighted) 180f else 0f,
|
||||
onClick = actions.onNavBackClick,
|
||||
)
|
||||
F7BottomBarSlot.Create -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/green-plus.svg",
|
||||
contentDescription = "Создать",
|
||||
onClick = actions.onCreateClick,
|
||||
)
|
||||
F7BottomBarSlot.Profile -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/profile-menu-icon-big.svg",
|
||||
contentDescription = "Профиль",
|
||||
onClick = actions.onProfileClick,
|
||||
)
|
||||
F7BottomBarSlot.Notifications -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/not-menu-icon-big.svg",
|
||||
contentDescription = "Уведомления",
|
||||
showBadge = showNotificationBadge,
|
||||
onClick = actions.onNotificationsClick,
|
||||
)
|
||||
F7BottomBarSlot.Settings -> F7BottomBarIconSlot(
|
||||
iconUrl = "$base/themes/forbion/images/header/setting-menu-icon.svg",
|
||||
contentDescription = "Настройки",
|
||||
onClick = actions.onSettingsClick,
|
||||
)
|
||||
F7BottomBarSlot.Menu -> F7BottomBarIconSlot(
|
||||
iconUrl = if (menuOpen) {
|
||||
"$base/themes/forbion/images/header/menu-burger-green.svg"
|
||||
} else {
|
||||
"$base/themes/forbion/images/header/menu-burger-gray.svg"
|
||||
},
|
||||
contentDescription = "Меню",
|
||||
highlighted = menuOpen,
|
||||
onClick = actions.onMenuClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun F7BottomBarIconSlot(
|
||||
iconUrl: String,
|
||||
contentDescription: String,
|
||||
onClick: () -> Unit,
|
||||
highlighted: Boolean = false,
|
||||
iconRotation: Float = 0f,
|
||||
showBadge: Boolean = false,
|
||||
) {
|
||||
val bg = if (highlighted) BottomBarHighlightBrush else null
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(BottomBarButtonSize)
|
||||
.clip(BottomBarButtonShape)
|
||||
.then(
|
||||
if (bg != null) {
|
||||
Modifier.background(bg, BottomBarButtonShape)
|
||||
} else {
|
||||
Modifier.background(Color(0x99FFFFFF), BottomBarButtonShape)
|
||||
},
|
||||
)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
brush = BottomBarBorderBrush,
|
||||
shape = BottomBarButtonShape,
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = onClick,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = iconUrl,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(BottomBarIconSize)
|
||||
.graphicsLayer { rotationZ = iconRotation },
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
if (showBadge) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 10.dp, end = 10.dp)
|
||||
.size(8.dp)
|
||||
.clip(BottomBarButtonShape)
|
||||
.background(Color(0xFFE53935)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
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.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.Credentials
|
||||
|
||||
private fun TextStyle.doubled(): TextStyle = copy(
|
||||
fontSize = fontSize * 2,
|
||||
lineHeight = lineHeight * 2,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F7NotificationRow(
|
||||
subject: String,
|
||||
message: String,
|
||||
relativeTime: String,
|
||||
iconUrl: String?,
|
||||
closeIconUrl: String,
|
||||
authHeader: String?,
|
||||
onClick: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
showDivider: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (relativeTime.isNotBlank()) {
|
||||
Text(
|
||||
text = relativeTime,
|
||||
style = MaterialTheme.typography.labelSmall.doubled(),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextSecondary.copy(alpha = 0.55f),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable(onClick = onDismiss),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = closeIconUrl,
|
||||
contentDescription = "Закрыть",
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
.background(F7Colors.SurfaceMuted),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val model = if (!iconUrl.isNullOrBlank()) {
|
||||
ImageRequest.Builder(context)
|
||||
.data(iconUrl)
|
||||
.apply {
|
||||
authHeader?.let { addHeader("Authorization", it) }
|
||||
}
|
||||
.build()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (model != null) {
|
||||
AsyncImage(
|
||||
model = model,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = subject,
|
||||
style = MaterialTheme.typography.bodyMedium.doubled(),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = F7Colors.TextPrimary,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
if (message.isNotBlank()) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall.doubled(),
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = F7Colors.TextSecondary.copy(alpha = 0.7f),
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 78.dp, top = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (showDivider) {
|
||||
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
interface F7OverlayNavigationScope {
|
||||
fun registerDismissHandler(handler: () -> Boolean): () -> Unit
|
||||
|
||||
fun dismissTopOverlay(): Boolean
|
||||
}
|
||||
|
||||
private class F7OverlayNavigationScopeImpl : F7OverlayNavigationScope {
|
||||
private val handlers = mutableStateListOf<() -> Boolean>()
|
||||
|
||||
override fun registerDismissHandler(handler: () -> Boolean): () -> Unit {
|
||||
handlers.add(handler)
|
||||
return { handlers.remove(handler) }
|
||||
}
|
||||
|
||||
override fun dismissTopOverlay(): Boolean {
|
||||
for (index in handlers.indices.reversed()) {
|
||||
if (handlers[index]()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
val LocalF7OverlayNavigation = compositionLocalOf<F7OverlayNavigationScope?> { null }
|
||||
|
||||
@Composable
|
||||
fun F7OverlayNavigationProvider(
|
||||
onSwipeDismiss: () -> Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val scope = remember { F7OverlayNavigationScopeImpl() }
|
||||
CompositionLocalProvider(LocalF7OverlayNavigation provides scope) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = modifier.f7SwipeFromRightToDismiss {
|
||||
if (scope.dismissTopOverlay()) return@f7SwipeFromRightToDismiss
|
||||
onSwipeDismiss()
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun F7OverlayDismissHandler(
|
||||
enabled: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val scope = LocalF7OverlayNavigation.current ?: return
|
||||
DisposableEffect(enabled, scope, onDismiss) {
|
||||
if (!enabled) {
|
||||
return@DisposableEffect onDispose {}
|
||||
}
|
||||
val unregister = scope.registerDismissHandler {
|
||||
onDismiss()
|
||||
true
|
||||
}
|
||||
onDispose(unregister)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.f7SwipeFromRightToDismiss(
|
||||
enabled: Boolean = true,
|
||||
edgeFraction: Float = 0.24f,
|
||||
dismissDistanceFraction: Float = 0.14f,
|
||||
onDismiss: () -> Unit,
|
||||
): Modifier {
|
||||
if (!enabled) return this
|
||||
return pointerInput(Unit) {
|
||||
val edgeStartPx = size.width * (1f - edgeFraction)
|
||||
val dismissDistancePx = size.width * dismissDistanceFraction
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
if (down.position.x < edgeStartPx) return@awaitEachGesture
|
||||
|
||||
var dragLeft = 0f
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id } ?: break
|
||||
if (!change.pressed) break
|
||||
val delta = change.position.x - change.previousPosition.x
|
||||
if (delta < 0f) {
|
||||
dragLeft += -delta
|
||||
}
|
||||
if (dragLeft >= dismissDistancePx) {
|
||||
onDismiss()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.Locale
|
||||
|
||||
fun formatNotificationRelativeTime(isoDatetime: String): String {
|
||||
if (isoDatetime.isBlank()) return ""
|
||||
val instant = runCatching { Instant.parse(isoDatetime) }.getOrNull() ?: return ""
|
||||
val zone = ZoneId.systemDefault()
|
||||
val date = instant.atZone(zone).toLocalDate()
|
||||
val today = LocalDate.now(zone)
|
||||
val days = ChronoUnit.DAYS.between(date, today)
|
||||
return when {
|
||||
days == 0L -> "сегодня"
|
||||
days == 1L -> "вчера"
|
||||
days == 2L -> "позавчера"
|
||||
days in 3..6 -> "$days ${daysLabel(days)} назад"
|
||||
else -> DateTimeFormatter.ofPattern("d MMM", Locale("ru")).format(date)
|
||||
}
|
||||
}
|
||||
|
||||
private fun daysLabel(days: Long): String {
|
||||
val mod10 = (days % 10).toInt()
|
||||
val mod100 = (days % 100).toInt()
|
||||
return when {
|
||||
mod10 == 1 && mod100 != 11 -> "день"
|
||||
mod10 in 2..4 && mod100 !in 12..14 -> "дня"
|
||||
else -> "дней"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val F7LightScheme = lightColorScheme(
|
||||
primary = F7Colors.Primary,
|
||||
onPrimary = F7Colors.TextOnPrimary,
|
||||
primaryContainer = F7Colors.PrimaryLight,
|
||||
onPrimaryContainer = F7Colors.TextPrimary,
|
||||
secondary = F7Colors.PrimaryDark,
|
||||
onSecondary = F7Colors.TextOnPrimary,
|
||||
background = F7Colors.Background,
|
||||
onBackground = F7Colors.TextPrimary,
|
||||
surface = F7Colors.Surface,
|
||||
onSurface = F7Colors.TextPrimary,
|
||||
surfaceVariant = F7Colors.SurfaceMuted,
|
||||
onSurfaceVariant = F7Colors.TextSecondary,
|
||||
outline = F7Colors.Border,
|
||||
error = F7Colors.Error,
|
||||
onError = Color.White,
|
||||
errorContainer = F7Colors.ErrorBg,
|
||||
onErrorContainer = F7Colors.TextPrimary,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun F7Theme(
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = F7LightScheme,
|
||||
typography = F7Typography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import ru.forbion.f7cloud.core.designsystem.R
|
||||
|
||||
val RalewayFamily = FontFamily(
|
||||
Font(R.font.raleway_medium, FontWeight.Medium),
|
||||
Font(R.font.raleway_semibold, FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
val F7Typography = Typography(
|
||||
displayLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
|
||||
titleLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.SemiBold, fontSize = 24.sp, lineHeight = 28.sp),
|
||||
titleMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
|
||||
titleSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||
bodyLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 16.sp, lineHeight = 20.sp),
|
||||
bodyMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||
bodySmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
|
||||
labelLarge = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp),
|
||||
labelMedium = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 16.sp),
|
||||
labelSmall = TextStyle(fontFamily = RalewayFamily, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 14.sp),
|
||||
)
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/** Top + horizontal safe area (status bar, display cutout on foldables / punch-hole). */
|
||||
@Composable
|
||||
fun Modifier.f7SafeTopInsets(): Modifier = windowInsetsPadding(
|
||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
)
|
||||
|
||||
/** Bottom navigation bar / gesture area when the app bottom bar is hidden. */
|
||||
@Composable
|
||||
fun Modifier.f7SafeBottomInsets(): Modifier = windowInsetsPadding(
|
||||
WindowInsets.navigationBars,
|
||||
)
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user