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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user