feat: simplify color handling in ChatsListScreen and improve gesture callbacks in SwipeableDialogItem

This commit is contained in:
2026-02-12 20:09:53 +05:00
parent ea537ccce1
commit e208ce050a
16 changed files with 419 additions and 365 deletions

View File

@@ -14,6 +14,7 @@
<application
android:name=".RosettaApplication"
android:allowBackup="true"
android:enableOnBackInvokedCallback="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"

View File

@@ -331,6 +331,56 @@ class MainActivity : FragmentActivity() {
accountManager.logout()
}
},
onDeleteAccount = {
val publicKey = currentAccount?.publicKey ?: return@MainScreen
scope.launch {
try {
val database = RosettaDatabase.getDatabase(this@MainActivity)
// 1. Delete all messages
database.messageDao().deleteAllByAccount(publicKey)
// 2. Delete all dialogs
database.dialogDao().deleteAllByAccount(publicKey)
// 3. Delete blacklist
database.blacklistDao().deleteAllByAccount(publicKey)
// 4. Delete avatars from DB
database.avatarDao().deleteAvatars(publicKey)
// 5. Delete account from Room DB
database.accountDao().deleteAccount(publicKey)
// 6. Disconnect protocol
com.rosetta.messenger.network.ProtocolManager.disconnect()
// 7. Delete account from AccountManager DataStore (removes from accounts list + clears login)
accountManager.deleteAccount(publicKey)
// 8. Refresh accounts list
val accounts = accountManager.getAllAccounts()
accountInfoList = accounts.map { acc ->
val shortKey = acc.publicKey.take(7)
val displayName = acc.name ?: shortKey
val initials = displayName.trim()
.split(Regex("\\s+"))
.filter { it.isNotEmpty() }
.let { words ->
when {
words.isEmpty() -> "??"
words.size == 1 -> words[0].take(2).uppercase()
else -> "${words[0].first()}${words[1].first()}".uppercase()
}
}
AccountInfo(
id = acc.publicKey,
name = displayName,
username = acc.username ?: "",
initials = initials,
publicKey = acc.publicKey
)
}
hasExistingAccount = accounts.isNotEmpty()
// 8. Navigate away last
currentAccount = null
} catch (e: Exception) {
android.util.Log.e("DeleteAccount", "Failed to delete account", e)
}
}
},
onAccountInfoUpdated = {
// Reload account list when profile is updated
val accounts = accountManager.getAllAccounts()
@@ -509,6 +559,7 @@ fun MainScreen(
onToggleTheme: () -> Unit = {},
onThemeModeChange: (String) -> Unit = {},
onLogout: () -> Unit = {},
onDeleteAccount: () -> Unit = {},
onAccountInfoUpdated: suspend () -> Unit = {}
) {
// Reactive state for account name and username
@@ -758,7 +809,6 @@ fun MainScreen(
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) },
onNavigateToLogs = { pushScreen(Screen.Logs) },
onNavigateToCrashLogs = { pushScreen(Screen.CrashLogs) },
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
viewModel = profileViewModel,
avatarRepository = avatarRepository,
@@ -770,13 +820,13 @@ fun MainScreen(
// Other screens with swipe back
SwipeBackContainer(
isVisible = isBackupVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Backup } + Screen.Safety },
onBack = { navStack = navStack.filterNot { it is Screen.Backup } },
isDarkTheme = isDarkTheme
) {
BackupScreen(
isDarkTheme = isDarkTheme,
onBack = {
navStack = navStack.filterNot { it is Screen.Backup } + Screen.Safety
navStack = navStack.filterNot { it is Screen.Backup }
},
onVerifyPassword = { password ->
// Verify password by trying to decrypt the private key
@@ -824,11 +874,9 @@ fun MainScreen(
accountPrivateKey = accountPrivateKey,
onBack = { navStack = navStack.filterNot { it is Screen.Safety } },
onBackupClick = {
navStack = navStack.filterNot { it is Screen.Safety } + Screen.Backup
navStack = navStack + Screen.Backup
},
onDeleteAccount = {
// TODO: Implement account deletion
}
onDeleteAccount = onDeleteAccount
)
}
@@ -893,8 +941,13 @@ fun MainScreen(
currentUserName = accountName,
onBack = { popChatAndChildren() },
onUserProfileClick = { user ->
// Открываем профиль другого пользователя
pushScreen(Screen.OtherProfile(user))
if (user.publicKey == accountPublicKey) {
// Свой профиль — открываем My Profile
pushScreen(Screen.Profile)
} else {
// Открываем профиль другого пользователя
pushScreen(Screen.OtherProfile(user))
}
},
onNavigateToChat = { forwardUser ->
// 📨 Forward: переход в выбранный чат с полными данными
@@ -926,6 +979,9 @@ fun MainScreen(
navStack =
navStack.filterNot { it is Screen.Search } +
Screen.ChatDetail(selectedSearchUser)
},
onNavigateToCrashLogs = {
navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs
}
)
}

View File

@@ -118,6 +118,32 @@ class AccountManager(private val context: Context) {
saveAccount(updatedAccount)
}
/**
* Delete account completely - remove from accounts list and clear login state
*/
suspend fun deleteAccount(publicKey: String) {
context.accountDataStore.edit { preferences ->
// Remove from accounts list
val existingJson = preferences[ACCOUNTS_JSON]
if (existingJson != null) {
val accounts = parseAccounts(existingJson).toMutableList()
accounts.removeAll { it.publicKey == publicKey }
preferences[ACCOUNTS_JSON] = serializeAccounts(accounts)
}
// Clear current login if this was the active account
val currentKey = preferences[CURRENT_PUBLIC_KEY]
if (currentKey == publicKey) {
preferences[IS_LOGGED_IN] = false
preferences.remove(CURRENT_PUBLIC_KEY)
}
}
// Clear SharedPreferences if this was the last logged account
val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null)
if (lastLogged == publicKey) {
sharedPrefs.edit().remove(KEY_LAST_LOGGED).commit()
}
}
suspend fun clearAll() {
context.accountDataStore.edit { it.clear() }
}

View File

@@ -64,4 +64,8 @@ interface BlacklistDao {
*/
@Query("SELECT public_key FROM blacklist WHERE account = :account")
suspend fun getBlockedPublicKeys(account: String): List<String>
/** Удалить все записи blacklist для аккаунта */
@Query("DELETE FROM blacklist WHERE account = :account")
suspend fun deleteAllByAccount(account: String)
}

View File

@@ -277,6 +277,10 @@ interface MessageDao {
)
suspend fun deleteMessagesBetweenUsers(account: String, user1: String, user2: String): Int
/** Удалить все сообщения аккаунта */
@Query("DELETE FROM messages WHERE account = :account")
suspend fun deleteAllByAccount(account: String): Int
/** Количество непрочитанных сообщений в диалоге */
@Query(
"""
@@ -492,6 +496,10 @@ interface DialogDao {
@Query("DELETE FROM dialogs WHERE account = :account AND opponent_key = :opponentKey")
suspend fun deleteDialog(account: String, opponentKey: String)
/** Удалить все диалоги аккаунта */
@Query("DELETE FROM dialogs WHERE account = :account")
suspend fun deleteAllByAccount(account: String)
/** Обновить информацию о собеседнике */
@Query(
"""

View File

@@ -2267,17 +2267,7 @@ fun ChatDetailScreen(
)
}
// 🐛 Debug Logs BottomSheet
if (showDebugLogs) {
DebugLogsBottomSheet(
logs = debugLogs,
isDarkTheme = isDarkTheme,
onDismiss = { showDebugLogs = false },
onClearLogs = { ProtocolManager.clearLogs() }
)
}
// 📨 Forward Chat Picker BottomSheet
// Forward Chat Picker BottomSheet
if (showForwardPicker) {
ForwardChatPickerBottomSheet(
dialogs = dialogsList,

View File

@@ -46,6 +46,7 @@ import com.rosetta.messenger.ui.components.AvatarImage
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
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -237,9 +238,6 @@ fun ChatsListScreen(
val textColor = remember(isDarkTheme) { if (isDarkTheme) Color.White else Color.Black }
val secondaryTextColor =
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) }
val drawerGrabZonePx = with(androidx.compose.ui.platform.LocalDensity.current) { 88.dp.toPx() }
val drawerOpenDistancePx = with(androidx.compose.ui.platform.LocalDensity.current) { 20.dp.toPx() }
val drawerOpenVelocityThresholdPx = with(androidx.compose.ui.platform.LocalDensity.current) { 110.dp.toPx() }
// Protocol connection state
val protocolState by ProtocolManager.state.collectAsState()
@@ -277,7 +275,7 @@ fun ChatsListScreen(
var visible by rememberSaveable { mutableStateOf(true) }
// Confirmation dialogs state
var dialogToDelete by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogsToDelete by remember { mutableStateOf<List<DialogUiModel>>(emptyList()) }
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
@@ -290,12 +288,16 @@ fun ChatsListScreen(
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
.collectAsState(initial = emptySet())
// Перехватываем системный back gesture - не закрываем приложение
val activity = context as? android.app.Activity
// Всегда перехватываем back чтобы predictive back анимация не ломала UI
BackHandler(enabled = true) {
if (isSelectionMode) {
selectedChatKeys = emptySet()
} else if (drawerState.isOpen) {
scope.launch { drawerState.close() }
} else {
activity?.moveTaskToBack(true)
}
}
@@ -447,96 +449,10 @@ fun ChatsListScreen(
Modifier.fillMaxSize()
.background(backgroundColor)
.navigationBarsPadding()
.pointerInput(drawerState.isOpen, showRequestsScreen) {
if (showRequestsScreen) return@pointerInput
val velocityTracker = VelocityTracker()
val relaxedTouchSlop = viewConfiguration.touchSlop * 0.8f
awaitEachGesture {
val down =
awaitFirstDown(requireUnconsumed = false)
if (drawerState.isOpen || down.position.x > drawerGrabZonePx) {
return@awaitEachGesture
}
velocityTracker.resetTracking()
velocityTracker.addPosition(
down.uptimeMillis,
down.position
)
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
while (true) {
val event = awaitPointerEvent()
val change =
event.changes.firstOrNull {
it.id == down.id
}
?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(
change.uptimeMillis,
change.position
)
if (!claimed) {
val distance =
kotlin.math.sqrt(
totalDragX *
totalDragX +
totalDragY *
totalDragY
)
if (distance < relaxedTouchSlop)
continue
val horizontalDominance =
kotlin.math.abs(
totalDragX
) >
kotlin.math.abs(
totalDragY
) * 1.15f
if (
totalDragX > 0 &&
horizontalDominance
) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
val velocityX = velocityTracker.calculateVelocity().x
val shouldOpenDrawer =
claimed &&
(totalDragX >=
drawerOpenDistancePx ||
velocityX >
drawerOpenVelocityThresholdPx)
if (shouldOpenDrawer && drawerState.isClosed) {
scope.launch { drawerState.open() }
}
}
}
) {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !showRequestsScreen, // Disable drawer swipe when requests are open
gesturesEnabled = !showRequestsScreen,
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = Color.Transparent,
@@ -571,7 +487,8 @@ fun ChatsListScreen(
BackgroundBlurPresets
.getOverlayColors(
backgroundBlurColorId
)
),
isDarkTheme = isDarkTheme
)
// Content поверх фона
@@ -634,11 +551,7 @@ fun ChatsListScreen(
contentDescription =
if (isDarkTheme) "Light Mode"
else "Dark Mode",
tint =
if (isDarkTheme)
Color.White.copy(alpha = 0.8f)
else
Color.Black.copy(alpha = 0.7f),
tint = Color.White,
modifier = Modifier.size(22.dp)
)
}
@@ -646,7 +559,7 @@ fun ChatsListScreen(
Spacer(
modifier =
Modifier.height(14.dp)
Modifier.height(8.dp)
)
// Display name
@@ -656,11 +569,7 @@ fun ChatsListScreen(
fontSize = 16.sp,
fontWeight =
FontWeight.SemiBold,
color =
if (isDarkTheme)
Color.White
else
Color.Black
color = Color.White
)
}
@@ -674,13 +583,7 @@ fun ChatsListScreen(
text =
"@$accountUsername",
fontSize = 13.sp,
color =
if (isDarkTheme)
Color.White
.copy(alpha = 0.7f)
else
Color.Black
.copy(alpha = 0.7f)
color = Color.White
)
}
}
@@ -699,7 +602,10 @@ fun ChatsListScreen(
.padding(vertical = 8.dp)
) {
val menuIconColor =
textColor.copy(alpha = 0.6f)
if (isDarkTheme) Color(0xFF7A7F85)
else textColor.copy(alpha = 0.6f)
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
// 👤 Profile
DrawerMenuItemEnhanced(
@@ -826,6 +732,7 @@ fun ChatsListScreen(
}
}
) {
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
key(isDarkTheme, showRequestsScreen, isSelectionMode) {
@@ -876,9 +783,8 @@ fun ChatsListScreen(
// Delete
IconButton(onClick = {
val allDialogs = topLevelChatsState.dialogs
val first = selectedChatKeys.firstOrNull()
val dlg = allDialogs.find { it.opponentKey == first }
if (dlg != null) dialogToDelete = dlg
val selected = allDialogs.filter { selectedChatKeys.contains(it.opponentKey) }
if (selected.isNotEmpty()) dialogsToDelete = selected
selectedChatKeys = emptySet()
}) {
Icon(
@@ -956,8 +862,8 @@ fun ChatsListScreen(
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
scrolledContainerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
containerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4),
scrolledContainerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4),
navigationIconContentColor = Color.White,
titleContentColor = Color.White,
actionIconContentColor = Color.White
@@ -1089,9 +995,9 @@ fun ChatsListScreen(
colors =
TopAppBarDefaults.topAppBarColors(
containerColor =
if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4),
scrolledContainerColor =
if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF0D8CF4),
navigationIconContentColor =
Color.White,
titleContentColor =
@@ -1493,8 +1399,8 @@ fun ChatsListScreen(
selectedChatKeys + dialog.opponentKey
},
onDelete = {
dialogToDelete =
dialog
dialogsToDelete =
listOf(dialog)
},
onBlock = {
dialogToBlock =
@@ -1539,44 +1445,49 @@ fun ChatsListScreen(
// Console button removed
}
}
} // Close content Box
} // Close ModalNavigationDrawer
// 🔥 Confirmation Dialogs
// Delete Dialog Confirmation
dialogToDelete?.let { dialog ->
if (dialogsToDelete.isNotEmpty()) {
val count = dialogsToDelete.size
AlertDialog(
onDismissRequest = { dialogToDelete = null },
onDismissRequest = { dialogsToDelete = emptyList() },
containerColor =
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Delete Chat",
if (count == 1) "Delete Chat" else "Delete $count Chats",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"Are you sure you want to delete this chat? This action cannot be undone.",
if (count == 1) "Are you sure you want to delete this chat? This action cannot be undone."
else "Are you sure you want to delete $count chats? This action cannot be undone.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
val opponentKey = dialog.opponentKey
dialogToDelete = null
val toDelete = dialogsToDelete.toList()
dialogsToDelete = emptyList()
scope.launch {
chatsViewModel.deleteDialog(
opponentKey
)
toDelete.forEach { dialog ->
chatsViewModel.deleteDialog(
dialog.opponentKey
)
}
}
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { dialogToDelete = null }) {
TextButton(onClick = { dialogsToDelete = emptyList() }) {
Text("Cancel", color = PrimaryBlue)
}
}
@@ -1662,6 +1573,7 @@ fun ChatsListScreen(
}
)
}
} // Close Box
}
@@ -2272,6 +2184,9 @@ fun SwipeableDialogItem(
}
// 2. КОНТЕНТ - поверх кнопок, сдвигается при свайпе
// 🔥 rememberUpdatedState чтобы pointerInput всегда вызывал актуальные callbacks
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
Column(
modifier =
Modifier.fillMaxSize()
@@ -2338,12 +2253,12 @@ fun SwipeableDialogItem(
when (gestureType) {
"tap" -> {
onClick()
currentOnClick()
return@awaitEachGesture
}
"cancelled" -> return@awaitEachGesture
"longpress" -> {
onLongClick()
currentOnLongClick()
// Consume remaining events until finger lifts
while (true) {
val event = awaitPointerEvent()
@@ -3317,7 +3232,7 @@ fun DrawerMenuItemEnhanced(
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
fontWeight = FontWeight.Medium,
color = textColor,
modifier = Modifier.weight(1f)
)
@@ -3348,7 +3263,7 @@ fun DrawerDivider(isDarkTheme: Boolean) {
Spacer(modifier = Modifier.height(8.dp))
Divider(
modifier = Modifier.padding(horizontal = 20.dp),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFEEEEEE),
color = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFC8C8CC),
thickness = 0.5.dp
)
Spacer(modifier = Modifier.height(8.dp))

View File

@@ -225,9 +225,9 @@ fun ForwardChatPickerBottomSheet(
onClick = {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
selectedChats = if (isSelected) {
selectedChats - dialog.opponentKey
emptySet()
} else {
selectedChats + dialog.opponentKey
setOf(dialog.opponentKey)
}
}
)
@@ -263,6 +263,7 @@ fun ForwardChatPickerBottomSheet(
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFD1D1D6),
disabledContentColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF999999)
)
@@ -274,10 +275,7 @@ fun ForwardChatPickerBottomSheet(
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = if (hasSelection)
"Forward to ${selectedChats.size} chat${if (selectedChats.size > 1) "s" else ""}"
else
"Select a chat",
text = if (hasSelection) "Forward" else "Select a chat",
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)

View File

@@ -54,7 +54,8 @@ fun SearchScreen(
isDarkTheme: Boolean,
protocolState: ProtocolState,
onBackClick: () -> Unit,
onUserSelect: (SearchUser) -> Unit
onUserSelect: (SearchUser) -> Unit,
onNavigateToCrashLogs: () -> Unit = {}
) {
// Context и View для мгновенного закрытия клавиатуры
val context = LocalContext.current
@@ -84,6 +85,14 @@ fun SearchScreen(
val searchResults by searchViewModel.searchResults.collectAsState()
val isSearching by searchViewModel.isSearching.collectAsState()
// Easter egg: navigate to CrashLogs when typing "rosettadev1"
LaunchedEffect(searchQuery) {
if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) {
searchViewModel.clearSearchQuery()
onNavigateToCrashLogs()
}
}
// Always reset query/results when leaving Search screen (back/swipe/navigation).
DisposableEffect(Unit) { onDispose { searchViewModel.clearSearchQuery() } }

View File

@@ -1753,23 +1753,7 @@ fun KebabMenu(
)
}
// Debug Logs
KebabMenuItem(
icon = TablerIcons.Bug,
text = "Debug Logs",
onClick = onLogsClick,
tintColor = PrimaryBlue,
textColor = if (isDarkTheme) Color.White else Color.Black
)
Box(
modifier =
Modifier.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
.height(0.5.dp)
.background(dividerColor)
)
// Delete chat
KebabMenuItem(
icon = TablerIcons.Trash,
text = "Delete Chat",

View File

@@ -38,37 +38,63 @@ fun BoxScope.BlurredAvatarBackground(
avatarRepository: AvatarRepository?,
fallbackColor: Color,
blurRadius: Float = 25f,
alpha: Float = 0.3f,
overlayColors: List<Color>? = null
alpha: Float = 0.9f,
overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true
) {
// Получаем аватары из репозитория
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
// В светлой теме: если дефолтный фон (avatar) — синий как шапка chat list,
// если выбран кастомный цвет в Appearance — используем его
if (!isDarkTheme) {
val lightBgModifier = if (overlayColors != null && overlayColors.isNotEmpty()) {
if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0])
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors)
)
}
} else {
Modifier.matchParentSize().background(Color(0xFF0D8CF4))
}
Box(modifier = lightBgModifier)
return
}
// Если выбран цвет в Appearance — просто сплошной цвет/градиент, без blur
if (overlayColors != null && overlayColors.isNotEmpty()) {
val bgModifier = if (overlayColors.size == 1) {
Modifier.matchParentSize().background(overlayColors[0])
} else {
Modifier.matchParentSize().background(
Brush.linearGradient(colors = overlayColors)
)
}
Box(modifier = bgModifier)
return
}
// Нет фона (avatar default) — blur аватарки
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) }
// Состояние для bitmap и размытого bitmap
var originalBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember(avatars) { mutableStateOf<Bitmap?>(null) }
// Декодируем и размываем аватар
LaunchedEffect(avatars) {
if (avatars.isNotEmpty()) {
originalBitmap = withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(avatars.first().base64Data)
}
// Размываем bitmap (уменьшаем для производительности, затем применяем blur)
originalBitmap?.let { bitmap ->
blurredBitmap = withContext(Dispatchers.Default) {
// Уменьшаем размер для быстрого blur
val scaledBitmap = Bitmap.createScaledBitmap(
bitmap,
bitmap.width / 4,
bitmap.height / 4,
true
)
// Применяем blur несколько раз для более гладкого эффекта
var result = scaledBitmap
repeat(3) {
repeat(2) {
result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1))
}
result
@@ -79,71 +105,22 @@ fun BoxScope.BlurredAvatarBackground(
blurredBitmap = null
}
}
// Используем matchParentSize() чтобы занимать только размер родителя
Box(modifier = Modifier.matchParentSize()) {
if (blurredBitmap != null) {
// Показываем размытое изображение
Image(
bitmap = blurredBitmap!!.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
this.alpha = alpha
},
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
// Дополнительный overlay — кастомный или стандартный
if (overlayColors != null && overlayColors.isNotEmpty()) {
// Кастомный цветной overlay
val overlayModifier = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
.background(overlayColors[0].copy(alpha = 0.55f))
} else {
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = overlayColors.map { it.copy(alpha = 0.55f) }
)
)
}
Box(modifier = overlayModifier)
} else {
// Стандартный overlay для затемнения
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor.copy(alpha = 0.3f))
)
}
} else {
// Fallback: когда нет аватарки
if (overlayColors != null && overlayColors.isNotEmpty()) {
// Кастомный фон без blur
val bgModifier = if (overlayColors.size == 1) {
Modifier
.fillMaxSize()
.background(overlayColors[0])
} else {
Modifier
.fillMaxSize()
.background(
Brush.linearGradient(colors = overlayColors)
)
}
Box(modifier = bgModifier)
} else {
// Стандартный fallback: цветной фон
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
}
// Нет фото — цвет аватарки
Box(
modifier = Modifier
.fillMaxSize()
.background(fallbackColor)
)
}
}
}

View File

@@ -29,7 +29,7 @@ object BackgroundBlurPresets {
/** Сплошные цвета */
private val solidColors = listOf(
BackgroundBlurOption("solid_blue", listOf(Color(0xFF2979FF)), "Blue"),
BackgroundBlurOption("solid_blue", listOf(Color(0xFF0D8CF4)), "Blue"),
BackgroundBlurOption("solid_green", listOf(Color(0xFF4CAF50)), "Green"),
BackgroundBlurOption("solid_orange", listOf(Color(0xFFFF9800)), "Orange"),
BackgroundBlurOption("solid_red", listOf(Color(0xFFE53935)), "Red"),

View File

@@ -63,11 +63,12 @@ fun AppearanceScreen(
BackHandler { onBack() }
Column(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
// ═══════════════════════════════════════════════════════════
// CONTENT
// ═══════════════════════════════════════════════════════════
@@ -105,7 +106,7 @@ fun AppearanceScreen(
tint = Color.White
)
}
IconButton(onClick = onToggleTheme) {
IconButton(onClick = { onToggleTheme() }) {
Icon(
imageVector = if (isDarkTheme) TablerIcons.Sun else TablerIcons.Moon,
contentDescription = "Toggle theme",
@@ -154,6 +155,7 @@ fun AppearanceScreen(
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
@@ -439,7 +441,7 @@ private fun ColorCircleItem(
val borderColor by animateColorAsState(
targetValue = if (isSelected) {
if (isDarkTheme) Color.White else Color(0xFF222222)
Color.White
} else {
Color.Transparent
},

View File

@@ -226,6 +226,14 @@ fun OtherProfileScreen(
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
// Закрываем клавиатуру при открытии экрана
LaunchedEffect(Unit) {
val imm = context.getSystemService(android.content.Context.INPUT_METHOD_SERVICE)
as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
}
// 🗑️ Для удаления диалога
val database = remember { RosettaDatabase.getDatabase(context) }
val messageDao = remember { database.messageDao() }
@@ -1664,7 +1672,7 @@ private fun CollapsingOtherProfileHeader(
// ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
// ═══════════════════════════════════════════════════════════
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColor = Color.White
Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) {
// ═══════════════════════════════════════════════════════════
@@ -1674,9 +1682,10 @@ private fun CollapsingOtherProfileHeader(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f,
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
blurRadius = 20f,
alpha = 0.9f,
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme
)
// ═══════════════════════════════════════════════════════════
@@ -1730,7 +1739,7 @@ private fun CollapsingOtherProfileHeader(
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back",
tint = if (isDarkTheme) Color.White else Color.Black,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
@@ -1751,7 +1760,7 @@ private fun CollapsingOtherProfileHeader(
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Profile menu",
tint = if (isDarkTheme) Color.White else Color.Black,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
@@ -1820,12 +1829,7 @@ private fun CollapsingOtherProfileHeader(
Text(
text = statusText,
fontSize = onlineFontSize,
color =
if (isOnline) {
Color(0xFF4CAF50)
} else {
textColor.copy(alpha = 0.7f)
}
color = Color.White
)
}
}

View File

@@ -55,6 +55,8 @@ 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.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import com.rosetta.messenger.ui.components.metaball.ProfileMetaballEffect
import com.rosetta.messenger.utils.AvatarFileManager
@@ -259,7 +261,6 @@ fun ProfileScreen(
onNavigateToAppearance: () -> Unit = {},
onNavigateToSafety: () -> Unit = {},
onNavigateToLogs: () -> Unit = {},
onNavigateToCrashLogs: () -> Unit = {},
onNavigateToBiometric: () -> Unit = {},
viewModel: ProfileViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: AvatarRepository? = null,
@@ -759,6 +760,31 @@ fun ProfileScreen(
Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS SECTION
// ═════════════════════════════════════════════════════════════
TelegramSectionTitle(title = "Notifications", isDarkTheme = isDarkTheme)
run {
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()
TelegramToggleItem(
icon = TablerIcons.Bell,
title = "Push Notifications",
isEnabled = notificationsEnabled,
onToggle = {
scope.launch {
preferencesManager.setNotificationsEnabled(!notificationsEnabled)
}
},
isDarkTheme = isDarkTheme
)
}
Spacer(modifier = Modifier.height(24.dp))
// ═════════════════════════════════════════════════════════════
// ⚙️ SETTINGS SECTION - Telegram style
// ═════════════════════════════════════════════════════════════
@@ -785,14 +811,6 @@ fun ProfileScreen(
title = "Safety",
onClick = onNavigateToSafety,
isDarkTheme = isDarkTheme,
showDivider = true
)
TelegramSettingsItem(
icon = TablerIcons.Bug,
title = "Crash Logs",
onClick = onNavigateToCrashLogs,
isDarkTheme = isDarkTheme,
showDivider = biometricAvailable is BiometricAvailability.Available
)
@@ -938,7 +956,7 @@ private fun CollapsingProfileHeader(
// ═══════════════════════════════════════════════════════════
// 🎨 TEXT COLOR - просто по теме: белый в тёмной, чёрный в светлой
// ═══════════════════════════════════════════════════════════
val textColor = if (isDarkTheme) Color.White else Color.Black
val textColor = Color.White
// ═══════════════════════════════════════════════════════════
// 📐 HEADER HEIGHT - ФИКСИРОВАННАЯ! Не меняется при overscroll
@@ -959,7 +977,7 @@ private fun CollapsingProfileHeader(
// ═══════════════════════════════════════════════════════════
// 📝 TEXT - внизу header зоны, внутри блока
// ═══════════════════════════════════════════════════════════
val textDefaultY = expandedHeight - 48.dp // Внизу header блока (ближе к низу)
val textDefaultY = expandedHeight - 70.dp // Ближе к аватарке
val textCollapsedY = statusBarHeight + COLLAPSED_HEADER_HEIGHT / 2
// Текст меняет позицию только при collapse, НЕ при overscroll
@@ -977,9 +995,10 @@ private fun CollapsingProfileHeader(
publicKey = publicKey,
avatarRepository = avatarRepository,
fallbackColor = avatarColors.backgroundColor,
blurRadius = 25f,
alpha = 0.3f,
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId)
blurRadius = 20f,
alpha = 0.9f,
overlayColors = BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme
)
// ═══════════════════════════════════════════════════════════
@@ -1033,7 +1052,7 @@ private fun CollapsingProfileHeader(
Icon(
imageVector = TablerIcons.ArrowLeft,
contentDescription = "Back",
tint = if (isDarkTheme) Color.White else Color.Black,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
@@ -1055,11 +1074,9 @@ private fun CollapsingProfileHeader(
text = "Save",
color =
if (isSaveEnabled) {
if (isDarkTheme) Color.White else Color.Black
Color.White
} else {
(if (isDarkTheme) Color.White else Color.Black).copy(
alpha = 0.45f
)
Color.White.copy(alpha = 0.45f)
},
fontWeight = FontWeight.SemiBold
)
@@ -1074,7 +1091,7 @@ private fun CollapsingProfileHeader(
Icon(
imageVector = TablerIcons.DotsVertical,
contentDescription = "Profile menu",
tint = if (isDarkTheme) Color.White else Color.Black,
tint = Color.White,
modifier = Modifier.size(24.dp)
)
}
@@ -1174,7 +1191,8 @@ private fun FullSizeAvatar(
}
initialLoadComplete = true
} else {
// Нет аватарки - помечаем загрузку завершенной
// Нет аватарки - сбрасываем bitmap и помечаем загрузку завершенной
bitmap = null
initialLoadComplete = true
}
}
@@ -1323,8 +1341,8 @@ fun ProfileCard(
// ═════════════════════════════════════════════════════════════
@Composable
fun TelegramSectionTitle(title: String, isDarkTheme: Boolean) {
val textColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
fun TelegramSectionTitle(title: String, isDarkTheme: Boolean, color: Color? = null) {
val textColor = color ?: if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
Text(
text = title,
@@ -1483,11 +1501,12 @@ private fun TelegramSettingsItem(
onClick: () -> Unit,
isDarkTheme: Boolean,
showDivider: Boolean = false,
subtitle: String? = null
subtitle: String? = null,
iconTint: Color? = null
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val iconColor = iconTint ?: if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
Column {
@@ -1527,85 +1546,109 @@ private fun TelegramSettingsItem(
}
@Composable
private fun TelegramBiometricItem(isEnabled: Boolean, onToggle: () -> Unit, isDarkTheme: Boolean) {
private fun TelegramToggleItem(
icon: ImageVector,
title: String,
subtitle: String? = null,
isEnabled: Boolean,
onToggle: () -> Unit,
isDarkTheme: Boolean,
showDivider: Boolean = false
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val iconColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val primaryBlue = Color(0xFF007AFF)
val accentColor = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE0E0E0)
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(20.dp))
Text(
text = "Biometric Authentication",
fontSize = 16.sp,
color = textColor,
modifier = Modifier.weight(1f)
)
// iOS-style animated switch
val animatedThumbOffset by
animateFloatAsState(
targetValue = if (isEnabled) 1f else 0f,
animationSpec =
spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "switchThumb"
)
val trackColor by
animateColorAsState(
targetValue =
if (isEnabled) primaryBlue
else if (isDarkTheme) Color(0xFF39393D) else Color(0xFFE9E9EA),
animationSpec = tween(300),
label = "trackColor"
)
Box(
Column {
Row(
modifier =
Modifier.width(51.dp)
.height(31.dp)
.clip(RoundedCornerShape(15.5.dp))
.background(trackColor)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onToggle() }
)
.padding(2.dp)
Modifier.fillMaxWidth()
.clickable(onClick = onToggle)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(20.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = title, fontSize = 16.sp, color = textColor)
if (subtitle != null) {
Spacer(modifier = Modifier.height(2.dp))
Text(text = subtitle, fontSize = 13.sp, color = secondaryTextColor)
}
}
// Material 2 / old Telegram style switch
val thumbOffset by animateFloatAsState(
targetValue = if (isEnabled) 1f else 0f,
animationSpec = tween(durationMillis = 150),
label = "thumb"
)
val trackColor by animateColorAsState(
targetValue = if (isEnabled) accentColor.copy(alpha = 0.5f)
else if (isDarkTheme) Color(0xFF39393D) else Color(0xFFBDBDBD),
animationSpec = tween(durationMillis = 150),
label = "track"
)
val thumbColor by animateColorAsState(
targetValue = if (isEnabled) accentColor
else if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFFF1F1F1),
animationSpec = tween(durationMillis = 150),
label = "thumbColor"
)
Box(
modifier =
Modifier.size(27.dp)
.align(Alignment.CenterStart)
.offset(x = (20.dp * animatedThumbOffset))
.shadow(
elevation = if (isEnabled) 3.dp else 2.dp,
shape = CircleShape,
spotColor = Color.Black.copy(alpha = 0.15f)
)
.clip(CircleShape)
.background(Color.White)
modifier = Modifier
.width(37.dp)
.height(20.dp)
.clip(RoundedCornerShape(10.dp))
.background(trackColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = onToggle
),
contentAlignment = Alignment.CenterStart
) {
Box(
modifier = Modifier
.offset(x = (17.dp * thumbOffset))
.size(20.dp)
.shadow(2.dp, CircleShape)
.clip(CircleShape)
.background(thumbColor)
)
}
}
if (showDivider) {
Divider(
color = dividerColor,
thickness = 0.5.dp,
modifier = Modifier.padding(start = 60.dp)
)
}
}
}
@Composable
private fun TelegramBiometricItem(isEnabled: Boolean, onToggle: () -> Unit, isDarkTheme: Boolean) {
TelegramToggleItem(
icon = TablerIcons.Fingerprint,
title = "Biometric Authentication",
isEnabled = isEnabled,
onToggle = onToggle,
isDarkTheme = isDarkTheme
)
}
@Composable
private fun TelegramLogoutItem(onClick: () -> Unit, isDarkTheme: Boolean) {
val redColor = if (isDarkTheme) Color(0xFFFF5555) else Color(0xFFFF3B30)

View File

@@ -24,6 +24,8 @@ import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.onboarding.PrimaryBlueDark
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -46,10 +48,11 @@ fun SafetyScreen(
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
// Copy states
var copiedPublicKey by remember { mutableStateOf(false) }
var copiedPrivateKey by remember { mutableStateOf(false) }
var showDeleteConfirmation by remember { mutableStateOf(false) }
// Handle back gesture
BackHandler { onBack() }
@@ -105,7 +108,7 @@ fun SafetyScreen(
// ═══════════════════════════════════════════════════════════════
// Keys Section - Telegram style
// ═══════════════════════════════════════════════════════════════
TelegramSectionHeader("Keys", secondaryTextColor)
TelegramSectionHeader("Keys", Color(0xFF8E8E93))
TelegramCopyRow(
label = "Public Key",
@@ -173,7 +176,7 @@ fun SafetyScreen(
TelegramActionRow(
label = "Delete Account",
onClick = onDeleteAccount,
onClick = { showDeleteConfirmation = true },
textColor = redColor,
secondaryTextColor = secondaryTextColor,
showDivider = false
@@ -187,6 +190,40 @@ fun SafetyScreen(
Spacer(modifier = Modifier.height(32.dp))
}
}
// Delete Account Confirmation Dialog
if (showDeleteConfirmation) {
AlertDialog(
onDismissRequest = { showDeleteConfirmation = false },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Delete Account",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"You are attempting to delete your account. Are you sure? This action cannot be undone.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
showDeleteConfirmation = false
onDeleteAccount()
}
) { Text("Delete", color = Color(0xFFFF3B30)) }
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmation = false }) {
Text("Cancel", color = if (isDarkTheme) PrimaryBlueDark else PrimaryBlue)
}
}
)
}
}
@Composable