android: add tablet adaptive layouts and fix voice release send
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 16:49:17 +03:00
parent f6851d2af9
commit fd31e39fce
9 changed files with 96 additions and 23 deletions

View File

@@ -450,3 +450,8 @@
- Added playback speed switch for voice messages (`1.0x -> 1.5x -> 2.0x`). - Added playback speed switch for voice messages (`1.0x -> 1.5x -> 2.0x`).
- Added view-only circle video renderer for `video_note` messages with looped playback. - Added view-only circle video renderer for `video_note` messages with looped playback.
- Kept regular audio/video attachment rendering for non-voice/non-circle media unchanged. - Kept regular audio/video attachment rendering for non-voice/non-circle media unchanged.
### Step 72 - Adaptive layout baseline (phone/tablet) + voice release fix
- Added tablet-aware max-width layout constraints across major screens (login, verify/reset auth, chats list, chat, profile, settings).
- Kept phone layout unchanged while centering content and limiting line width on larger displays.
- Fixed voice hold-to-send gesture reliability by removing pointer-input restarts during active recording, so release consistently triggers send path.

View File

@@ -1,9 +1,11 @@
package ru.daemonlord.messenger.ui.auth package ru.daemonlord.messenger.ui.auth
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -17,6 +19,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -29,11 +32,18 @@ fun LoginScreen(
onOpenVerifyEmail: () -> Unit, onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit, onOpenResetPassword: () -> Unit,
) { ) {
Column( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 24.dp), .padding(horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 560.dp) else Modifier),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
@@ -103,4 +113,5 @@ fun LoginScreen(
Text(text = "Forgot password") Text(text = "Forgot password")
} }
} }
}
} }

View File

@@ -1,12 +1,14 @@
package ru.daemonlord.messenger.ui.auth.reset package ru.daemonlord.messenger.ui.auth.reset
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -18,7 +20,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -33,11 +37,18 @@ fun ResetPasswordRoute(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var email by remember { mutableStateOf("") } var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
Column( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text("Password reset", style = MaterialTheme.typography.headlineSmall) Text("Password reset", style = MaterialTheme.typography.headlineSmall)
@@ -84,4 +95,5 @@ fun ResetPasswordRoute(
Text("Back to login") Text("Back to login")
} }
} }
}
} }

View File

@@ -1,12 +1,14 @@
package ru.daemonlord.messenger.ui.auth.verify package ru.daemonlord.messenger.ui.auth.verify
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -19,7 +21,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -40,11 +44,18 @@ fun VerifyEmailRoute(
} }
} }
Column( val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(16.dp), .padding(16.dp),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 620.dp) else Modifier),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Text("Verify email", style = MaterialTheme.typography.headlineSmall) Text("Verify email", style = MaterialTheme.typography.headlineSmall)
@@ -74,4 +85,5 @@ fun VerifyEmailRoute(
Text("Back to login") Text("Back to login")
} }
} }
}
} }

View File

@@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -237,6 +238,8 @@ fun ChatScreen(
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
LaunchedEffect(state.isRecordingVoice) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -264,7 +267,8 @@ fun ChatScreen(
), ),
), ),
) )
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = adaptiveHorizontalPadding),
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -676,7 +680,6 @@ fun ChatScreen(
} else { } else {
VoiceHoldToRecordButton( VoiceHoldToRecordButton(
enabled = state.canSendMessages && !state.isUploadingMedia, enabled = state.canSendMessages && !state.isUploadingMedia,
isRecording = state.isRecordingVoice,
isLocked = state.isVoiceLocked, isLocked = state.isVoiceLocked,
onStart = onVoiceRecordStart, onStart = onVoiceRecordStart,
onLock = onVoiceRecordLock, onLock = onVoiceRecordLock,
@@ -1357,7 +1360,6 @@ private fun VoiceWaveform(
@Composable @Composable
private fun VoiceHoldToRecordButton( private fun VoiceHoldToRecordButton(
enabled: Boolean, enabled: Boolean,
isRecording: Boolean,
isLocked: Boolean, isLocked: Boolean,
onStart: () -> Unit, onStart: () -> Unit,
onLock: () -> Unit, onLock: () -> Unit,
@@ -1366,7 +1368,7 @@ private fun VoiceHoldToRecordButton(
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.pointerInput(enabled, isRecording, isLocked) { .pointerInput(enabled, isLocked) {
if (!enabled || isLocked) return@pointerInput if (!enabled || isLocked) return@pointerInput
awaitEachGesture { awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false) val down = awaitFirstDown(requireUnconsumed = false)

View File

@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
@@ -41,6 +42,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -133,11 +135,18 @@ fun ChatListScreen(
var selectedManageChatIdText by remember { mutableStateOf("") } var selectedManageChatIdText by remember { mutableStateOf("") }
var manageUserIdText by remember { mutableStateOf("") } var manageUserIdText by remember { mutableStateOf("") }
var manageRoleText by remember { mutableStateOf("member") } var manageRoleText by remember { mutableStateOf("member") }
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing), .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxSize()
.then(if (isTabletLayout) Modifier.widthIn(max = 820.dp) else Modifier),
) { ) {
TabRow( TabRow(
selectedTabIndex = if (state.selectedTab == ChatTab.ALL) 0 else 1, selectedTabIndex = if (state.selectedTab == ChatTab.ALL) 0 else 1,
@@ -490,6 +499,7 @@ fun ChatListScreen(
} }
} }
} }
}
} }
@Composable @Composable

View File

@@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@@ -38,6 +39,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -78,6 +80,7 @@ fun ProfileScreen(
} }
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val pickAvatarLauncher = rememberLauncherForActivityResult( val pickAvatarLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(), contract = ActivityResultContracts.GetContent(),
@@ -92,12 +95,18 @@ fun ProfileScreen(
} }
} }
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing)
.navigationBarsPadding(),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.navigationBarsPadding()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
@@ -201,6 +210,7 @@ fun ProfileScreen(
Text("Back to chats") Text("Back to chats")
} }
} }
}
} }
private fun Uri.toSquareJpeg(context: Context): ByteArray? { private fun Uri.toSquareJpeg(context: Context): ByteArray? {

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.settings package ru.daemonlord.messenger.ui.settings
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -28,6 +30,7 @@ import androidx.compose.runtime.setValue
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -66,16 +69,23 @@ fun SettingsScreen(
var blockUserIdInput by remember { mutableStateOf("") } var blockUserIdInput by remember { mutableStateOf("") }
var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) } var nightMode by remember { mutableIntStateOf(AppCompatDelegate.getDefaultNightMode()) }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.refresh() viewModel.refresh()
viewModel.refreshRecoveryStatus() viewModel.refreshRecoveryStatus()
} }
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing) .windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 720.dp) else Modifier)
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -311,4 +321,5 @@ fun SettingsScreen(
} }
} }
} }
}
} }

View File

@@ -97,7 +97,7 @@
## 13. UI/UX и темы ## 13. UI/UX и темы
- [x] Светлая/темная тема (читаемая) - [x] Светлая/темная тема (читаемая)
- [ ] Адаптивность phone/tablet - [x] Адаптивность phone/tablet
- [x] Контекстные меню без конфликтов жестов - [x] Контекстные меню без конфликтов жестов
- [x] Bottom sheets/dialog behavior consistency - [x] Bottom sheets/dialog behavior consistency
- [x] Accessibility (TalkBack, dynamic type) - [x] Accessibility (TalkBack, dynamic type)