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 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.
### 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
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
@@ -17,6 +19,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
@@ -29,11 +32,18 @@ fun LoginScreen(
onOpenVerifyEmail: () -> Unit,
onOpenResetPassword: () -> Unit,
) {
Column(
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (isTabletLayout) Modifier.widthIn(max = 560.dp) else Modifier),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@@ -104,3 +114,4 @@ fun LoginScreen(
}
}
}
}

View File

@@ -1,12 +1,14 @@
package ru.daemonlord.messenger.ui.auth.reset
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -18,7 +20,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -33,11 +37,18 @@ fun ResetPasswordRoute(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
Column(
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.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),
) {
Text("Password reset", style = MaterialTheme.typography.headlineSmall)
@@ -85,3 +96,4 @@ fun ResetPasswordRoute(
}
}
}
}

View File

@@ -1,12 +1,14 @@
package ru.daemonlord.messenger.ui.auth.verify
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -19,7 +21,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -40,11 +44,18 @@ fun VerifyEmailRoute(
}
}
Column(
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.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),
) {
Text("Verify email", style = MaterialTheme.typography.headlineSmall)
@@ -75,3 +86,4 @@ fun VerifyEmailRoute(
}
}
}
}

View File

@@ -55,6 +55,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -237,6 +238,8 @@ fun ChatScreen(
var showDeleteDialog by remember { mutableStateOf(false) }
val actionSheetState = 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) {
if (!state.isRecordingVoice) return@LaunchedEffect
@@ -264,7 +267,8 @@ fun ChatScreen(
),
),
)
.windowInsetsPadding(WindowInsets.safeDrawing),
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = adaptiveHorizontalPadding),
) {
Row(
modifier = Modifier
@@ -676,7 +680,6 @@ fun ChatScreen(
} else {
VoiceHoldToRecordButton(
enabled = state.canSendMessages && !state.isUploadingMedia,
isRecording = state.isRecordingVoice,
isLocked = state.isVoiceLocked,
onStart = onVoiceRecordStart,
onLock = onVoiceRecordLock,
@@ -1357,7 +1360,6 @@ private fun VoiceWaveform(
@Composable
private fun VoiceHoldToRecordButton(
enabled: Boolean,
isRecording: Boolean,
isLocked: Boolean,
onStart: () -> Unit,
onLock: () -> Unit,
@@ -1366,7 +1368,7 @@ private fun VoiceHoldToRecordButton(
) {
Box(
modifier = Modifier
.pointerInput(enabled, isRecording, isLocked) {
.pointerInput(enabled, isLocked) {
if (!enabled || isLocked) return@pointerInput
awaitEachGesture {
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.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.WindowInsets
@@ -41,6 +42,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
@@ -133,11 +135,18 @@ fun ChatListScreen(
var selectedManageChatIdText by remember { mutableStateOf("") }
var manageUserIdText by remember { mutableStateOf("") }
var manageRoleText by remember { mutableStateOf("member") }
val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840
Column(
Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.TopCenter,
) {
Column(
modifier = Modifier
.fillMaxSize()
.then(if (isTabletLayout) Modifier.widthIn(max = 820.dp) else Modifier),
) {
TabRow(
selectedTabIndex = if (state.selectedTab == ChatTab.ALL) 0 else 1,
@@ -491,6 +500,7 @@ fun ChatListScreen(
}
}
}
}
@Composable
private fun ChatRow(

View File

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

View File

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

View File

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