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:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
id 'org.jetbrains.kotlin.plugin.compose'
}
android {
namespace 'ru.forbion.f7cloud.core.designsystem'
compileSdk 36
defaultConfig {
minSdk 26
}
buildFeatures {
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
implementation composeBom
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.foundation:foundation'
implementation 'androidx.compose.material3:material3'
implementation 'io.coil-kt:coil-compose:2.6.0'
implementation 'io.coil-kt:coil-svg:2.6.0'
}
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -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
}
}
@@ -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,
)
}
}
@@ -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()
}
}
}
}
@@ -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()
}
}
}
@@ -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,
)
}
}
}
@@ -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)),
)
}
}
}
@@ -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)
}
}
@@ -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
}
}
}
}
}
@@ -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),
)
@@ -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,
)