feat: improve media viewer and push delivery stability
Some checks failed
Android CI / android (push) Failing after 11m0s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

- add unified Android media viewer with swipe navigation, pinch-to-zoom and swipe-to-dismiss\n- move circle videos out of media gallery and surface them in voice/chat info flows\n- align web chat info handling for circle videos and media viewer exclusions\n- stabilize realtime and tablet chat shell updates already staged in this batch\n- fix Celery push delivery loop handling so FCM jobs can read tokens reliably in worker processes
This commit is contained in:
2026-04-05 14:06:36 +03:00
parent b40dea18f1
commit d2e0969fd5
16 changed files with 1916 additions and 765 deletions

View File

@@ -107,6 +107,8 @@ dependencies {
implementation("androidx.media3:media3-ui:1.4.1")
implementation("androidx.media3:media3-datasource:1.4.1")
implementation("androidx.media3:media3-datasource-okhttp:1.4.1")
implementation("androidx.media3:media3-effect:1.4.1")
implementation("androidx.media3:media3-transformer:1.4.1")
implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2")
implementation("androidx.camera:camera-lifecycle:1.4.2")

View File

@@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<application
android:name=".MessengerApplication"

View File

@@ -20,6 +20,7 @@ import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
import ru.daemonlord.messenger.domain.settings.repository.LanguageRepository
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
import ru.daemonlord.messenger.ui.theme.MessengerTheme
@@ -36,6 +37,9 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
@Inject
lateinit var handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase
private var pendingInviteToken by mutableStateOf<String?>(null)
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
@@ -70,6 +74,7 @@ class MainActivity : AppCompatActivity() {
pendingNotificationChatId = notificationPayload?.first
pendingNotificationMessageId = notificationPayload?.second
notificationPayload?.first?.let(notificationDispatcher::clearChatNotifications)
handleRealtimeEventsUseCase.start()
enableEdgeToEdge()
setContent {
MessengerTheme {

View File

@@ -1,10 +1,14 @@
package ru.daemonlord.messenger.core.notifications
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.daemonlord.messenger.MainActivity
import javax.inject.Inject
@@ -73,7 +77,7 @@ class NotificationDispatcher @Inject constructor(
.build()
val manager = NotificationManagerCompat.from(context)
manager.notify(chatNotificationId(payload.chatId), notification)
manager.notifySafely(chatNotificationId(payload.chatId), notification)
showSummaryNotification(manager)
}
@@ -82,14 +86,14 @@ class NotificationDispatcher @Inject constructor(
synchronized(chatStates) {
chatStates.remove(chatId)
}
manager.cancel(chatNotificationId(chatId))
manager.cancelSafely(chatNotificationId(chatId))
showSummaryNotification(manager)
}
private fun showSummaryNotification(manager: NotificationManagerCompat) {
val snapshot = synchronized(chatStates) { chatStates.values.toList() }
if (snapshot.isEmpty()) {
manager.cancel(SUMMARY_NOTIFICATION_ID)
manager.cancelSafely(SUMMARY_NOTIFICATION_ID)
return
}
val totalUnread = snapshot.sumOf { it.unreadCount }
@@ -114,7 +118,24 @@ class NotificationDispatcher @Inject constructor(
.setGroupSummary(true)
.setAutoCancel(true)
.build()
manager.notify(SUMMARY_NOTIFICATION_ID, summary)
manager.notifySafely(SUMMARY_NOTIFICATION_ID, summary)
}
private fun NotificationManagerCompat.notifySafely(id: Int, notification: android.app.Notification) {
if (!canPostNotifications()) return
runCatching { notify(id, notification) }
}
private fun NotificationManagerCompat.cancelSafely(id: Int) {
runCatching { cancel(id) }
}
private fun canPostNotifications(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS,
) == PackageManager.PERMISSION_GRANTED
}
private fun chatNotificationId(chatId: Long): Int {

View File

@@ -2,8 +2,8 @@ package ru.daemonlord.messenger.data.media.repository
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Movie
import android.graphics.ImageDecoder
import android.os.Build
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
@@ -166,14 +166,8 @@ class NetworkMediaRepository @Inject constructor(
fileName: String,
bytes: ByteArray,
): UploadPayload? {
val movie = runCatching { Movie.decodeByteArray(bytes, 0, bytes.size) }.getOrNull() ?: return null
val width = movie.width().coerceAtLeast(1)
val height = movie.height().coerceAtLeast(1)
val bitmap = runCatching { Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) }.getOrNull() ?: return null
val bitmap = decodeGifFirstFrame(bytes) ?: return null
return try {
val canvas = Canvas(bitmap)
movie.setTime(0)
movie.draw(canvas, 0f, 0f)
val output = ByteArrayOutputStream()
val compressed = bitmap.compress(Bitmap.CompressFormat.PNG, 100, output)
if (!compressed) return null
@@ -189,4 +183,17 @@ class NetworkMediaRepository @Inject constructor(
bitmap.recycle()
}
}
private fun decodeGifFirstFrame(bytes: ByteArray): Bitmap? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val decoded = runCatching {
val source = ImageDecoder.createSource(java.nio.ByteBuffer.wrap(bytes))
ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
}
}.getOrNull()
if (decoded != null) return decoded
}
return runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
}
}

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.di
import android.content.Context
import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
@@ -15,10 +16,12 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@UnstableApi
object MediaCacheModule {
@Provides
@Singleton
@UnstableApi
fun provideMediaCache(
@ApplicationContext context: Context,
): Cache {
@@ -34,4 +37,3 @@ object MediaCacheModule {
)
}
}

View File

@@ -14,6 +14,7 @@ import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.message.local.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
@@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val chatRepository: ChatRepository,
private val chatDao: ChatDao,
private val messageDao: MessageDao,
private val messageRepository: MessageRepository,
private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker,
private val tokenRepository: TokenRepository,
@@ -50,6 +52,8 @@ class HandleRealtimeEventsUseCase @Inject constructor(
is RealtimeEvent.ReceiveMessage -> {
val activeChatId = activeChatTracker.activeChatId.value
val lastMessagePreview = event.text?.takeIf { it.isNotBlank() }
?: fallbackMessagePreview(event.type)
messageDao.upsertMessages(
listOf(
MessageEntity(
@@ -75,16 +79,18 @@ class HandleRealtimeEventsUseCase @Inject constructor(
)
chatDao.updateLastMessage(
chatId = event.chatId,
lastMessageText = event.text,
lastMessageText = lastMessagePreview,
lastMessageType = event.type,
lastMessageCreatedAt = event.createdAt,
updatedSortAt = event.createdAt,
)
if (activeChatId == event.chatId) {
chatDao.markChatRead(chatId = event.chatId)
messageRepository.syncRecentMessages(chatId = event.chatId)
} else {
chatDao.incrementUnread(chatId = event.chatId)
}
chatRepository.refreshChat(chatId = event.chatId)
val activeUserId = tokenRepository.getActiveUserId()
val myUsername = activeUserId?.let { userId ->
tokenRepository.getAccounts()
@@ -142,11 +148,15 @@ class HandleRealtimeEventsUseCase @Inject constructor(
)
chatDao.updateLastMessage(
chatId = event.chatId,
lastMessageText = event.text,
lastMessageText = event.text?.takeIf { it.isNotBlank() } ?: fallbackMessagePreview(event.type),
lastMessageType = event.type,
lastMessageCreatedAt = event.updatedAt,
updatedSortAt = event.updatedAt,
)
if (activeChatTracker.activeChatId.value == event.chatId) {
messageRepository.syncRecentMessages(chatId = event.chatId)
}
chatRepository.refreshChat(chatId = event.chatId)
}
is RealtimeEvent.MessageDeleted -> {
@@ -156,6 +166,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
}
is RealtimeEvent.ChatUpdated -> {
chatRepository.refreshChat(chatId = event.chatId)
chatRepository.refreshChats(archived = false)
chatRepository.refreshChats(archived = true)
}
@@ -209,4 +220,16 @@ class HandleRealtimeEventsUseCase @Inject constructor(
collectionJob = null
realtimeManager.disconnect()
}
private fun fallbackMessagePreview(type: String?): String {
return when (type?.lowercase()) {
"image" -> "Photo"
"video" -> "Video"
"audio" -> "Audio"
"voice" -> "Voice message"
"file" -> "File"
"circle_video" -> "Video message"
else -> "New message"
}
}
}

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.chat.voice
import android.content.Context
import android.os.Build
import android.media.MediaRecorder
import java.io.File
import java.util.UUID
@@ -13,7 +14,7 @@ class VoiceRecorder(private val context: Context) {
fun start(): Boolean {
return runCatching {
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
val mediaRecorder = MediaRecorder().apply {
val mediaRecorder = createMediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
@@ -34,6 +35,15 @@ class VoiceRecorder(private val context: Context) {
}
}
private fun createMediaRecorder(): MediaRecorder {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
@Suppress("DEPRECATION")
MediaRecorder()
}
}
fun elapsedMillis(nowMillis: Long = System.currentTimeMillis()): Long {
if (startedAtMillis <= 0L) return 0L
return (nowMillis - startedAtMillis).coerceAtLeast(0L)

View File

@@ -103,6 +103,7 @@ fun ChatListRoute(
onInviteTokenConsumed: () -> Unit,
isMainBarVisible: Boolean,
onMainBarVisibilityChanged: (Boolean) -> Unit,
selectedChatId: Long? = null,
viewModel: ChatListViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
@@ -153,6 +154,7 @@ fun ChatListRoute(
onBanMember = viewModel::banMember,
onUnbanMember = viewModel::unbanMember,
onToggleDayNightMode = viewModel::toggleDayNightMode,
selectedChatId = selectedChatId,
)
}
@@ -194,6 +196,7 @@ fun ChatListScreen(
onBanMember: (Long, Long) -> Unit,
onUnbanMember: (Long, Long) -> Unit,
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
selectedChatId: Long? = null,
) {
val context = LocalContext.current
var managementExpanded by remember { mutableStateOf(false) }
@@ -534,6 +537,7 @@ fun ChatListScreen(
chat = chat,
isSelecting = selectedChatIds.isNotEmpty(),
isSelected = selectedChatIds.contains(chat.id),
isActive = selectedChatIds.isEmpty() && selectedChatId == chat.id,
onClick = {
if (selectedChatIds.isNotEmpty()) {
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
@@ -1392,12 +1396,20 @@ private fun ChatRow(
chat: ChatItem,
isSelecting: Boolean,
isSelected: Boolean,
isActive: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(
when {
isActive -> MaterialTheme.colorScheme.primary.copy(alpha = 0.12f)
else -> Color.Transparent
},
)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,

View File

@@ -3,17 +3,21 @@ package ru.daemonlord.messenger.ui.navigation
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@@ -28,6 +32,7 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -37,10 +42,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.compose.ui.Alignment
@@ -99,16 +107,23 @@ fun MessengerNavHost(
) {
val uiState by viewModel.uiState.collectAsState()
val context = LocalContext.current
val configuration = LocalConfiguration.current
val isTabletLandscape = remember(configuration.screenWidthDp, configuration.screenHeightDp) {
configuration.screenWidthDp >= 840 && configuration.screenWidthDp > configuration.screenHeightDp
}
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
) {}
var isMainBarVisible by remember { mutableStateOf(true) }
var tabletSelectedChatId by rememberSaveable { mutableStateOf<Long?>(null) }
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
val mainTabRoutes = remember {
setOf(Routes.Chats, Routes.Contacts, Routes.Settings, Routes.Profile)
}
val showMainBar = currentRoute in mainTabRoutes
val isChatRoute = currentRoute?.startsWith("${Routes.Chat}/") == true
val showMainBar = (currentRoute in mainTabRoutes) &&
!(isTabletLandscape && (currentRoute == Routes.Chats || isChatRoute))
LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
@@ -143,11 +158,21 @@ fun MessengerNavHost(
}
if (uiState.isAuthenticated) {
if (notificationChatId != null) {
navController.navigate("${Routes.Chat}/$notificationChatId") {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
if (isTabletLandscape) {
tabletSelectedChatId = notificationChatId
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
} else {
navController.navigate("${Routes.Chat}/$notificationChatId") {
popUpTo(navController.graph.findStartDestination().id) {
inclusive = true
}
launchSingleTop = true
}
launchSingleTop = true
}
onNotificationConsumed()
} else {
@@ -277,15 +302,25 @@ fun MessengerNavHost(
}
composable(route = Routes.Chats) {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
},
isMainBarVisible = isMainBarVisible,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
if (isTabletLandscape) {
TabletChatsRoute(
parentNavController = navController,
selectedChatId = tabletSelectedChatId,
onSelectChat = { tabletSelectedChatId = it },
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
)
} else {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
},
isMainBarVisible = isMainBarVisible,
onMainBarVisibilityChanged = { isMainBarVisible = it },
)
}
}
composable(route = Routes.Contacts) {
@@ -323,9 +358,30 @@ fun MessengerNavHost(
navArgument("chatId") { type = NavType.LongType }
),
) { backStackEntry ->
ChatRoute(
onBack = { navController.popBackStack() },
)
val selectedChatId = backStackEntry.arguments?.getLong("chatId")
if (isTabletLandscape) {
LaunchedEffect(selectedChatId) {
tabletSelectedChatId = selectedChatId
navController.navigate(Routes.Chats) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
TabletChatsRoute(
parentNavController = navController,
selectedChatId = tabletSelectedChatId ?: selectedChatId,
onSelectChat = { tabletSelectedChatId = it },
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
)
} else {
ChatRoute(
onBack = { navController.popBackStack() },
)
}
}
}
@@ -355,14 +411,116 @@ fun MessengerNavHost(
}
}
@Composable
private fun TabletChatsRoute(
parentNavController: NavHostController,
selectedChatId: Long?,
onSelectChat: (Long?) -> Unit,
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
) {
val configuration = LocalConfiguration.current
val listPaneWidth = remember(configuration.screenWidthDp) {
(configuration.screenWidthDp * 0.29f).dp.coerceIn(320.dp, 420.dp)
}
BackHandler(enabled = selectedChatId != null) {
onSelectChat(null)
}
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Surface(
modifier = Modifier
.width(listPaneWidth)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 2.dp,
) {
Box(modifier = Modifier.fillMaxSize()) {
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onOpenChat = { chatId ->
onSelectChat(chatId)
},
isMainBarVisible = true,
onMainBarVisibilityChanged = {},
selectedChatId = selectedChatId,
)
MainBottomBar(
currentRoute = Routes.Chats,
onNavigate = { route ->
if (route == Routes.Chats) {
parentNavController.navigate(Routes.Chats) {
popUpTo(parentNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
} else {
parentNavController.navigate(route) {
popUpTo(parentNavController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(horizontal = 12.dp, vertical = 12.dp),
)
}
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxHeight(),
color = MaterialTheme.colorScheme.background,
tonalElevation = 0.dp,
) {
key(selectedChatId) {
val detailNavController = rememberNavController()
NavHost(
navController = detailNavController,
startDestination = if (selectedChatId == null) "empty" else "${Routes.Chat}/$selectedChatId",
) {
composable("empty") {
TabletChatPlaceholder()
}
composable(
route = "${Routes.Chat}/{chatId}",
arguments = listOf(navArgument("chatId") { type = NavType.LongType }),
) {
ChatRoute(
onBack = {
onSelectChat(null)
},
showBackButton = false,
)
}
}
}
}
}
}
@Composable
private fun MainBottomBar(
currentRoute: String?,
onNavigate: (String) -> Unit,
modifier: Modifier = Modifier.padding(horizontal = 12.dp),
) {
Surface(
modifier = Modifier
.padding(horizontal = 12.dp)
modifier = modifier
.fillMaxWidth(),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.88f),
@@ -454,3 +612,58 @@ private fun SessionCheckingScreen() {
CircularProgressIndicator()
}
}
@Composable
private fun TabletChatPlaceholder() {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f),
tonalElevation = 2.dp,
shadowElevation = 10.dp,
modifier = Modifier.fillMaxWidth(0.56f),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 40.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
modifier = Modifier.padding(bottom = 18.dp),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Chat,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(18.dp),
)
}
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_title),
style = MaterialTheme.typography.headlineSmall,
)
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 12.dp),
)
Text(
text = stringResource(id = R.string.tablet_chat_placeholder_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.78f),
modifier = Modifier.padding(top = 16.dp),
)
}
}
}
}

View File

@@ -28,6 +28,9 @@
<string name="chats_dialog_delete_selected_title">Удалить выбранные чаты</string>
<string name="chats_dialog_delete_selected_body">Вы уверены, что хотите удалить выбранные чаты?</string>
<string name="chats_dialog_delete_for_all">Удалить для всех (где доступно)</string>
<string name="tablet_chat_placeholder_title">Выберите чат</string>
<string name="tablet_chat_placeholder_body">Откройте диалог из левой колонки, и он появится здесь.</string>
<string name="tablet_chat_placeholder_hint">Открытый чат остаётся справа, а список слева всегда под рукой.</string>
<string name="filter_all">Все</string>
<string name="filter_people">Люди</string>
@@ -158,6 +161,7 @@
<string name="chat_picker_tab_stickers">Стикеры</string>
<string name="chat_playback_subtitle_voice">Голосовое сообщение • %1$s</string>
<string name="chat_playback_subtitle_audio">Аудио • %1$s</string>
<string name="chat_playback_subtitle_circle">Видеокружок • %1$s</string>
<string name="chat_user_fallback_with_id">Пользователь #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string>
@@ -332,4 +336,5 @@
<string name="account_info_recovery_codes_regenerated">Коды восстановления перегенерированы.</string>
<string name="account_error_invalid_credentials">Неверные учетные данные.</string>
<string name="account_error_unauthorized">Не авторизовано.</string>
<string name="chat_audio_strip_video_note">Видеокружок</string>
</resources>

View File

@@ -68,6 +68,9 @@
<string name="chats_dialog_delete_selected_title">Delete selected chats</string>
<string name="chats_dialog_delete_selected_body">Are you sure you want to delete selected chats?</string>
<string name="chats_dialog_delete_for_all">Delete for all (where allowed)</string>
<string name="tablet_chat_placeholder_title">Choose a chat</string>
<string name="tablet_chat_placeholder_body">Select a conversation from the left column to open it here.</string>
<string name="tablet_chat_placeholder_hint">Your current chat stays open here while the list remains visible.</string>
<string name="common_cancel">Cancel</string>
<string name="common_confirm">Confirm</string>
@@ -158,6 +161,7 @@
<string name="chat_picker_tab_stickers">Stickers</string>
<string name="chat_playback_subtitle_voice">Voice message • %1$s</string>
<string name="chat_playback_subtitle_audio">Audio • %1$s</string>
<string name="chat_playback_subtitle_circle">Video note • %1$s</string>
<string name="chat_user_fallback_with_id">User #%1$d</string>
<string name="chat_member_role_with_id">%1$s • id %2$d</string>
<string name="chat_member_id">id %1$d</string>
@@ -332,4 +336,5 @@
<string name="account_info_recovery_codes_regenerated">Recovery codes regenerated.</string>
<string name="account_error_invalid_credentials">Invalid credentials.</string>
<string name="account_error_unauthorized">Unauthorized.</string>
<string name="chat_audio_strip_video_note">Video note</string>
</resources>