Промежуточный результат для 1.0.4 версии
This commit is contained in:
@@ -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)) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
// Кэшируем текст
|
||||
|
||||
@@ -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)) {
|
||||
// Заголовок
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user