Промежуточный результат для 1.0.4 версии

This commit is contained in:
2026-02-22 08:54:46 +05:00
parent 3aa18fa9ac
commit 5b9b3f83f7
37 changed files with 5643 additions and 928 deletions

View File

@@ -25,6 +25,7 @@ fun AuthFlow(
hasExistingAccount: Boolean,
accounts: List<AccountInfo> = emptyList(),
accountManager: AccountManager,
startInCreateMode: Boolean = false,
onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> Unit = {}
) {
@@ -42,6 +43,7 @@ fun AuthFlow(
var currentScreen by remember {
mutableStateOf(
when {
startInCreateMode -> AuthScreen.WELCOME
hasExistingAccount -> AuthScreen.UNLOCK
else -> AuthScreen.WELCOME
}
@@ -56,6 +58,13 @@ fun AuthFlow(
}
var showCreateModal by remember { mutableStateOf(false) }
var isImportMode by remember { mutableStateOf(false) }
// If parent requests create mode while AuthFlow is alive, jump to Welcome/Create path.
LaunchedEffect(startInCreateMode) {
if (startInCreateMode && currentScreen == AuthScreen.UNLOCK) {
currentScreen = AuthScreen.WELCOME
}
}
// Handle system back button
BackHandler(enabled = currentScreen != AuthScreen.UNLOCK && !(currentScreen == AuthScreen.WELCOME && !hasExistingAccount)) {

View File

@@ -72,6 +72,7 @@ import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
import com.rosetta.messenger.ui.chats.components.*
import com.rosetta.messenger.ui.chats.components.ImageEditorScreen
import com.rosetta.messenger.ui.chats.components.InAppCameraScreen
@@ -489,7 +490,10 @@ fun ChatDetailScreen(
}
// Динамический subtitle: typing > online > offline
val isSystemAccount = user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount
val chatSubtitle =
when {
isSavedMessages -> "Notes"
@@ -1044,8 +1048,8 @@ fun ChatDetailScreen(
.Ellipsis
)
if (!isSavedMessages &&
user.verified >
0
(user.verified >
0 || isRosettaOfficial)
) {
Spacer(
modifier =
@@ -1055,7 +1059,7 @@ fun ChatDetailScreen(
)
VerifiedBadge(
verified =
user.verified,
if (user.verified > 0) user.verified else 1,
size =
16,
isDarkTheme =
@@ -1873,7 +1877,8 @@ fun ChatDetailScreen(
else
16.dp
),
reverseLayout = true
reverseLayout = true,
verticalArrangement = Arrangement.Bottom
) {
itemsIndexed(
messagesWithDates,
@@ -2127,57 +2132,98 @@ fun ChatDetailScreen(
}
} // Конец Column внутри Scaffold content
// 📎 Media Picker INLINE OVERLAY (Telegram-style gallery над клавиатурой)
// Теперь это НЕ Dialog, а обычный composable внутри того же layout!
MediaPickerBottomSheet(
isVisible = showMediaPicker,
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia, caption ->
// 📸 Отправляем фото напрямую с caption
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
// 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style)
// Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet
val USE_NEW_ATTACH_ALERT = true
if (USE_NEW_ATTACH_ALERT) {
ChatAttachAlert(
isVisible = showMediaPicker,
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia, caption ->
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
// 📸 Отправляем фото с caption напрямую
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption)
},
onOpenCamera = {
// 📷 Открываем встроенную камеру (без системного превью!)
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
window?.let { win ->
androidx.core.view.WindowCompat.getInsetsController(win, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
view.findFocus()?.clearFocus()
(context as? Activity)?.currentFocus?.clearFocus()
keyboardController?.hide()
focusManager.clearFocus(force = true)
showEmojiPicker = false
showMediaPicker = false
showInAppCamera = true
},
onOpenFilePicker = {
// 📄 Открываем файловый пикер
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
// 👤 Отправляем свой аватар (как в desktop)
viewModel.sendAvatarMessage()
},
recipientName = user.title
)
viewModel.sendImageFromUri(mediaItem.uri, caption)
},
onOpenCamera = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
window?.let { win ->
androidx.core.view.WindowCompat.getInsetsController(win, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
view.findFocus()?.clearFocus()
(context as? Activity)?.currentFocus?.clearFocus()
keyboardController?.hide()
focusManager.clearFocus(force = true)
showEmojiPicker = false
showMediaPicker = false
showInAppCamera = true
},
onOpenFilePicker = {
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
viewModel.sendAvatarMessage()
},
recipientName = user.title
)
} else {
MediaPickerBottomSheet(
isVisible = showMediaPicker,
onDismiss = { showMediaPicker = false },
isDarkTheme = isDarkTheme,
currentUserPublicKey = currentUserPublicKey,
onMediaSelected = { selectedMedia, caption ->
val imageUris =
selectedMedia.filter { !it.isVideo }.map { it.uri }
if (imageUris.isNotEmpty()) {
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageGroupFromUris(imageUris, caption)
}
},
onMediaSelectedWithCaption = { mediaItem, caption ->
showMediaPicker = false
inputFocusTrigger++
viewModel.sendImageFromUri(mediaItem.uri, caption)
},
onOpenCamera = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
window?.let { win ->
androidx.core.view.WindowCompat.getInsetsController(win, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
view.findFocus()?.clearFocus()
(context as? Activity)?.currentFocus?.clearFocus()
keyboardController?.hide()
focusManager.clearFocus(force = true)
showEmojiPicker = false
showMediaPicker = false
showInAppCamera = true
},
onOpenFilePicker = {
filePickerLauncher.launch("*/*")
},
onAvatarClick = {
viewModel.sendAvatarMessage()
},
recipientName = user.title
)
}
} // Закрытие Box wrapper для Scaffold content
} // Закрытие Box

View File

@@ -574,6 +574,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
subscribedToOnlineStatus = false // 🔥 Сбрасываем флаг подписки при смене диалога
isDialogActive = true // 🔥 Диалог активен!
// 📝 Восстанавливаем черновик для этого диалога (draft, как в Telegram)
val draft = com.rosetta.messenger.data.DraftManager.getDraft(publicKey)
_inputText.value = draft ?: ""
// 📨 Применяем Forward сообщения СРАЗУ после сброса
if (hasForward) {
// Конвертируем ForwardMessage в ReplyMessage
@@ -1598,6 +1602,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** Обновить текст ввода */
fun updateInputText(text: String) {
_inputText.value = text
// 📝 Сохраняем черновик при каждом изменении текста (draft, как в Telegram)
opponentKey?.let { key ->
com.rosetta.messenger.data.DraftManager.saveDraft(key, text)
}
}
/**
@@ -1850,7 +1858,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
addMessageSafely(optimisticMessage)
_inputText.value = ""
// 🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации
// <EFBFBD> Очищаем черновик после отправки
opponentKey?.let { com.rosetta.messenger.data.DraftManager.clearDraft(it) }
// <20>🔥 Очищаем reply после отправки - данные сохраняются в displayReplyMessages для анимации
clearReplyMessages()
// Кэшируем текст

View File

@@ -21,8 +21,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.input.nestedscroll.nestedScroll
@@ -30,16 +38,21 @@ import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
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.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.view.drawToBitmap
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.BuildConfig
@@ -70,6 +83,8 @@ import java.text.SimpleDateFormat
import java.util.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.math.hypot
import kotlin.math.roundToInt
@Immutable
data class Chat(
@@ -168,6 +183,26 @@ fun getAvatarText(publicKey: String): String {
return publicKey.take(2).uppercase()
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
private val TELEGRAM_DIALOG_ROW_HEIGHT = 72.dp
private val TELEGRAM_DIALOG_VERTICAL_PADDING = 9.dp
private val TELEGRAM_DIALOG_AVATAR_GAP =
TELEGRAM_DIALOG_TEXT_START - TELEGRAM_DIALOG_AVATAR_START - TELEGRAM_DIALOG_AVATAR_SIZE
private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
if (bounds.width <= 0 || bounds.height <= 0) return 0f
val width = bounds.width.toFloat()
val height = bounds.height.toFloat()
return maxOf(
hypot(center.x, center.y),
hypot(width - center.x, center.y),
hypot(center.x, height - center.y),
hypot(width - center.x, height - center.y)
)
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ChatsListScreen(
@@ -195,9 +230,9 @@ fun ChatsListScreen(
onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
onLogout: () -> Unit,
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {}
onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {}
) {
// Theme transition state
var hasInitialized by remember { mutableStateOf(false) }
@@ -209,6 +244,67 @@ fun ChatsListScreen(
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeToggleCenterInRoot by remember { mutableStateOf<Offset?>(null) }
var themeRevealActive by remember { mutableStateOf(false) }
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
fun startThemeReveal() {
if (themeRevealActive) {
return
}
if (rootSize.width <= 0 || rootSize.height <= 0) {
onToggleTheme()
return
}
val center =
themeToggleCenterInRoot
?: Offset(rootSize.width * 0.85f, rootSize.height * 0.12f)
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
if (snapshotBitmap == null) {
onToggleTheme()
return
}
val toDark = !isDarkTheme
val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) {
onToggleTheme()
return
}
themeRevealActive = true
themeRevealToDark = toDark
themeRevealCenter = center
themeRevealSnapshot = snapshotBitmap.asImageBitmap()
scope.launch {
try {
if (toDark) {
themeRevealRadius.snapTo(0f)
} else {
themeRevealRadius.snapTo(maxRadius)
}
onToggleTheme()
withFrameNanos { }
themeRevealRadius.animateTo(
targetValue = if (toDark) maxRadius else 0f,
animationSpec =
tween(
durationMillis = 400,
easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
)
)
} finally {
themeRevealSnapshot = null
themeRevealActive = false
}
}
}
// 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen
// Используем DisposableEffect чтобы срабатывало при каждом появлении экрана
@@ -336,6 +432,7 @@ fun ChatsListScreen(
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
var deviceResolveRequest by
remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
@@ -520,6 +617,7 @@ fun ChatsListScreen(
Box(
modifier =
Modifier.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
.navigationBarsPadding()
) {
@@ -594,6 +692,27 @@ fun ChatsListScreen(
Color.White
.copy(alpha = 0.2f)
)
.combinedClickable(
onClick = {
scope.launch {
accountsSectionExpanded = false
drawerState.close()
kotlinx.coroutines.delay(150)
onProfileClick()
}
},
onLongClick = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
accountToDelete =
allAccounts
.firstOrNull {
it.publicKey ==
accountPublicKey
}
}
)
.padding(3.dp),
contentAlignment =
Alignment.Center
@@ -616,8 +735,24 @@ fun ChatsListScreen(
// Theme toggle icon
IconButton(
onClick = { onToggleTheme() },
modifier = Modifier.size(48.dp)
onClick = { startThemeReveal() },
modifier =
Modifier.size(48.dp)
.onGloballyPositioned {
val pos =
it.positionInRoot()
themeToggleCenterInRoot =
Offset(
x =
pos.x +
it.size.width /
2f,
y =
pos.y +
it.size.height /
2f
)
}
) {
Icon(
painter = painterResource(
@@ -664,7 +799,8 @@ fun ChatsListScreen(
)
VerifiedBadge(
verified = 1,
size = 15
size = 15,
badgeTint = Color.White
)
}
}
@@ -726,16 +862,24 @@ fun ChatsListScreen(
modifier =
Modifier.fillMaxWidth()
.height(48.dp)
.clickable {
if (!isCurrentAccount) {
scope.launch {
accountsSectionExpanded = false
drawerState.close()
kotlinx.coroutines.delay(150)
onSwitchAccount(account.publicKey)
.combinedClickable(
onClick = {
if (!isCurrentAccount) {
scope.launch {
accountsSectionExpanded = false
drawerState.close()
kotlinx.coroutines.delay(150)
onSwitchAccount(account.publicKey)
}
}
},
onLongClick = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
accountToDelete = account
}
}
)
.padding(start = 14.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -857,8 +1001,8 @@ fun ChatsListScreen(
else Color(0xFF889198)
val menuTextColor =
if (isDarkTheme) Color(0xFFF4FFFFFF)
else Color(0xFF444444)
if (isDarkTheme) Color(0xFFF4FFFFFF.toInt())
else Color(0xFF444444.toInt())
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
@@ -933,7 +1077,7 @@ fun ChatsListScreen(
}
// ═══════════════════════════════════════════════════════════
// 🚪 FOOTER - Logout & Version
// FOOTER - Version
// ═══════════════════════════════════════════════════════════
Column(modifier = Modifier.fillMaxWidth()) {
Divider(
@@ -944,22 +1088,6 @@ fun ChatsListScreen(
thickness = 0.5.dp
)
// Logout
DrawerMenuItemEnhanced(
painter = TelegramIcons.Leave,
text = "Log Out",
iconColor = Color(0xFFFF4444),
textColor = Color(0xFFFF4444),
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(150)
onLogout()
}
}
)
// Version info
Box(
modifier =
@@ -1667,16 +1795,24 @@ fun ChatsListScreen(
}
items(
currentDialogs,
key = { it.opponentKey },
contentType = { "dialog" }
items = currentDialogs,
key = { dialog ->
dialog.opponentKey
},
contentType = { _ ->
"dialog"
}
) { dialog ->
val isSavedMessages =
dialog.opponentKey ==
accountPublicKey
val isPinnedDialog =
pinnedChats
.contains(
dialog.opponentKey
)
val isSystemSafeDialog =
dialog.opponentKey ==
MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
MessageRepository.isSystemAccount(dialog.opponentKey)
val isBlocked =
blockedUsers
.contains(
@@ -1693,6 +1829,34 @@ fun ChatsListScreen(
)
}
}
val isSelectedDialog =
selectedChatKeys
.contains(
dialog.opponentKey
)
val dividerBackgroundColor =
when {
isSelectedDialog ->
if (isDarkTheme)
Color(
0xFF1A3A5C
)
else
Color(
0xFFD6EAFF
)
isPinnedDialog ->
if (isDarkTheme)
Color(
0xFF232323
)
else
Color(
0xFFE8E8ED
)
else ->
listBackgroundColor
}
Column(
modifier =
@@ -1728,7 +1892,7 @@ fun ChatsListScreen(
swipedItemKey ==
dialog.opponentKey,
isSelected =
selectedChatKeys.contains(dialog.opponentKey),
isSelectedDialog,
onSwipeStarted = {
swipedItemKey =
dialog.opponentKey
@@ -1780,10 +1944,7 @@ fun ChatsListScreen(
dialog
},
isPinned =
pinnedChats
.contains(
dialog.opponentKey
),
isPinnedDialog,
swipeEnabled =
!isSystemSafeDialog,
onPin = {
@@ -1796,16 +1957,45 @@ fun ChatsListScreen(
// 🔥 СЕПАРАТОР -
// линия разделения
// между диалогами
Divider(
Box(
modifier =
Modifier.padding(
start =
84.dp
),
color =
dividerColor,
thickness =
0.5.dp
Modifier.fillMaxWidth()
.height(
0.5.dp
)
.background(
dividerBackgroundColor
)
) {
Divider(
modifier =
Modifier.fillMaxWidth()
.padding(
start =
TELEGRAM_DIALOG_TEXT_START
),
color =
dividerColor,
thickness =
0.5.dp
)
}
}
}
// Footer hint
item(key = "chat_list_footer") {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "Tap on search to find new chats",
color = Color(0xFF8E8E93),
fontSize = 14.sp,
fontWeight = FontWeight.Normal
)
}
}
@@ -1972,6 +2162,122 @@ fun ChatsListScreen(
)
}
if (themeRevealActive) {
val snapshot = themeRevealSnapshot
if (snapshot != null) {
Canvas(
modifier =
Modifier.fillMaxSize()
.graphicsLayer(
compositingStrategy =
CompositingStrategy.Offscreen
)
) {
val destinationSize =
IntSize(
width = size.width.roundToInt(),
height = size.height.roundToInt()
)
if (themeRevealToDark) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
drawCircle(
color = Color.Transparent,
radius = themeRevealRadius.value,
center = themeRevealCenter,
blendMode = BlendMode.Clear
)
} else {
val radius = themeRevealRadius.value
if (radius > 0f) {
val clipCirclePath =
Path().apply {
addOval(
Rect(
left = themeRevealCenter.x - radius,
top = themeRevealCenter.y - radius,
right = themeRevealCenter.x + radius,
bottom = themeRevealCenter.y + radius
)
)
}
clipPath(clipCirclePath) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize =
IntSize(
snapshot.width,
snapshot.height
),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
}
}
}
}
}
}
accountToDelete?.let { account ->
val isCurrentAccount = account.publicKey == accountPublicKey
val displayName =
resolveAccountDisplayName(
account.publicKey,
account.name,
account.username
)
AlertDialog(
onDismissRequest = { accountToDelete = null },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
text = "Delete Account",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
text =
if (isCurrentAccount) {
"Delete \"$displayName\" from this device? You will return to the main login screen."
} else {
"Delete \"$displayName\" from this device?"
},
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val publicKey = account.publicKey
accountToDelete = null
allAccounts =
allAccounts.filterNot {
it.publicKey == publicKey
}
scope.launch {
accountsSectionExpanded = false
drawerState.close()
onDeleteAccountFromSidebar(publicKey)
}
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { accountToDelete = null }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
} // Close Box
}
@@ -2138,13 +2444,23 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
modifier =
Modifier.fillMaxWidth()
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar placeholder
Box(modifier = Modifier.size(56.dp).clip(CircleShape).background(shimmerColor))
Box(
modifier =
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape)
.background(shimmerColor)
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) {
// Name placeholder
@@ -2180,7 +2496,7 @@ private fun SkeletonDialogItem(shimmerColor: Color, isDarkTheme: Boolean) {
Divider(
color = dividerColor,
thickness = 0.5.dp,
modifier = Modifier.padding(start = 84.dp)
modifier = Modifier.padding(start = TELEGRAM_DIALOG_TEXT_START)
)
}
@@ -2290,21 +2606,24 @@ fun ChatItem(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar with real image
AvatarImage(
publicKey = chat.publicKey,
avatarRepository = avatarRepository,
size = 56.dp,
size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme,
showOnlineIndicator = true,
isOnline = chat.isOnline,
displayName = chat.name // 🔥 Для инициалов
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) {
Row(
@@ -2566,7 +2885,7 @@ fun SwipeableDialogItem(
val swipeWidthPx = with(density) { swipeWidthDp.toPx() }
// Фиксированная высота элемента (как в DialogItem)
val itemHeight = 80.dp
val itemHeight = TELEGRAM_DIALOG_ROW_HEIGHT
// Close when another item starts swiping (like Telegram)
LaunchedEffect(isSwipedOpen) {
@@ -2716,9 +3035,14 @@ fun SwipeableDialogItem(
}
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные значения
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
val currentIsDrawerOpen by rememberUpdatedState(isDrawerOpen)
val currentSwipeEnabled by rememberUpdatedState(swipeEnabled)
val currentSwipeWidthPx by rememberUpdatedState(swipeWidthPx)
val currentOnSwipeStarted by rememberUpdatedState(onSwipeStarted)
val currentOnSwipeClosed by rememberUpdatedState(onSwipeClosed)
Column(
modifier =
Modifier.fillMaxSize()
@@ -2736,7 +3060,7 @@ fun SwipeableDialogItem(
)
// Don't handle swipes when drawer is open
if (isDrawerOpen) return@awaitEachGesture
if (currentIsDrawerOpen) return@awaitEachGesture
velocityTracker.resetTracking()
var totalDragX = 0f
@@ -2747,6 +3071,7 @@ fun SwipeableDialogItem(
// Phase 1: Determine gesture type (tap / long-press / drag)
// Wait up to longPressTimeout; if no up or slop → long press
var gestureType = "unknown"
var fingerIsUp = false
val result = withTimeoutOrNull(longPressTimeoutMs) {
while (true) {
@@ -2780,8 +3105,31 @@ fun SwipeableDialogItem(
Unit
}
// Timeout → long press
if (result == null) gestureType = "longpress"
// Timeout → check if finger lifted during the race window
if (result == null) {
// Grace period: check if up event arrived just as timeout fired
val graceResult = withTimeoutOrNull(32L) {
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id }
if (change == null) {
gestureType = "cancelled"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
if (change.changedToUpIgnoreConsumed()) {
change.consume()
gestureType = "tap"
fingerIsUp = true
return@withTimeoutOrNull Unit
}
// Still moving/holding — it's a real long press
break
}
Unit
}
if (gestureType == "unknown") gestureType = "longpress"
}
when (gestureType) {
"tap" -> {
@@ -2811,10 +3159,10 @@ fun SwipeableDialogItem(
when {
// Horizontal left swipe — reveal action buttons
swipeEnabled && dominated && totalDragX < 0 -> {
currentSwipeEnabled && dominated && totalDragX < 0 -> {
passedSlop = true
claimed = true
onSwipeStarted()
currentOnSwipeStarted()
}
// Horizontal right swipe with buttons open — close them
dominated && totalDragX > 0 && offsetX != 0f -> {
@@ -2828,7 +3176,7 @@ fun SwipeableDialogItem(
else -> {
if (offsetX != 0f) {
offsetX = 0f
onSwipeClosed()
currentOnSwipeClosed()
}
return@awaitEachGesture
}
@@ -2847,8 +3195,9 @@ fun SwipeableDialogItem(
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
val curSwipeWidthPx = currentSwipeWidthPx
val newOffset = offsetX + delta.x
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
offsetX = newOffset.coerceIn(-curSwipeWidthPx, 0f)
velocityTracker.addPosition(
change.uptimeMillis,
change.position
@@ -2858,6 +3207,7 @@ fun SwipeableDialogItem(
// Phase 3: Snap animation
if (claimed) {
val curSwipeWidthPx = currentSwipeWidthPx
val velocity =
velocityTracker
.calculateVelocity()
@@ -2865,18 +3215,18 @@ fun SwipeableDialogItem(
when {
velocity > 150f -> {
offsetX = 0f
onSwipeClosed()
currentOnSwipeClosed()
}
velocity < -300f -> {
offsetX = -swipeWidthPx
offsetX = -curSwipeWidthPx
}
kotlin.math.abs(offsetX) >
swipeWidthPx / 2 -> {
offsetX = -swipeWidthPx
curSwipeWidthPx / 2 -> {
offsetX = -curSwipeWidthPx
}
else -> {
offsetX = 0f
onSwipeClosed()
currentOnSwipeClosed()
}
}
}
@@ -2893,14 +3243,6 @@ fun SwipeableDialogItem(
avatarRepository = avatarRepository,
onClick = null // Tap handled by parent pointerInput
)
// Сепаратор внутри контента
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
Divider(
modifier = Modifier.padding(start = 84.dp),
color = dividerColor,
thickness = 0.5.dp
)
}
}
}
@@ -2998,11 +3340,14 @@ fun DialogItemContent(
if (onClick != null) Modifier.clickable(onClick = onClick)
else Modifier
)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically
) {
// Avatar container with online indicator
Box(modifier = Modifier.size(56.dp)) {
Box(modifier = Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)) {
// Avatar
if (dialog.isSavedMessages) {
Box(
@@ -3042,7 +3387,7 @@ fun DialogItemContent(
com.rosetta.messenger.ui.components.AvatarImage(
publicKey = dialog.opponentKey,
avatarRepository = avatarRepository,
size = 56.dp,
size = TELEGRAM_DIALOG_AVATAR_SIZE,
isDarkTheme = isDarkTheme,
displayName = avatarDisplayName
)
@@ -3069,7 +3414,7 @@ fun DialogItemContent(
}
}
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
// Name and last message
Column(modifier = Modifier.weight(1f)) {
@@ -3090,9 +3435,12 @@ fun DialogItemContent(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (dialog.verified > 0) {
val isRosettaOfficial = dialog.opponentTitle.equals("Rosetta", ignoreCase = true) ||
dialog.opponentUsername.equals("rosetta", ignoreCase = true) ||
MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(verified = dialog.verified, size = 16)
VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16)
}
// 🔒 Красная иконка замочка для заблокированных пользователей
if (isBlocked) {
@@ -3119,7 +3467,9 @@ fun DialogItemContent(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
// 📁 Для Saved Messages ВСЕГДА показываем синие двойные
// <EFBFBD> Скрываем статус доставки когда есть черновик (как в Telegram)
if (dialog.draftText.isNullOrEmpty()) {
// <20>📁 Для Saved Messages ВСЕГДА показываем синие двойные
// галочки (прочитано)
if (dialog.isSavedMessages) {
Box(
@@ -3270,6 +3620,7 @@ fun DialogItemContent(
}
}
}
} // 📝 end if (draftText.isNullOrEmpty) — скрываем статус при наличии черновика
val formattedTime =
remember(dialog.lastMessageTimestamp) {
@@ -3297,8 +3648,29 @@ fun DialogItemContent(
// 🔥 Показываем typing индикатор или последнее сообщение
if (isTyping) {
TypingIndicatorSmall()
} else if (!dialog.draftText.isNullOrEmpty()) {
// 📝 Показываем черновик (как в Telegram)
Row(modifier = Modifier.weight(1f)) {
Text(
text = "Draft: ",
fontSize = 14.sp,
color = Color(0xFFFF3B30), // Красный как в Telegram
fontWeight = FontWeight.Normal,
maxLines = 1
)
AppleEmojiText(
text = dialog.draftText,
modifier = Modifier.weight(1f),
fontSize = 14.sp,
color = secondaryTextColor,
fontWeight = FontWeight.Normal,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
)
}
} else {
// <EFBFBD> Определяем что показывать - attachment или текст
// 📎 Определяем что показывать - attachment или текст
val displayText =
when {
dialog.lastMessageAttachmentType ==
@@ -3497,13 +3869,16 @@ fun RequestsSection(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
.padding(
horizontal = TELEGRAM_DIALOG_AVATAR_START,
vertical = TELEGRAM_DIALOG_VERTICAL_PADDING
),
verticalAlignment = Alignment.CenterVertically
) {
// Иконка — круглый аватар как Archived Chats в Telegram
Box(
modifier =
Modifier.size(56.dp)
Modifier.size(TELEGRAM_DIALOG_AVATAR_SIZE)
.clip(CircleShape)
.background(iconBgColor),
contentAlignment = Alignment.Center
@@ -3516,7 +3891,7 @@ fun RequestsSection(
)
}
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(TELEGRAM_DIALOG_AVATAR_GAP))
Column(modifier = Modifier.weight(1f)) {
// Заголовок

View File

@@ -6,6 +6,7 @@ import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
@@ -40,7 +41,8 @@ data class DialogUiModel(
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
val lastMessageRead: Int = 0, // Прочитано (0/1)
val lastMessageAttachmentType: String? =
null // 📎 Тип attachment: "Photo", "File", или null
null, // 📎 Тип attachment: "Photo", "File", или null
val draftText: String? = null // 📝 Черновик сообщения (как в Telegram)
)
/**
@@ -95,9 +97,17 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
// 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
// 📝 Комбинируем с drafts для показа черновиков в списке чатов
val chatsState: StateFlow<ChatsUiState> =
combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count ->
ChatsUiState(dialogs, requests, count)
combine(_dialogs, _requests, _requestsCount, DraftManager.drafts) { dialogs, requests, count, drafts ->
// 📝 Обогащаем диалоги черновиками
val dialogsWithDrafts = if (drafts.isEmpty()) dialogs else {
dialogs.map { dialog ->
val draft = drafts[dialog.opponentKey]
if (draft != null) dialog.copy(draftText = draft) else dialog
}
}
ChatsUiState(dialogsWithDrafts, requests, count)
}
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
.stateIn(
@@ -140,7 +150,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
currentAccount = publicKey
currentPrivateKey = privateKey
// 🔥 Очищаем устаревшие данные от предыдущего аккаунта
// <EFBFBD> Устанавливаем аккаунт для DraftManager (загрузит черновики из SharedPreferences)
DraftManager.setAccount(publicKey)
// <20>🔥 Очищаем устаревшие данные от предыдущего аккаунта
_dialogs.value = emptyList()
_requests.value = emptyList()
_requestsCount.value = 0

View File

@@ -0,0 +1,77 @@
package com.rosetta.messenger.ui.chats.attach
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/**
* Avatar tab content for the attach alert.
* Shows a styled button to send the user's avatar.
*/
@Composable
internal fun AttachAlertAvatarLayout(
onAvatarClick: () -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
painter = TelegramIcons.Contact,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Send Avatar",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Share your profile avatar with this contact",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(28.dp))
Button(
onClick = onAvatarClick,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 24.dp)
) {
Text(
text = "Send Avatar",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
package com.rosetta.messenger.ui.chats.attach
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
/**
* File/Document tab content for the attach alert.
* Phase 1: Styled button that launches the system file picker.
*/
@Composable
internal fun AttachAlertFileLayout(
onOpenFilePicker: () -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(32.dp)
) {
Icon(
painter = TelegramIcons.File,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(72.dp)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "Send a File",
color = textColor,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Browse files on your device and share them in the chat",
color = secondaryTextColor,
fontSize = 14.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(28.dp))
Button(
onClick = onOpenFilePicker,
colors = ButtonDefaults.buttonColors(containerColor = PrimaryBlue),
shape = RoundedCornerShape(12.dp),
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 24.dp)
) {
Text(
text = "Choose File",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
}
}

View File

@@ -0,0 +1,102 @@
package com.rosetta.messenger.ui.chats.attach
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import android.content.pm.PackageManager
import com.rosetta.messenger.ui.chats.components.ThumbnailPosition
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import androidx.compose.material3.Icon
/**
* Photo/Video tab content for the attach alert.
* Shows a 3-column grid with camera preview in the first cell,
* album dropdown, multi-select with numbered badges.
*/
@Composable
internal fun AttachAlertPhotoLayout(
state: AttachAlertUiState,
gridState: LazyGridState,
onCameraClick: () -> Unit,
onItemClick: (MediaItem, ThumbnailPosition) -> Unit,
onItemCheckClick: (MediaItem) -> Unit,
onItemLongClick: (MediaItem) -> Unit,
onRequestPermission: () -> Unit,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
when {
!state.hasPermission -> {
PermissionRequestView(
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onRequestPermission = onRequestPermission
)
}
state.isLoading -> {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = PrimaryBlue,
modifier = Modifier.size(48.dp)
)
}
}
state.visibleMediaItems.isEmpty() -> {
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = TelegramIcons.Photos,
contentDescription = null,
tint = secondaryTextColor,
modifier = Modifier.size(64.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No photos or videos",
color = secondaryTextColor,
fontSize = 16.sp
)
}
}
}
else -> {
MediaGrid(
mediaItems = state.visibleMediaItems,
selectedItemOrder = state.selectedItemOrder,
showCameraItem = state.visibleAlbum?.isAllMedia != false,
gridState = gridState,
onCameraClick = onCameraClick,
onItemClick = onItemClick,
onItemCheckClick = onItemCheckClick,
onItemLongClick = onItemLongClick,
isDarkTheme = isDarkTheme,
modifier = modifier
)
}
}
}

View File

@@ -0,0 +1,69 @@
package com.rosetta.messenger.ui.chats.attach
import android.net.Uri
const val ALL_MEDIA_ALBUM_ID = 0L
/**
* Media item from gallery
*/
data class MediaItem(
val id: Long,
val uri: Uri,
val mimeType: String,
val duration: Long = 0, // For videos, in milliseconds
val dateModified: Long = 0,
val bucketId: Long = 0,
val bucketName: String = ""
) {
val isVideo: Boolean get() = mimeType.startsWith("video/")
}
data class MediaAlbum(
val id: Long,
val name: String,
val items: List<MediaItem>,
val isAllMedia: Boolean = false
)
data class MediaPickerData(
val items: List<MediaItem>,
val albums: List<MediaAlbum>
)
/**
* Tab types for the attach alert
*/
enum class AttachAlertTab {
PHOTO,
FILE,
AVATAR
}
/**
* UI state for the attach alert, managed by AttachAlertViewModel
*/
data class AttachAlertUiState(
val currentTab: AttachAlertTab = AttachAlertTab.PHOTO,
val mediaItems: List<MediaItem> = emptyList(),
val albums: List<MediaAlbum> = emptyList(),
val selectedAlbumId: Long = ALL_MEDIA_ALBUM_ID,
val selectedItemOrder: List<Long> = emptyList(),
val isLoading: Boolean = true,
val hasPermission: Boolean = false,
val captionText: String = "",
val editingItem: MediaItem? = null
) {
val selectedCount: Int get() = selectedItemOrder.size
val visibleAlbum: MediaAlbum?
get() = albums.firstOrNull { it.id == selectedAlbumId } ?: albums.firstOrNull()
val visibleMediaItems: List<MediaItem>
get() = visibleAlbum?.items ?: mediaItems
val canSwitchAlbums: Boolean get() = albums.size > 1
}
// ThumbnailPosition is defined in com.rosetta.messenger.ui.chats.components.ImageEditorScreen
// and reused via import. No duplicate needed here.

View File

@@ -0,0 +1,108 @@
package com.rosetta.messenger.ui.chats.attach
import android.app.Application
import android.content.Context
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AttachAlertViewModel(application: Application) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(AttachAlertUiState())
val uiState: StateFlow<AttachAlertUiState> = _uiState.asStateFlow()
fun loadMedia(context: Context) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val data = loadMediaPickerData(context)
_uiState.update {
it.copy(
mediaItems = data.items,
albums = data.albums,
selectedAlbumId = data.albums.firstOrNull()?.id ?: ALL_MEDIA_ALBUM_ID,
isLoading = false
)
}
}
}
fun selectTab(tab: AttachAlertTab) {
_uiState.update { it.copy(currentTab = tab) }
}
fun selectAlbum(albumId: Long) {
_uiState.update { it.copy(selectedAlbumId = albumId) }
}
fun toggleSelection(itemId: Long, maxSelection: Int = 10) {
_uiState.update { state ->
val currentOrder = state.selectedItemOrder
val newOrder = if (currentOrder.contains(itemId)) {
currentOrder.filterNot { it == itemId }
} else if (currentOrder.size < maxSelection) {
currentOrder + itemId
} else {
currentOrder
}
state.copy(selectedItemOrder = newOrder)
}
}
fun clearSelection() {
_uiState.update { it.copy(selectedItemOrder = emptyList(), captionText = "") }
}
fun updateCaption(text: String) {
_uiState.update { it.copy(captionText = text) }
}
fun setPermissionGranted(granted: Boolean) {
_uiState.update { it.copy(hasPermission = granted) }
}
fun setEditingItem(item: MediaItem?) {
_uiState.update { it.copy(editingItem = item) }
}
fun resolveSelectedMedia(): List<MediaItem> {
val state = _uiState.value
if (state.selectedItemOrder.isEmpty()) return emptyList()
val byId = state.mediaItems.associateBy { it.id }
return state.selectedItemOrder.mapNotNull { byId[it] }
}
fun selectionHeaderText(): String {
val state = _uiState.value
val count = state.selectedCount
if (count == 0) return ""
val byId = state.mediaItems.associateBy { it.id }
val selected = state.selectedItemOrder.mapNotNull { byId[it] }
val hasPhotos = selected.any { !it.isVideo }
val hasVideos = selected.any { it.isVideo }
return when {
hasPhotos && hasVideos -> "$count media selected"
hasVideos -> if (count == 1) "$count video selected" else "$count videos selected"
else -> if (count == 1) "$count photo selected" else "$count photos selected"
}
}
fun reset() {
_uiState.value = AttachAlertUiState()
}
fun onShow() {
_uiState.update {
it.copy(
selectedItemOrder = emptyList(),
captionText = "",
selectedAlbumId = ALL_MEDIA_ALBUM_ID,
currentTab = AttachAlertTab.PHOTO,
editingItem = null
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
package com.rosetta.messenger.ui.chats.attach
import android.content.ContentUris
import android.content.Context
import android.provider.MediaStore
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "MediaRepository"
/**
* Loads media items from device gallery, grouped into albums.
* Mirrors Telegram's MediaController album loading logic:
* - "All media" album (bucketId=0) contains everything sorted by date
* - Real albums sorted: All media first, then by item count descending
*/
suspend fun loadMediaPickerData(context: Context): MediaPickerData = withContext(Dispatchers.IO) {
val items = mutableListOf<MediaItem>()
try {
// Query images with bucket info
val imageProjection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.MIME_TYPE,
MediaStore.Images.Media.DATE_MODIFIED,
MediaStore.Images.Media.BUCKET_ID,
MediaStore.Images.Media.BUCKET_DISPLAY_NAME
)
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
imageProjection,
null,
null,
"${MediaStore.Images.Media.DATE_MODIFIED} DESC"
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE)
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_ID)
val bucketNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.BUCKET_DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val mimeType = cursor.getString(mimeColumn) ?: "image/*"
val dateModified = cursor.getLong(dateColumn)
val bucketId = cursor.getLong(bucketIdColumn)
val bucketName = cursor.getString(bucketNameColumn) ?: "Unknown"
val uri = ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
items.add(
MediaItem(
id = id,
uri = uri,
mimeType = mimeType,
dateModified = dateModified,
bucketId = bucketId,
bucketName = bucketName
)
)
}
}
// Query videos with bucket info
val videoProjection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.MIME_TYPE,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.DATE_MODIFIED,
MediaStore.Video.Media.BUCKET_ID,
MediaStore.Video.Media.BUCKET_DISPLAY_NAME
)
context.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
videoProjection,
null,
null,
"${MediaStore.Video.Media.DATE_MODIFIED} DESC"
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val mimeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.MIME_TYPE)
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED)
val bucketIdColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_ID)
val bucketNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val mimeType = cursor.getString(mimeColumn) ?: "video/*"
val duration = cursor.getLong(durationColumn)
val dateModified = cursor.getLong(dateColumn)
val bucketId = cursor.getLong(bucketIdColumn)
val bucketName = cursor.getString(bucketNameColumn) ?: "Unknown"
val uri = ContentUris.withAppendedId(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
id
)
// Use negative id to avoid collision with images
items.add(
MediaItem(
id = -id,
uri = uri,
mimeType = mimeType,
duration = duration,
dateModified = dateModified,
bucketId = bucketId,
bucketName = bucketName
)
)
}
}
// Sort all items by date
items.sortByDescending { it.dateModified }
} catch (e: Exception) {
Log.e(TAG, "Failed to load media", e)
}
// Build albums (like Telegram's updateAlbumsDropDown)
val allMediaAlbum = MediaAlbum(
id = ALL_MEDIA_ALBUM_ID,
name = "All media",
items = items,
isAllMedia = true
)
val bucketAlbums = items
.groupBy { it.bucketId }
.map { (bucketId, bucketItems) ->
MediaAlbum(
id = bucketId,
name = bucketItems.first().bucketName,
items = bucketItems.sortedByDescending { it.dateModified }
)
}
.sortedByDescending { it.items.size } // Largest albums first, like Telegram
val albums = listOf(allMediaAlbum) + bucketAlbums
MediaPickerData(items = items, albums = albums)
}

View File

@@ -1238,7 +1238,9 @@ fun ImageAttachment(
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
)
}
@@ -1246,7 +1248,9 @@ fun ImageAttachment(
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
)
}
@@ -1647,20 +1651,28 @@ fun FileAttachment(
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
)
}
MessageStatus.DELIVERED, MessageStatus.READ -> {
MessageStatus.DELIVERED -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint =
if (messageStatus == MessageStatus.READ) {
Color(0xFF4FC3F7)
} else {
Color.White.copy(alpha = 0.7f)
},
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
)
}
MessageStatus.READ -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint =
if (isDarkTheme) Color.White else Color(0xFF4FC3F7),
modifier = Modifier.size(14.dp)
)
}
@@ -2035,7 +2047,9 @@ fun AvatarAttachment(
Icon(
painter = TelegramIcons.Done,
contentDescription = "Sent",
tint = Color.White.copy(alpha = 0.6f),
tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
}
@@ -2043,7 +2057,9 @@ fun AvatarAttachment(
Icon(
painter = TelegramIcons.Done,
contentDescription = "Delivered",
tint = Color.White.copy(alpha = 0.6f),
tint =
if (isDarkTheme) Color.White
else Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
}

View File

@@ -324,6 +324,10 @@ fun MessageBubble(
if (message.isOutgoing) Color.White.copy(alpha = 0.7f)
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
}
val statusColor =
remember(message.isOutgoing, isDarkTheme, timeColor) {
if (message.isOutgoing) Color.White else timeColor
}
val isSafeSystemMessage =
isSystemSafeChat &&
@@ -817,7 +821,11 @@ fun MessageBubble(
status =
displayStatus,
timeColor =
timeColor,
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
@@ -901,7 +909,11 @@ fun MessageBubble(
status =
displayStatus,
timeColor =
timeColor,
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
@@ -978,7 +990,11 @@ fun MessageBubble(
status =
displayStatus,
timeColor =
timeColor,
statusColor,
isDarkTheme =
isDarkTheme,
isOutgoing =
message.isOutgoing,
timestamp =
message.timestamp
.time,
@@ -1078,6 +1094,8 @@ private fun buildSafeSystemAnnotatedText(text: String) = buildAnnotatedString {
fun AnimatedMessageStatus(
status: MessageStatus,
timeColor: Color,
isDarkTheme: Boolean = false,
isOutgoing: Boolean = false,
timestamp: Long = 0L,
onRetry: () -> Unit = {},
onDelete: () -> Unit = {}
@@ -1090,7 +1108,7 @@ fun AnimatedMessageStatus(
val targetColor =
when (effectiveStatus) {
MessageStatus.READ -> Color(0xFF4FC3F7)
MessageStatus.READ -> if (isDarkTheme || isOutgoing) Color.White else Color(0xFF4FC3F7)
MessageStatus.ERROR -> Color(0xFFE53935)
else -> timeColor
}
@@ -2126,6 +2144,7 @@ fun OtherProfileMenu(
onDismiss: () -> Unit,
isDarkTheme: Boolean,
isBlocked: Boolean,
isSystemAccount: Boolean = false,
onBlockClick: () -> Unit,
onClearChatClick: () -> Unit
) {
@@ -2153,13 +2172,15 @@ fun OtherProfileMenu(
dismissOnClickOutside = true
)
) {
ProfilePhotoMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = onBlockClick,
tintColor = iconColor,
textColor = textColor
)
if (!isSystemAccount) {
ProfilePhotoMenuItem(
icon = if (isBlocked) TelegramIcons.Done else TelegramIcons.Block,
text = if (isBlocked) "Unblock User" else "Block User",
onClick = onBlockClick,
tintColor = iconColor,
textColor = textColor
)
}
ProfilePhotoMenuItem(
icon = TelegramIcons.Delete,

View File

@@ -41,6 +41,7 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.zIndex
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
@@ -55,6 +56,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.exifinterface.media.ExifInterface
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
@@ -144,6 +147,28 @@ fun ImageEditorScreen(
thumbnailPosition: ThumbnailPosition? = null, // Позиция для Telegram-style анимации
skipEnterAnimation: Boolean = false // Из камеры — без fade, мгновенно
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false, // BackHandler handles this
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
// Configure Dialog window for fullscreen edge-to-edge
val dialogView = LocalView.current
SideEffect {
val dialogWindow = (dialogView.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window
?: (dialogView.context as? Activity)?.window
dialogWindow?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
win.statusBarColor = android.graphics.Color.BLACK
win.navigationBarColor = android.graphics.Color.BLACK
win.setWindowAnimations(0)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
val view = LocalView.current
@@ -186,7 +211,7 @@ fun ImageEditorScreen(
SystemBarsStyleUtils.capture(window, view)
}
LaunchedEffect(window, view) {
SideEffect {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
}
@@ -201,7 +226,7 @@ fun ImageEditorScreen(
)
}
}
// Функция для плавного закрытия
fun animatedDismiss() {
if (isClosing) return
@@ -315,6 +340,9 @@ fun ImageEditorScreen(
var photoEditor by remember { mutableStateOf<PhotoEditor?>(null) }
var photoEditorView by remember { mutableStateOf<PhotoEditorView?>(null) }
// Track whether user made drawing edits (to skip PhotoEditor save when only cropping)
var hasDrawingEdits by remember { mutableStateOf(false) }
// UCrop launcher
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
@@ -386,18 +414,17 @@ fun ImageEditorScreen(
animatedBackgroundAlpha = progress
}
// Telegram behavior: photo stays fullscreen, only input moves with keyboard
Box(
modifier = Modifier
.fillMaxSize()
// 🔥 Блокируем свайпы от SwipeBackContainer - на ImageEditor свайпы не должны работать
.background(Color.Black)
.zIndex(100f)
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
down.consume() // Поглощаем все touch события
down.consume()
}
}
.background(Color.Black)
) {
Box(
modifier =
@@ -438,9 +465,7 @@ fun ImageEditorScreen(
modifier = Modifier.fillMaxSize()
)
// ═══════════════════════════════════════════════════════════
// 🎛️ TOP BAR - Solid black (Telegram style)
// ═══════════════════════════════════════════════════════════
// TOP BAR
Box(
modifier = Modifier
.fillMaxWidth()
@@ -639,7 +664,8 @@ fun ImageEditorScreen(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = uriToSend
imageUri = uriToSend,
hasDrawingEdits = hasDrawingEdits
)
isSaving = false
@@ -735,6 +761,7 @@ fun ImageEditorScreen(
}
} else {
currentTool = EditorTool.DRAW
hasDrawingEdits = true
isEraserActive = false
photoEditor?.setBrushDrawingMode(true)
photoEditor?.brushColor = selectedColor.toArgb()
@@ -769,7 +796,8 @@ fun ImageEditorScreen(
context = context,
photoEditor = photoEditor,
photoEditorView = photoEditorView,
imageUri = currentImageUri
imageUri = currentImageUri,
hasDrawingEdits = hasDrawingEdits
)
isSaving = false
onSave(savedUri ?: currentImageUri)
@@ -783,6 +811,7 @@ fun ImageEditorScreen(
}
}
}
} // Dialog
}
/**
@@ -1220,12 +1249,12 @@ private suspend fun saveEditedImageOld(
}
try {
val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.png")
val tempFile = File(context.cacheDir, "edited_${System.currentTimeMillis()}.jpg")
val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.setCompressFormat(Bitmap.CompressFormat.PNG)
.setCompressQuality(100)
.setTransparencyEnabled(false)
.setCompressFormat(Bitmap.CompressFormat.JPEG)
.setCompressQuality(95)
.build()
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
@@ -1265,8 +1294,13 @@ private suspend fun saveEditedImageSync(
context: Context,
photoEditor: PhotoEditor?,
photoEditorView: PhotoEditorView?,
imageUri: Uri
imageUri: Uri,
hasDrawingEdits: Boolean = false
): Uri? {
// No drawing edits — return the source URI directly (clean crop from UCrop, no letterbox)
if (!hasDrawingEdits) {
return imageUri
}
return saveEditedImageSyncOld(
context = context,
photoEditor = photoEditor,
@@ -1291,14 +1325,14 @@ private suspend fun saveEditedImageSyncOld(
return try {
val tempFile = File(
context.cacheDir,
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.png"
"edited_${System.currentTimeMillis()}_${(0..9999).random()}.jpg"
)
val saveSettings = SaveSettings.Builder()
.setClearViewsEnabled(false)
.setTransparencyEnabled(true)
.setCompressFormat(Bitmap.CompressFormat.PNG)
.setCompressQuality(100)
.setTransparencyEnabled(false)
.setCompressFormat(Bitmap.CompressFormat.JPEG)
.setCompressQuality(95)
.build()
val savedPath = suspendCancellableCoroutine<String?> { continuation ->
@@ -1381,8 +1415,10 @@ private suspend fun removeLetterboxFromEditedImage(
return@runCatching editedUri
}
// Safety guard against invalid/asymmetric crop.
if (cropWidth < editedWidth / 3 || cropHeight < editedHeight / 3) {
// Safety guard: only skip if crop is unreasonably small (< 5% of area)
if (cropWidth < 10 || cropHeight < 10 ||
(cropWidth.toLong() * cropHeight.toLong()) < (editedWidth.toLong() * editedHeight.toLong()) / 20
) {
editedBitmap.recycle()
return@runCatching editedUri
}
@@ -1400,10 +1436,10 @@ private suspend fun removeLetterboxFromEditedImage(
val normalizedFile =
File(
context.cacheDir,
"edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.png"
"edited_normalized_${System.currentTimeMillis()}_${(0..9999).random()}.jpg"
)
normalizedFile.outputStream().use { out ->
cropped.compress(Bitmap.CompressFormat.PNG, 100, out)
cropped.compress(Bitmap.CompressFormat.JPEG, 95, out)
out.flush()
}
cropped.recycle()
@@ -1448,18 +1484,18 @@ private fun launchCrop(
launcher: androidx.activity.result.ActivityResultLauncher<Intent>
) {
try {
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.png")
val destinationFile = File(context.cacheDir, "cropped_${System.currentTimeMillis()}.jpg")
val destinationUri = Uri.fromFile(destinationFile)
val options = UCrop.Options().apply {
setCompressionFormat(Bitmap.CompressFormat.PNG)
setCompressionQuality(100)
setCompressionFormat(Bitmap.CompressFormat.JPEG)
setCompressionQuality(95)
// Dark theme
setToolbarColor(android.graphics.Color.BLACK)
setStatusBarColor(android.graphics.Color.BLACK)
setActiveControlsWidgetColor(android.graphics.Color.parseColor("#007AFF"))
setToolbarWidgetColor(android.graphics.Color.WHITE)
setRootViewBackgroundColor(android.graphics.Color.BLACK)
setRootViewBackgroundColor(android.graphics.Color.WHITE)
setFreeStyleCropEnabled(true)
setShowCropGrid(true)
setShowCropFrame(true)
@@ -1488,6 +1524,27 @@ fun MultiImageEditorScreen(
isDarkTheme: Boolean = true,
recipientName: String? = null // Имя получателя (как в Telegram)
) {
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(
dismissOnBackPress = false,
dismissOnClickOutside = false,
usePlatformDefaultWidth = false,
decorFitsSystemWindows = false
)
) {
val dialogView = LocalView.current
SideEffect {
val dialogWindow = (dialogView.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window
?: (dialogView.context as? Activity)?.window
dialogWindow?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
win.statusBarColor = android.graphics.Color.BLACK
win.navigationBarColor = android.graphics.Color.BLACK
win.setWindowAnimations(0)
}
}
val context = LocalContext.current
val scope = rememberCoroutineScope()
val view = LocalView.current
@@ -1540,7 +1597,7 @@ fun MultiImageEditorScreen(
SystemBarsStyleUtils.capture(window, view)
}
LaunchedEffect(window, view) {
SideEffect {
SystemBarsStyleUtils.applyFullscreenDark(window, view)
}
@@ -1572,6 +1629,7 @@ fun MultiImageEditorScreen(
val photoEditors = remember { mutableStateMapOf<Int, PhotoEditor?>() }
val photoEditorViews = remember { mutableStateMapOf<Int, PhotoEditorView?>() }
val drawingEditPages = remember { mutableSetOf<Int>() }
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
@@ -1605,11 +1663,11 @@ fun MultiImageEditorScreen(
BackHandler { animatedDismiss() }
// ⚡ Простой fade - только opacity
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.zIndex(100f)
) {
Box(
modifier =
@@ -1834,6 +1892,7 @@ fun MultiImageEditorScreen(
showColorPicker = !showColorPicker
} else {
currentTool = EditorTool.DRAW
drawingEditPages.add(pagerState.currentPage)
currentEditor?.setBrushDrawingMode(true)
currentEditor?.brushColor = selectedColor.toArgb()
currentEditor?.brushSize = brushSize
@@ -1875,7 +1934,7 @@ fun MultiImageEditorScreen(
val originalImage = imagesToSend[i]
if (editor != null) {
val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri)
val savedUri = saveEditedImageSync(context, editor, editorView, originalImage.uri, hasDrawingEdits = i in drawingEditPages)
if (savedUri != null) {
savedImages.add(originalImage.copy(uri = savedUri))
} else {
@@ -1906,6 +1965,7 @@ fun MultiImageEditorScreen(
}
}
}
} // Dialog
}
private fun loadBitmapRespectExif(context: Context, uri: Uri): Bitmap? {

View File

@@ -56,6 +56,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.ui.graphics.graphicsLayer
import com.rosetta.messenger.data.PreferencesManager
/**
* 📷 In-App Camera Screen - как в Telegram
@@ -73,11 +74,18 @@ fun InAppCameraScreen(
val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val preferencesManager = remember(context.applicationContext) {
PreferencesManager(context.applicationContext)
}
// Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }
var flashMode by remember { mutableStateOf(ImageCapture.FLASH_MODE_AUTO) }
var isCapturing by remember { mutableStateOf(false) }
LaunchedEffect(preferencesManager) {
flashMode = preferencesManager.getCameraFlashMode()
}
// Camera references
var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
@@ -231,27 +239,27 @@ fun InAppCameraScreen(
// PreviewView reference
var previewView by remember { mutableStateOf<PreviewView?>(null) }
// Bind camera when previewView, lensFacing or flashMode changes
LaunchedEffect(previewView, lensFacing, flashMode) {
// Bind camera when previewView or lensFacing changes (NOT flashMode — that causes flicker)
LaunchedEffect(previewView, lensFacing) {
val pv = previewView ?: return@LaunchedEffect
val provider = context.getCameraProvider()
cameraProvider = provider
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(pv.surfaceProvider)
}
val capture = ImageCapture.Builder()
.setFlashMode(flashMode)
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.build()
imageCapture = capture
val cameraSelector = CameraSelector.Builder()
.requireLensFacing(lensFacing)
.build()
try {
provider.unbindAll()
provider.bindToLifecycle(
@@ -263,6 +271,11 @@ fun InAppCameraScreen(
} catch (e: Exception) {
}
}
// Apply flash mode directly without rebinding camera (prevents flicker)
LaunchedEffect(flashMode) {
imageCapture?.flashMode = flashMode
}
// Unbind on dispose
DisposableEffect(Unit) {
@@ -318,11 +331,15 @@ fun InAppCameraScreen(
// Flash button
IconButton(
onClick = {
flashMode = when (flashMode) {
val nextFlashMode = when (flashMode) {
ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_AUTO
ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_ON
else -> ImageCapture.FLASH_MODE_OFF
}
flashMode = nextFlashMode
scope.launch {
preferencesManager.setCameraFlashMode(nextFlashMode)
}
},
modifier = Modifier
.size(44.dp)

View File

@@ -79,6 +79,7 @@ fun AvatarImage(
displayName: String? = null // 🔥 Имя для инициалов (title/username)
) {
val isSystemSafeAccount = publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
val isSystemUpdatesAccount = publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY
// Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
@@ -141,6 +142,13 @@ fun AvatarImage(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (isSystemUpdatesAccount) {
Image(
painter = painterResource(id = R.drawable.updates_account),
contentDescription = "Rosetta Updates avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (bitmap != null) {
// Отображаем реальный аватар
Image(

View File

@@ -42,61 +42,7 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true
) {
// Если выбран цвет в Appearance — рисуем блюр аватарки + полупрозрачный overlay поверх
// (одинаково для светлой и тёмной темы, чтобы цвет совпадал с превью в Appearance)
if (overlayColors != null && overlayColors.isNotEmpty()) {
// Загружаем блюр аватарки для подложки
val avatarsForOverlay by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
val avatarKeyForOverlay = remember(avatarsForOverlay) {
avatarsForOverlay.firstOrNull()?.timestamp ?: 0L
}
var blurredForOverlay by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(avatarKeyForOverlay) {
val currentAvatars = avatarsForOverlay
if (currentAvatars.isNotEmpty()) {
val original = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(currentAvatars.first().base64Data)
}
if (original != null) {
blurredForOverlay = withContext(Dispatchers.Default) {
val scaled = Bitmap.createScaledBitmap(original, original.width / 4, original.height / 4, true)
var result = scaled
repeat(2) { result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1)) }
result
}
}
} else {
blurredForOverlay = null
}
}
// Подложка: блюр аватарки или fallback цвет
Box(modifier = Modifier.matchParentSize()) {
if (blurredForOverlay != null) {
Image(
bitmap = blurredForOverlay!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize().graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop
)
}
}
// Overlay — полупрозрачный, как в Appearance preview
val overlayAlpha = if (blurredForOverlay != null) 0.55f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0].copy(alpha = overlayAlpha))
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors.map { it.copy(alpha = overlayAlpha) })
)
}
Box(modifier = overlayMod)
return
}
// Нет фона (avatar default) — blur аватарки
// Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима)
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
@@ -137,22 +83,68 @@ fun BoxScope.BlurredAvatarBackground(
}
}
Box(modifier = Modifier.matchParentSize()) {
// ═══════════════════════════════════════════════════════════
// Если выбран overlay-цвет — рисуем blur + пастельный overlay
// (повторяет логику ProfileBlurPreview из AppearanceScreen)
// ═══════════════════════════════════════════════════════════
if (overlayColors != null && overlayColors.isNotEmpty()) {
// LAYER 1: Blurred avatar underneath (если есть)
if (blurredBitmap != null) {
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop
)
} else {
// Нет фото — цвет аватарки
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
}
// LAYER 2: Цвет/градиент overlay с пониженной прозрачностью
// С blur-подложкой — 55%, без — 85% (как в AppearanceScreen preview)
val colorAlpha = if (blurredBitmap != null) 0.55f else 0.85f
val overlayMod = if (overlayColors.size == 1) {
Modifier.matchParentSize()
.background(overlayColors[0].copy(alpha = colorAlpha))
} else {
Modifier.matchParentSize()
.background(
Brush.linearGradient(
colors = overlayColors.map { it.copy(alpha = colorAlpha) }
)
)
}
Box(modifier = overlayMod)
return
}
// ═══════════════════════════════════════════════════════════
// Стандартный режим (нет overlay-цвета) — blur аватарки или fallback
// Повторяет логику AppearanceScreen → ProfileBlurPreview:
// blur 35% alpha + цвет-тинт 30% alpha
// ═══════════════════════════════════════════════════════════
if (blurredBitmap != null) {
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier.matchParentSize()
.graphicsLayer { this.alpha = 0.35f },
contentScale = ContentScale.Crop
)
// Тонкий цветовой тинт поверх blur (как в AppearanceScreen preview)
Box(
modifier = Modifier
.matchParentSize()
.background(fallbackColor.copy(alpha = 0.3f))
)
} else {
// Нет фото — fallback: серый в светлой, тёмный в тёмной (как в AppearanceScreen)
Box(
modifier = Modifier
.matchParentSize()
.background(
if (isDarkTheme) Color(0xFF2A2A2E) else Color(0xFFD8D8DC)
)
)
}
}
@@ -264,4 +256,3 @@ private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) {
sumB += (bottomPixel and 0xff) - (topPixel and 0xff)
}
}

View File

@@ -99,35 +99,42 @@ fun SwipeBackContainer(
val scrimAlpha = (1f - (currentOffset / screenWidthPx).coerceIn(0f, 1f)) * 0.4f
// Handle visibility changes
// 🔥 FIX: try/finally ensures animation flags are ALWAYS reset even if
// LaunchedEffect is cancelled by rapid isVisible changes (fast swipes).
// Without this, isAnimatingIn/isAnimatingOut can stay true forever,
// leaving an invisible overlay that blocks all touches on the chat list.
LaunchedEffect(isVisible) {
if (isVisible && !shouldShow) {
// Animate in: fade-in
shouldShow = true
isAnimatingIn = true
offsetAnimatable.snapTo(0f) // No slide for entry
alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
isAnimatingIn = false
} else if (!isVisible && shouldShow && !isAnimatingOut) {
try {
offsetAnimatable.snapTo(0f) // No slide for entry
alphaAnimatable.snapTo(0f)
alphaAnimatable.animateTo(
targetValue = 1f,
animationSpec =
tween(
durationMillis = ANIMATION_DURATION_ENTER,
easing = FastOutSlowInEasing
)
)
} finally {
isAnimatingIn = false
}
} else if (!isVisible && shouldShow) {
// Animate out: fade-out (when triggered by button, not swipe)
isAnimatingOut = true
alphaAnimatable.snapTo(1f)
alphaAnimatable.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
)
shouldShow = false
isAnimatingOut = false
offsetAnimatable.snapTo(0f)
alphaAnimatable.snapTo(0f)
dragOffset = 0f
try {
alphaAnimatable.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
)
} finally {
shouldShow = false
isAnimatingOut = false
dragOffset = 0f
}
}
}
@@ -286,6 +293,13 @@ fun SwipeBackContainer(
TelegramEasing
)
)
// 🔥 FIX: Reset state BEFORE onBack() to prevent
// redundant fade-out animation in LaunchedEffect.
// Without this, onBack() changes isVisible→false,
// triggering a 200ms fade-out during which an invisible
// overlay blocks all touches on the chat list.
shouldShow = false
dragOffset = 0f
onBack()
} else {
offsetAnimatable.animateTo(
@@ -298,9 +312,8 @@ fun SwipeBackContainer(
TelegramEasing
)
)
dragOffset = 0f
}
dragOffset = 0f
}
}
}

View File

@@ -28,18 +28,21 @@ fun VerifiedBadge(
verified: Int,
size: Int = 16,
modifier: Modifier = Modifier,
isDarkTheme: Boolean = isSystemInDarkTheme()
isDarkTheme: Boolean = isSystemInDarkTheme(),
badgeTint: Color? = null
) {
if (verified <= 0) return
var showDialog by remember { mutableStateOf(false) }
// Цвет в зависимости от уровня верификации
val badgeColor = when (verified) {
1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram)
2 -> Color(0xFFFFD700) // Золотая верификация
else -> Color(0xFF4CAF50) // Зеленая для других уровней
}
val badgeColor =
badgeTint
?: when (verified) {
1 -> Color(0xFF1DA1F2) // Стандартная верификация (синий как в Twitter/Telegram)
2 -> Color(0xFFFFD700) // Золотая верификация
else -> Color(0xFF4CAF50) // Зеленая для других уровней
}
// Текст аннотации
val annotationText = when (verified) {

View File

@@ -1,10 +1,17 @@
package com.rosetta.messenger.ui.settings
import android.graphics.Bitmap
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
import android.view.View
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -24,23 +31,34 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
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.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import compose.icons.tablericons.Sun
import compose.icons.tablericons.Moon
import kotlin.math.hypot
import kotlin.math.max
/**
* Экран кастомизации внешнего вида.
@@ -74,9 +92,85 @@ fun AppearanceScreen(
var selectedId by remember { mutableStateOf(currentBlurColorId) }
// ── Circular reveal state ──
val scope = rememberCoroutineScope()
var screenshotBitmap by remember { mutableStateOf<Bitmap?>(null) }
val revealProgress = remember { Animatable(1f) } // 1 = fully revealed (no overlay)
var revealCenter by remember { mutableStateOf(Offset.Zero) }
var screenSize by remember { mutableStateOf(IntSize.Zero) }
// Helper: capture screenshot of the current view, then animate circular reveal
fun triggerCircularReveal(center: Offset, applyChange: () -> Unit) {
// Don't start a new animation while one is running
if (screenshotBitmap != null) return
val rootView = view.rootView
val width = rootView.width
val height = rootView.height
if (width <= 0 || height <= 0) {
applyChange()
return
}
val bmp = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
fun onScreenshotReady() {
screenshotBitmap = bmp
revealCenter = center
scope.launch {
revealProgress.snapTo(0f)
applyChange()
revealProgress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 380)
)
screenshotBitmap = null
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val window = (view.context as? android.app.Activity)?.window
if (window != null) {
PixelCopy.request(
window,
bmp,
{ result ->
if (result == PixelCopy.SUCCESS) {
onScreenshotReady()
} else {
applyChange()
}
},
Handler(Looper.getMainLooper())
)
return
}
}
// Fallback for older APIs
@Suppress("DEPRECATION")
rootView.isDrawingCacheEnabled = true
@Suppress("DEPRECATION")
rootView.buildDrawingCache()
@Suppress("DEPRECATION")
val cache = rootView.drawingCache
if (cache != null) {
val canvas = android.graphics.Canvas(bmp)
canvas.drawBitmap(cache, 0f, 0f, null)
@Suppress("DEPRECATION")
rootView.destroyDrawingCache()
onScreenshotReady()
} else {
applyChange()
}
}
BackHandler { onBack() }
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { screenSize = it.size }
) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -119,7 +213,18 @@ fun AppearanceScreen(
tint = Color.White
)
}
IconButton(onClick = { onToggleTheme() }) {
var themeButtonCenter by remember { mutableStateOf(Offset.Zero) }
IconButton(
onClick = {
triggerCircularReveal(themeButtonCenter) {
onToggleTheme()
}
},
modifier = Modifier.onGloballyPositioned { coords ->
val bounds = coords.boundsInRoot()
themeButtonCenter = bounds.center
}
) {
Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon,
contentDescription = "Toggle theme",
@@ -149,9 +254,13 @@ fun AppearanceScreen(
ColorSelectionGrid(
selectedId = selectedId,
isDarkTheme = isDarkTheme,
onSelect = { id ->
selectedId = id
onBlurColorChange(id)
onSelect = { id, centerInRoot ->
if (id != selectedId) {
triggerCircularReveal(centerInRoot) {
selectedId = id
onBlurColorChange(id)
}
}
}
)
@@ -168,6 +277,37 @@ fun AppearanceScreen(
Spacer(modifier = Modifier.height(32.dp))
}
}
// ── Circular reveal overlay ──
// Screenshot of old state is drawn with a circular hole that grows,
// revealing the new state underneath.
val bmp = screenshotBitmap
if (bmp != null) {
val progress = revealProgress.value
val maxRadius = hypot(
max(revealCenter.x, screenSize.width - revealCenter.x),
max(revealCenter.y, screenSize.height - revealCenter.y)
)
val currentRadius = maxRadius * progress
Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { compositingStrategy = androidx.compose.ui.graphics.CompositingStrategy.Offscreen }
) {
// Draw the old screenshot
drawImage(
image = bmp.asImageBitmap()
)
// Cut a circular hole — reveals new content underneath
drawCircle(
color = Color.Black,
radius = currentRadius,
center = revealCenter,
blendMode = BlendMode.DstOut
)
}
}
}
}
@@ -413,7 +553,7 @@ private fun ProfileBlurPreview(
private fun ColorSelectionGrid(
selectedId: String,
isDarkTheme: Boolean,
onSelect: (String) -> Unit
onSelect: (String, Offset) -> Unit
) {
val allOptions = BackgroundBlurPresets.allWithDefault
val horizontalPadding = 12.dp
@@ -460,7 +600,7 @@ private fun ColorSelectionGrid(
isSelected = option.id == selectedId,
isDarkTheme = isDarkTheme,
circleSize = circleSize,
onClick = { onSelect(option.id) }
onSelectWithPosition = { centerInRoot -> onSelect(option.id, centerInRoot) }
)
}
repeat(columns - rowItems.size) {
@@ -478,7 +618,7 @@ private fun ColorCircleItem(
isSelected: Boolean,
isDarkTheme: Boolean,
circleSize: Dp,
onClick: () -> Unit
onSelectWithPosition: (Offset) -> Unit
) {
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.08f else 1.0f,
@@ -496,17 +636,26 @@ private fun ColorCircleItem(
label = "border"
)
// Track center position in root coordinates for circular reveal
var boundsInRoot by remember { mutableStateOf(Rect.Zero) }
Box(
modifier = Modifier
.size(circleSize)
.scale(scale)
.onGloballyPositioned { coords ->
boundsInRoot = coords.boundsInRoot()
}
.clip(CircleShape)
.border(
width = if (isSelected) 2.5.dp else 0.5.dp,
color = if (isSelected) borderColor else if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.12f),
shape = CircleShape
)
.clickable(onClick = onClick),
.clickable {
val center = boundsInRoot.center
onSelectWithPosition(center)
},
contentAlignment = Alignment.Center
) {
when {

View File

@@ -201,6 +201,9 @@ fun OtherProfileScreen(
val isSafetyProfile = remember(user.publicKey) {
user.publicKey == MessageRepository.SYSTEM_SAFE_PUBLIC_KEY
}
val isSystemAccount = remember(user.publicKey) {
MessageRepository.isSystemAccount(user.publicKey)
}
val context = LocalContext.current
val view = LocalView.current
val window = remember { (view.context as? Activity)?.window }
@@ -590,7 +593,7 @@ fun OtherProfileScreen(
)
}
if (!isSafetyProfile) {
if (!isSafetyProfile && !isSystemAccount) {
// Call
Button(
onClick = { /* TODO: call action */ },
@@ -791,6 +794,7 @@ fun OtherProfileScreen(
showAvatarMenu = showAvatarMenu,
onAvatarMenuChange = { showAvatarMenu = it },
isBlocked = isBlocked,
isSystemAccount = isSystemAccount,
onBlockToggle = {
coroutineScope.launch {
if (isBlocked) {
@@ -1727,7 +1731,7 @@ private fun OtherProfileEmptyState(
@Composable
private fun CollapsingOtherProfileHeader(
name: String,
@Suppress("UNUSED_PARAMETER") username: String,
username: String,
publicKey: String,
verified: Int,
isOnline: Boolean,
@@ -1740,6 +1744,7 @@ private fun CollapsingOtherProfileHeader(
showAvatarMenu: Boolean,
onAvatarMenuChange: (Boolean) -> Unit,
isBlocked: Boolean,
isSystemAccount: Boolean = false,
onBlockToggle: () -> Unit,
avatarRepository: AvatarRepository? = null,
onClearChat: () -> Unit,
@@ -1769,6 +1774,10 @@ private fun CollapsingOtherProfileHeader(
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val rosettaBadgeBlue = Color(0xFF1DA1F2)
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
// ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
@@ -1878,6 +1887,13 @@ private fun CollapsingOtherProfileHeader(
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (publicKey == MessageRepository.SYSTEM_UPDATES_PUBLIC_KEY) {
Image(
painter = painterResource(id = R.drawable.updates_account),
contentDescription = "Rosetta Updates avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else if (hasAvatar && avatarRepository != null) {
OtherProfileFullSizeAvatar(
publicKey = publicKey,
@@ -1975,6 +1991,7 @@ private fun CollapsingOtherProfileHeader(
onDismiss = { onAvatarMenuChange(false) },
isDarkTheme = isDarkTheme,
isBlocked = isBlocked,
isSystemAccount = isSystemAccount,
onBlockClick = {
onAvatarMenuChange(false)
onBlockToggle()
@@ -2010,9 +2027,14 @@ private fun CollapsingOtherProfileHeader(
textAlign = TextAlign.Center
)
if (verified > 0) {
if (verified > 0 || isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(verified = verified, size = (nameFontSize.value * 0.8f).toInt(), isDarkTheme = isDarkTheme)
VerifiedBadge(
verified = if (verified > 0) verified else 1,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme,
badgeTint = if (isRosettaOfficial) rosettaBadgeBlue else null
)
}
}

View File

@@ -61,6 +61,7 @@ import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.BlurredAvatarBackground
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
@@ -1067,7 +1068,7 @@ fun ProfileScreen(
@Composable
private fun CollapsingProfileHeader(
name: String,
@Suppress("UNUSED_PARAMETER") username: String,
username: String,
publicKey: String,
avatarColors: AvatarColors,
collapseProgress: Float,
@@ -1128,11 +1129,17 @@ private fun CollapsingProfileHeader(
// Font sizes
val nameFontSize = androidx.compose.ui.unit.lerp(24.sp, 18.sp, collapseProgress)
val onlineFontSize = androidx.compose.ui.unit.lerp(14.sp, 13.sp, collapseProgress)
val rosettaBadgeBlue = Color(0xFF1DA1F2)
val isRosettaOfficial =
name.equals("Rosetta", ignoreCase = true) ||
username.equals("rosetta", ignoreCase = true)
Box(modifier = Modifier.fillMaxWidth().height(headerHeight).clipToBounds()) {
// Expansion fraction — computed early so gradient can fade during expansion
val expandFraction = expansionProgress.coerceIn(0f, 1f)
val headerBaseColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4)
// Neutral screen bg — used under blur/overlay so blue doesn't bleed through
val screenBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
// ═══════════════════════════════════════════════════════════
// 🎨 BLURRED AVATAR BACKGROUND — ВСЕГДА видим
@@ -1140,15 +1147,16 @@ private fun CollapsingProfileHeader(
// и естественно перекрывает его. Без мерцания.
// ═══════════════════════════════════════════════════════════
Box(modifier = Modifier.matchParentSize()) {
Box(modifier = Modifier.matchParentSize().background(headerBaseColor))
if (backgroundBlurColorId == "none") {
// None — стандартный цвет шапки без blur
Box(
modifier = Modifier
.fillMaxSize()
.matchParentSize()
.background(headerBaseColor)
)
} else {
// Neutral base so transparent blur layers don't pick up blue tint
Box(modifier = Modifier.matchParentSize().background(screenBgColor))
BlurredAvatarBackground(
publicKey = publicKey,
avatarRepository = avatarRepository,
@@ -1375,16 +1383,30 @@ private fun CollapsingProfileHeader(
Modifier.align(Alignment.TopCenter).offset(y = textY),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = name,
fontSize = nameFontSize,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = name,
fontSize = nameFontSize,
fontWeight = FontWeight.SemiBold,
color = textColor,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 220.dp),
textAlign = TextAlign.Center
)
if (isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(
verified = 2,
size = (nameFontSize.value * 0.8f).toInt(),
isDarkTheme = isDarkTheme,
badgeTint = rosettaBadgeBlue
)
}
}
Spacer(modifier = Modifier.height(2.dp))

View File

@@ -1,11 +1,14 @@
package com.rosetta.messenger.ui.settings
import androidx.activity.compose.BackHandler
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
@@ -20,13 +23,42 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.drawToBitmap
import com.rosetta.messenger.ui.components.AppleEmojiText
import kotlin.math.hypot
import kotlinx.coroutines.launch
private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
if (bounds.width <= 0 || bounds.height <= 0) return 0f
val width = bounds.width.toFloat()
val height = bounds.height.toFloat()
return maxOf(
hypot(center.x, center.y),
hypot(width - center.x, center.y),
hypot(center.x, height - center.y),
hypot(width - center.x, height - center.y)
)
}
@Composable
fun ThemeScreen(
@@ -49,123 +81,251 @@ fun ThemeScreen(
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val scope = rememberCoroutineScope()
val systemIsDark = isSystemInDarkTheme()
// Theme mode: "light", "dark", "auto"
var themeMode by remember { mutableStateOf(currentThemeMode) }
val themeRevealRadius = remember { Animatable(0f) }
var rootSize by remember { mutableStateOf(IntSize.Zero) }
var themeRevealActive by remember { mutableStateOf(false) }
var themeRevealToDark by remember { mutableStateOf(false) }
var themeRevealCenter by remember { mutableStateOf(Offset.Zero) }
var themeRevealSnapshot by remember { mutableStateOf<ImageBitmap?>(null) }
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
LaunchedEffect(currentThemeMode) {
themeMode = currentThemeMode
}
fun resolveThemeIsDark(mode: String): Boolean =
when (mode) {
"dark" -> true
"light" -> false
else -> systemIsDark
}
fun startThemeReveal(targetMode: String, centerHint: Offset?) {
if (themeRevealActive || themeMode == targetMode) return
val targetIsDark = resolveThemeIsDark(targetMode)
if (targetIsDark == isDarkTheme || rootSize.width <= 0 || rootSize.height <= 0) {
themeMode = targetMode
onThemeModeChange(targetMode)
return
}
val center =
centerHint ?: Offset(rootSize.width * 0.85f, rootSize.height * 0.18f)
val snapshotBitmap = runCatching { view.drawToBitmap() }.getOrNull()
if (snapshotBitmap == null) {
themeMode = targetMode
onThemeModeChange(targetMode)
return
}
val maxRadius = maxRevealRadius(center, rootSize)
if (maxRadius <= 0f) {
themeMode = targetMode
onThemeModeChange(targetMode)
return
}
themeRevealActive = true
themeRevealToDark = targetIsDark
themeRevealCenter = center
themeRevealSnapshot = snapshotBitmap.asImageBitmap()
scope.launch {
try {
if (targetIsDark) {
themeRevealRadius.snapTo(0f)
} else {
themeRevealRadius.snapTo(maxRadius)
}
themeMode = targetMode
onThemeModeChange(targetMode)
withFrameNanos { }
themeRevealRadius.animateTo(
targetValue = if (targetIsDark) maxRadius else 0f,
animationSpec =
tween(
durationMillis = 400,
easing = CubicBezierEasing(0.45f, 0.05f, 0.55f, 0.95f)
)
)
} finally {
themeRevealSnapshot = null
themeRevealActive = false
}
}
}
// Handle back gesture
BackHandler { onBack() }
Column(
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { rootSize = it }
.background(backgroundColor)
) {
// Top Bar
Surface(
modifier = Modifier.fillMaxWidth(),
color = backgroundColor
Column(
modifier = Modifier.fillMaxSize()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
// Top Bar
Surface(
modifier = Modifier.fillMaxWidth(),
color = backgroundColor
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor
Row(
modifier = Modifier
.fillMaxWidth()
.padding(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding())
.padding(horizontal = 4.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor
)
}
Text(
text = "Theme",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
modifier = Modifier.padding(start = 8.dp)
)
}
Text(
text = "Theme",
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold,
color = textColor,
modifier = Modifier.padding(start = 8.dp)
}
// Content
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// ═══════════════════════════════════════════════════════
// CHAT PREVIEW - Message bubbles like in real chat
// ═══════════════════════════════════════════════════════
ChatPreview(isDarkTheme = isDarkTheme)
Spacer(modifier = Modifier.height(24.dp))
// ═══════════════════════════════════════════════════════
// MODE SELECTOR - Telegram style
// ═══════════════════════════════════════════════════════
TelegramSectionHeader("Appearance", secondaryTextColor)
Column {
TelegramThemeOption(
icon = TablerIcons.Sun,
title = "Light",
isSelected = themeMode == "light",
onClick = { startThemeReveal("light", lightOptionCenter) },
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme,
onCenterInRootChanged = { lightOptionCenter = it }
)
TelegramThemeOption(
icon = TablerIcons.Moon,
title = "Dark",
isSelected = themeMode == "dark",
onClick = { startThemeReveal("dark", darkOptionCenter) },
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme,
onCenterInRootChanged = { darkOptionCenter = it }
)
TelegramThemeOption(
icon = TablerIcons.DeviceMobile,
title = "System",
isSelected = themeMode == "auto",
onClick = { startThemeReveal("auto", systemOptionCenter) },
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = false,
isDarkTheme = isDarkTheme,
onCenterInRootChanged = { systemOptionCenter = it }
)
}
TelegramInfoText(
text = "System mode automatically switches between light and dark themes based on your device settings.",
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(32.dp))
}
}
// Content
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(8.dp))
// ═══════════════════════════════════════════════════════
// CHAT PREVIEW - Message bubbles like in real chat
// ═══════════════════════════════════════════════════════
ChatPreview(isDarkTheme = isDarkTheme)
Spacer(modifier = Modifier.height(24.dp))
// ═══════════════════════════════════════════════════════
// MODE SELECTOR - Telegram style
// ═══════════════════════════════════════════════════════
TelegramSectionHeader("Appearance", secondaryTextColor)
Column {
TelegramThemeOption(
icon = TablerIcons.Sun,
title = "Light",
isSelected = themeMode == "light",
onClick = {
if (themeMode != "light") {
themeMode = "light"
onThemeModeChange("light")
if (themeRevealActive) {
val snapshot = themeRevealSnapshot
if (snapshot != null) {
Canvas(
modifier =
Modifier.fillMaxSize()
.graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen
)
) {
val destinationSize =
IntSize(
width = size.width.toInt(),
height = size.height.toInt()
)
if (themeRevealToDark) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
drawCircle(
color = Color.Transparent,
radius = themeRevealRadius.value,
center = themeRevealCenter,
blendMode = BlendMode.Clear
)
} else {
val radius = themeRevealRadius.value
if (radius > 0f) {
val clipCirclePath =
Path().apply {
addOval(
Rect(
left = themeRevealCenter.x - radius,
top = themeRevealCenter.y - radius,
right = themeRevealCenter.x + radius,
bottom = themeRevealCenter.y + radius
)
)
}
clipPath(clipCirclePath) {
drawImage(
image = snapshot,
srcOffset = IntOffset.Zero,
srcSize = IntSize(snapshot.width, snapshot.height),
dstOffset = IntOffset.Zero,
dstSize = destinationSize
)
}
}
},
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme
)
TelegramThemeOption(
icon = TablerIcons.Moon,
title = "Dark",
isSelected = themeMode == "dark",
onClick = {
if (themeMode != "dark") {
themeMode = "dark"
onThemeModeChange("dark")
}
},
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = true,
isDarkTheme = isDarkTheme
)
TelegramThemeOption(
icon = TablerIcons.DeviceMobile,
title = "System",
isSelected = themeMode == "auto",
onClick = {
if (themeMode != "auto") {
themeMode = "auto"
onThemeModeChange("auto")
}
},
textColor = textColor,
secondaryTextColor = secondaryTextColor,
showDivider = false,
isDarkTheme = isDarkTheme
)
}
}
}
TelegramInfoText(
text = "System mode automatically switches between light and dark themes based on your device settings.",
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(32.dp))
}
}
}
@@ -190,7 +350,8 @@ private fun TelegramThemeOption(
textColor: Color,
secondaryTextColor: Color,
showDivider: Boolean,
isDarkTheme: Boolean
isDarkTheme: Boolean,
onCenterInRootChanged: ((Offset) -> Unit)? = null
) {
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
@@ -199,6 +360,15 @@ private fun TelegramThemeOption(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.onGloballyPositioned { coords ->
val pos = coords.positionInRoot()
onCenterInRootChanged?.invoke(
Offset(
x = pos.x + coords.size.width / 2f,
y = pos.y + coords.size.height / 2f
)
)
}
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -262,7 +432,9 @@ private fun ChatPreview(isDarkTheme: Boolean) {
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val myTextColor = Color.White // White text on blue bubble
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
val timeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
val otherTimeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val myCheckColor = Color.White
Surface(
modifier = Modifier
@@ -289,7 +461,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = timeColor
timeColor = otherTimeColor
)
}
@@ -304,7 +476,8 @@ private fun ChatPreview(isDarkTheme: Boolean) {
isMe = true,
bubbleColor = myBubbleColor,
textColor = myTextColor,
timeColor = timeColor
timeColor = myTimeColor,
checkmarkColor = myCheckColor
)
}
@@ -319,7 +492,7 @@ private fun ChatPreview(isDarkTheme: Boolean) {
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = timeColor
timeColor = otherTimeColor
)
}
}
@@ -333,7 +506,8 @@ private fun MessageBubble(
isMe: Boolean,
bubbleColor: Color,
textColor: Color,
timeColor: Color
timeColor: Color,
checkmarkColor: Color = Color(0xFF4FC3F7)
) {
Surface(
color = bubbleColor,
@@ -372,7 +546,7 @@ private fun MessageBubble(
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color(0xFF4FC3F7), // Blue checkmarks for read messages
tint = checkmarkColor,
modifier = Modifier.size(14.dp)
)
}