Refactor image blurring to use RenderScript for improved performance and quality

- Replaced custom fast blur implementation with RenderScript-based Gaussian blur in BlurredAvatarBackground and AppearanceScreen.
- Updated image processing logic to scale down bitmaps before applying blur for efficiency.
- Simplified blur logic by removing unnecessary pixel manipulation methods.
- Enhanced media preview handling in OtherProfileScreen to utilize new Gaussian blur function.
- Improved code readability and maintainability by consolidating blur functionality.
This commit is contained in:
2026-02-22 12:32:19 +05:00
parent 5b9b3f83f7
commit ba7182abe6
13 changed files with 1378 additions and 697 deletions

View File

@@ -1,9 +1,14 @@
package com.rosetta.messenger.ui.settings
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.renderscript.Allocation
import android.renderscript.Element
import android.renderscript.RenderScript
import android.renderscript.ScriptIntrinsicBlur
import android.view.PixelCopy
import android.view.View
import androidx.activity.compose.BackHandler
@@ -43,6 +48,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -336,6 +342,7 @@ private fun ProfileBlurPreview(
val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L }
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
val blurContext = LocalContext.current
LaunchedEffect(avatarKey) {
val current = avatars
@@ -345,19 +352,8 @@ private fun ProfileBlurPreview(
}
if (decoded != null) {
avatarBitmap = decoded
// Blur для фонового изображения
blurredBitmap = withContext(Dispatchers.Default) {
val scaled = Bitmap.createScaledBitmap(
decoded,
decoded.width / 4,
decoded.height / 4,
true
)
var result = scaled
repeat(3) {
result = fastBlur(result, 6)
}
result
appearanceGaussianBlur(blurContext, decoded, radius = 25f, passes = 3)
}
}
} else {
@@ -734,56 +730,31 @@ private fun ColorCircleItem(
}
/**
* Быстрый box blur (для preview, идентично BlurredAvatarBackground)
* Proper Gaussian blur via RenderScript — smooth, non-pixelated.
*/
private fun fastBlur(source: Bitmap, radius: Int): Bitmap {
if (radius < 1) return source
val w = source.width
val h = source.height
val bitmap = source.copy(source.config, true)
val pixels = IntArray(w * h)
bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
for (y in 0 until h) blurRow(pixels, y, w, radius)
for (x in 0 until w) blurColumn(pixels, x, w, h, radius)
bitmap.setPixels(pixels, 0, w, 0, 0, w, h)
return bitmap
@Suppress("deprecation")
private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, passes: Int = 3): Bitmap {
val w = (source.width / 4).coerceAtLeast(8)
val h = (source.height / 4).coerceAtLeast(8)
var current = Bitmap.createScaledBitmap(source, w, h, true)
.copy(Bitmap.Config.ARGB_8888, true)
val rs = RenderScript.create(context)
val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
blur.setRadius(radius.coerceIn(1f, 25f))
repeat(passes) {
val input = Allocation.createFromBitmap(rs, current)
val output = Allocation.createFromBitmap(rs, current)
blur.setInput(input)
blur.forEach(output)
output.copyTo(current)
input.destroy()
output.destroy()
}
blur.destroy()
rs.destroy()
return current
}
private fun blurRow(pixels: IntArray, y: Int, w: Int, radius: Int) {
var sR = 0; var sG = 0; var sB = 0; var sA = 0
val dv = radius * 2 + 1; val off = y * w
for (i in -radius..radius) {
val x = i.coerceIn(0, w - 1); val p = pixels[off + x]
sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff
sG += (p shr 8) and 0xff; sB += p and 0xff
}
for (x in 0 until w) {
pixels[off + x] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv)
val xL = (x - radius).coerceIn(0, w - 1); val xR = (x + radius + 1).coerceIn(0, w - 1)
val lp = pixels[off + xL]; val rp = pixels[off + xR]
sA += ((rp shr 24) and 0xff) - ((lp shr 24) and 0xff)
sR += ((rp shr 16) and 0xff) - ((lp shr 16) and 0xff)
sG += ((rp shr 8) and 0xff) - ((lp shr 8) and 0xff)
sB += (rp and 0xff) - (lp and 0xff)
}
}
private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) {
var sR = 0; var sG = 0; var sB = 0; var sA = 0
val dv = radius * 2 + 1
for (i in -radius..radius) {
val y = i.coerceIn(0, h - 1); val p = pixels[y * w + x]
sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff
sG += (p shr 8) and 0xff; sB += p and 0xff
}
for (y in 0 until h) {
val off = y * w + x
pixels[off] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv)
val yT = (y - radius).coerceIn(0, h - 1); val yB = (y + radius + 1).coerceIn(0, h - 1)
val tp = pixels[yT * w + x]; val bp = pixels[yB * w + x]
sA += ((bp shr 24) and 0xff) - ((tp shr 24) and 0xff)
sR += ((bp shr 16) and 0xff) - ((tp shr 16) and 0xff)
sG += ((bp shr 8) and 0xff) - ((tp shr 8) and 0xff)
sB += (bp and 0xff) - (tp and 0xff)
}
}

View File

@@ -11,6 +11,7 @@ import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.core.content.FileProvider
import androidx.core.view.WindowCompat
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Spring
@@ -24,11 +25,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -101,6 +99,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.json.JSONArray
import java.io.File
@@ -114,7 +114,7 @@ private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp
private val COLLAPSED_HEADER_HEIGHT_OTHER = 64.dp
private val AVATAR_SIZE_EXPANDED_OTHER = 120.dp
private val AVATAR_SIZE_COLLAPSED_OTHER = 36.dp
private const val MEDIA_THUMB_EDGE_PX = 480
private const val MEDIA_THUMB_EDGE_PX = 240
private object SharedMediaBitmapCache {
private val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt()
@@ -182,18 +182,10 @@ fun OtherProfileScreen(
var showAvatarMenu by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
val tabs = remember { OtherProfileTab.entries }
val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size })
val selectedTab =
tabs.getOrElse(pagerState.currentPage.coerceIn(0, tabs.lastIndex)) {
OtherProfileTab.MEDIA
}
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp)
val isPagerSwiping = pagerState.isScrollInProgress
val isOnFirstPage = pagerState.currentPage == 0 && pagerState.currentPageOffsetFraction == 0f
LaunchedEffect(showImageViewer, isPagerSwiping, isOnFirstPage) {
onSwipeBackEnabledChanged(!showImageViewer && !isPagerSwiping && isOnFirstPage)
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
LaunchedEffect(showImageViewer) {
onSwipeBackEnabledChanged(!showImageViewer)
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
@@ -533,6 +525,21 @@ fun OtherProfileScreen(
// Handle back gesture
BackHandler { onBack() }
// ══════════════════════════════════════════════════════
// PRE-COMPUTE media grid state (must be in Composable context)
// ══════════════════════════════════════════════════════
val mediaColumns = 3
val mediaSpacing = 1.dp
val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp
val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns
val mediaDecodeSemaphore = remember { Semaphore(4) }
// Use stable key for bitmap cache - don't recreate on size change
val mediaBitmapStates = remember { mutableStateMapOf<String, android.graphics.Bitmap?>() }
// Pre-compute indexed rows to avoid O(n) indexOf calls
val mediaIndexedRows = remember(sharedContent.mediaPhotos) {
sharedContent.mediaPhotos.chunked(mediaColumns).mapIndexed { idx, row -> idx to row }
}
Box(
modifier =
Modifier.fillMaxSize()
@@ -698,80 +705,231 @@ fun OtherProfileScreen(
// ═══════════════════════════════════════════════════════════
OtherProfileSharedTabs(
selectedTab = selectedTab,
onTabSelected = { tab ->
val targetPage = tab.ordinal
if (pagerState.currentPage != targetPage) {
coroutineScope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
},
onTabSelected = { tab -> selectedTab = tab },
isDarkTheme = isDarkTheme
)
}
Spacer(modifier = Modifier.height(10.dp))
// ══════════════════════════════════════════════════════
// TAB CONTENT — inlined directly into LazyColumn items
// for true virtualization (only visible items compose)
// ══════════════════════════════════════════════════════
when (selectedTab) {
OtherProfileTab.MEDIA -> {
if (sharedContent.mediaPhotos.isEmpty()) {
item(key = "media_empty") {
OtherProfileEmptyState(
animationAssetPath = "lottie/saved.json",
title = "No shared media yet",
subtitle = "Photos from your chat will appear here.",
isDarkTheme = isDarkTheme
)
}
} else {
items(
items = mediaIndexedRows,
key = { (idx, _) -> "media_row_$idx" }
) { (rowIdx, rowPhotos) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
) {
rowPhotos.forEachIndexed { colIdx, media ->
val globalIndex = rowIdx * mediaColumns + colIdx
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight),
beyondBoundsPageCount = 0,
verticalAlignment = Alignment.Top,
userScrollEnabled = true
) { page ->
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) {
OtherProfileSharedTabContent(
selectedTab = tabs[page],
sharedContent = sharedContent,
isDarkTheme = isDarkTheme,
accountPublicKey = activeAccountPublicKey,
accountPrivateKey = activeAccountPrivateKey,
onMediaClick = { index ->
imageViewerInitialIndex = index
showImageViewer = true
},
onFileClick = { file ->
val opened = openSharedFile(context, file)
if (!opened) {
Toast.makeText(
context,
"File is not available on this device",
Toast.LENGTH_SHORT
)
.show()
}
},
onLinkClick = { link ->
val normalizedLink =
if (link.startsWith("http://", ignoreCase = true) ||
link.startsWith("https://", ignoreCase = true)
) {
link
} else {
"https://$link"
// Check cache first
val cachedBitmap = mediaBitmapStates[media.key]
?: SharedMediaBitmapCache.get(media.key)
// Only launch decode for items not yet cached
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
LaunchedEffect(media.key) {
mediaDecodeSemaphore.withPermit {
val bitmap = withContext(Dispatchers.IO) {
resolveSharedPhotoBitmap(
context = context,
media = media,
accountPublicKey = activeAccountPublicKey,
accountPrivateKey = activeAccountPrivateKey
)
}
mediaBitmapStates[media.key] = bitmap
}
val opened =
}
}
val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key]
val model = remember(media.localUri, media.blob) {
resolveSharedMediaModel(media.localUri, media.blob)
}
// Decode blurred preview from base64 (small image ~4-16px)
val previewBitmap = remember(media.preview) {
if (media.preview.isNotBlank()) {
runCatching {
context.startActivity(
Intent(
Intent.ACTION_VIEW,
Uri.parse(normalizedLink)
)
)
}
.isSuccess
if (!opened) {
Toast.makeText(
context,
"Unable to open this link",
Toast.LENGTH_SHORT
)
.show()
val bytes = Base64.decode(media.preview, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
} else null
}
val isLoaded = resolvedBitmap != null || model != null
// Animate alpha for smooth fade-in
val imageAlpha by animateFloatAsState(
targetValue = if (isLoaded) 1f else 0f,
animationSpec = tween(300),
label = "media_fade"
)
Box(
modifier = Modifier
.size(mediaCellSize)
.clip(RoundedCornerShape(0.dp))
.clickable(
enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank()
) {
imageViewerInitialIndex = globalIndex
showImageViewer = true
},
contentAlignment = Alignment.Center
) {
// Blurred preview placeholder (always shown initially)
if (previewBitmap != null) {
Image(
bitmap = previewBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Fallback shimmer if no preview
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isDarkTheme) Color(0xFF1E1E1E)
else Color(0xFFECECEC)
)
)
}
// Full quality image fades in on top
if (resolvedBitmap != null) {
Image(
bitmap = resolvedBitmap.asImageBitmap(),
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
contentScale = ContentScale.Crop
)
} else if (model != null) {
coil.compose.AsyncImage(
model = model,
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
contentScale = ContentScale.Crop
)
}
}
}
)
// Fill remaining cells in last incomplete row
repeat(mediaColumns - rowPhotos.size) {
Spacer(modifier = Modifier.size(mediaCellSize))
}
}
}
}
}
OtherProfileTab.FILES -> {
if (sharedContent.files.isEmpty()) {
item(key = "files_empty") {
OtherProfileEmptyState(
animationAssetPath = "lottie/folder.json",
title = "No shared files",
subtitle = "Documents from this chat will appear here.",
isDarkTheme = isDarkTheme
)
}
} else {
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file ->
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val opened = openSharedFile(context, file)
if (!opened) {
Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show()
}
}
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(36.dp).clip(CircleShape)
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.height(2.dp))
Text(text = "${formatFileSize(file.sizeBytes)}${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
}
if (index != sharedContent.files.lastIndex) {
Divider(color = fileDivider, thickness = 0.5.dp)
}
}
}
}
}
OtherProfileTab.LINKS -> {
if (sharedContent.links.isEmpty()) {
item(key = "links_empty") {
OtherProfileEmptyState(
animationAssetPath = "lottie/earth.json",
title = "No shared links",
subtitle = "Links from your messages will appear here.",
isDarkTheme = isDarkTheme
)
}
} else {
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link ->
Column {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}"
val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess
if (!opened) {
Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show()
}
}
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline)
Spacer(modifier = Modifier.height(3.dp))
Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp)
}
if (index != sharedContent.links.lastIndex) {
Divider(color = linkDivider, thickness = 0.5.dp)
}
}
}
}
}
}
item(key = "bottom_spacer") {
Spacer(modifier = Modifier.height(32.dp))
}
}
@@ -1426,261 +1584,6 @@ private fun OtherProfileSharedTabs(
}
}
@Composable
private fun OtherProfileSharedTabContent(
selectedTab: OtherProfileTab,
sharedContent: OtherProfileSharedContent,
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
onMediaClick: (Int) -> Unit,
onFileClick: (SharedFileItem) -> Unit,
onLinkClick: (String) -> Unit
) {
when (selectedTab) {
OtherProfileTab.MEDIA -> {
if (sharedContent.mediaPhotos.isEmpty()) {
OtherProfileEmptyState(
animationAssetPath = "lottie/saved.json",
title = "No shared media yet",
subtitle = "Photos from your chat will appear here.",
isDarkTheme = isDarkTheme
)
} else {
OtherProfileMediaGrid(
photos = sharedContent.mediaPhotos,
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
onMediaClick = onMediaClick
)
}
}
OtherProfileTab.FILES -> {
if (sharedContent.files.isEmpty()) {
OtherProfileEmptyState(
animationAssetPath = "lottie/folder.json",
title = "No shared files",
subtitle = "Documents from this chat will appear here.",
isDarkTheme = isDarkTheme
)
} else {
OtherProfileFileList(sharedContent.files, isDarkTheme, onFileClick)
}
}
OtherProfileTab.LINKS -> {
if (sharedContent.links.isEmpty()) {
OtherProfileEmptyState(
animationAssetPath = "lottie/earth.json",
title = "No shared links",
subtitle = "Links from your messages will appear here.",
isDarkTheme = isDarkTheme
)
} else {
OtherProfileLinksList(sharedContent.links, isDarkTheme, onLinkClick)
}
}
}
}
@Composable
private fun OtherProfileMediaGrid(
photos: List<SharedPhotoItem>,
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
onMediaClick: (Int) -> Unit
) {
val context = LocalContext.current
val columns = 3
val spacing = 1.dp
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val cellSize = (screenWidth - spacing * (columns - 1)) / columns
val rowCount = ceil(photos.size / columns.toFloat()).toInt().coerceAtLeast(1)
val gridHeight = cellSize * rowCount + spacing * (rowCount - 1)
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
modifier = Modifier.fillMaxWidth().height(gridHeight),
userScrollEnabled = false,
horizontalArrangement = Arrangement.spacedBy(spacing),
verticalArrangement = Arrangement.spacedBy(spacing)
) {
itemsIndexed(photos, key = { _, item -> item.key }) { index, media ->
val resolvedBitmap by
produceState<android.graphics.Bitmap?>(
initialValue = null,
media.key,
media.localUri,
media.blob.length,
media.preview,
media.chachaKey,
accountPublicKey,
accountPrivateKey
) {
value =
withContext(Dispatchers.IO) {
resolveSharedPhotoBitmap(
context = context,
media = media,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey
)
}
}
val model =
remember(media.localUri, media.blob) {
resolveSharedMediaModel(media.localUri, media.blob)
}
Box(
modifier =
Modifier.fillMaxWidth()
.aspectRatio(1f)
.clickable(
enabled =
model != null ||
resolvedBitmap != null ||
media.attachmentId.isNotBlank()
) { onMediaClick(index) },
contentAlignment = Alignment.Center
) {
if (resolvedBitmap != null) {
Image(
bitmap = resolvedBitmap!!.asImageBitmap(),
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (model != null) {
coil.compose.AsyncImage(
model = model,
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.fillMaxSize()
.background(
if (isDarkTheme) Color(0xFF151515)
else Color(0xFFE9ECF2)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.PhotoOff,
contentDescription = null,
tint = if (isDarkTheme) Color(0xFF7D7D7D) else Color(0xFF8F8F8F)
)
}
}
}
}
}
}
@Composable
private fun OtherProfileFileList(
items: List<SharedFileItem>,
isDarkTheme: Boolean,
onFileClick: (SharedFileItem) -> Unit
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val divider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
Column(modifier = Modifier.fillMaxWidth()) {
items.forEachIndexed { index, file ->
Row(
modifier =
Modifier.fillMaxWidth()
.clickable { onFileClick(file) }
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(36.dp)
.clip(CircleShape)
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.File,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = file.fileName,
color = textColor,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "${formatFileSize(file.sizeBytes)}${formatTimestamp(file.timestamp)}",
color = secondary,
fontSize = 12.sp
)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = TablerIcons.ChevronRight,
contentDescription = null,
tint = secondary.copy(alpha = 0.6f),
modifier = Modifier.size(16.dp)
)
}
if (index != items.lastIndex) {
Divider(color = divider, thickness = 0.5.dp)
}
}
}
}
@Composable
private fun OtherProfileLinksList(
links: List<SharedLinkItem>,
isDarkTheme: Boolean,
onLinkClick: (String) -> Unit
) {
val secondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val divider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
Column(modifier = Modifier.fillMaxWidth()) {
links.forEachIndexed { index, link ->
Column(
modifier =
Modifier.fillMaxWidth()
.clickable { onLinkClick(link.url) }
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
Text(
text = link.url,
color = PrimaryBlue,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textDecoration = TextDecoration.Underline
)
Spacer(modifier = Modifier.height(3.dp))
Text(text = formatTimestamp(link.timestamp), color = secondary, fontSize = 12.sp)
}
if (index != links.lastIndex) {
Divider(color = divider, thickness = 0.5.dp)
}
}
}
}
@Composable
private fun OtherProfileEmptyState(
animationAssetPath: String,