feat: improve media viewer and push delivery stability
- 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:
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user