android: fix voice permissions, theme apply, and profile avatar layout
This commit is contained in:
@@ -429,3 +429,11 @@
|
||||
- Added accessibility refinements for key surfaces and controls:
|
||||
- explicit content descriptions for avatars and tab-like controls,
|
||||
- voice record button semantic label for TalkBack.
|
||||
|
||||
### Step 69 - Bugfix pass: voice recording, theme apply, profile avatar UX
|
||||
- Fixed voice recording start on Android by switching `VoiceRecorder` to compatible `MediaRecorder()` initialization.
|
||||
- Fixed microphone permission flow: record action now triggers runtime permission request reliably and auto-starts recording after grant.
|
||||
- Fixed theme switching application by introducing app-level `MessengerTheme` and switching app manifest base theme to DayNight.
|
||||
- Fixed profile screen usability after avatar upload:
|
||||
- enabled vertical scrolling with safe insets/navigation padding,
|
||||
- constrained avatar preview to a centered circular area instead of full-screen takeover.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
android:roundIcon="@android:drawable/sym_def_app_icon"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
|
||||
@@ -2,10 +2,8 @@ package ru.daemonlord.messenger
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -13,12 +11,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
||||
pendingNotificationMessageId = notificationPayload?.second
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
MaterialTheme {
|
||||
MessengerTheme {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
AppRoot(
|
||||
inviteToken = pendingInviteToken,
|
||||
|
||||
@@ -94,10 +94,24 @@ fun ChatRoute(
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
var startRecordingAfterPermissionGrant by remember { mutableStateOf(false) }
|
||||
val startVoiceRecording: () -> Unit = {
|
||||
val started = voiceRecorder.start()
|
||||
if (started) {
|
||||
AppAudioFocusCoordinator.request("voice-recording")
|
||||
viewModel.onVoiceRecordStarted()
|
||||
} else {
|
||||
viewModel.onVoiceRecordCancelled()
|
||||
}
|
||||
}
|
||||
val audioPermissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
hasAudioPermission = granted
|
||||
if (granted && startRecordingAfterPermissionGrant) {
|
||||
startVoiceRecording()
|
||||
}
|
||||
startRecordingAfterPermissionGrant = false
|
||||
}
|
||||
DisposableEffect(voiceRecorder) {
|
||||
onDispose {
|
||||
@@ -137,15 +151,10 @@ fun ChatRoute(
|
||||
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
||||
onVoiceRecordStart = {
|
||||
if (!hasAudioPermission) {
|
||||
startRecordingAfterPermissionGrant = true
|
||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
} else {
|
||||
val started = voiceRecorder.start()
|
||||
if (started) {
|
||||
AppAudioFocusCoordinator.request("voice-recording")
|
||||
viewModel.onVoiceRecordStarted()
|
||||
} else {
|
||||
viewModel.onVoiceRecordCancelled()
|
||||
}
|
||||
startVoiceRecording()
|
||||
}
|
||||
},
|
||||
onVoiceRecordTick = {
|
||||
@@ -1263,7 +1272,7 @@ private fun VoiceHoldToRecordButton(
|
||||
},
|
||||
) {
|
||||
Button(
|
||||
onClick = {},
|
||||
onClick = onStart,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
|
||||
) {
|
||||
|
||||
@@ -13,10 +13,11 @@ class VoiceRecorder(private val context: Context) {
|
||||
fun start(): Boolean {
|
||||
return runCatching {
|
||||
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
||||
val mediaRecorder = MediaRecorder(context).apply {
|
||||
val mediaRecorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||
setAudioChannels(1)
|
||||
setAudioEncodingBitRate(64_000)
|
||||
setAudioSamplingRate(44_100)
|
||||
setOutputFile(file.absolutePath)
|
||||
|
||||
@@ -8,15 +8,23 @@ import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.windowInsetsPadding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -29,6 +37,7 @@ import androidx.compose.runtime.getValue
|
||||
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.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -69,6 +78,7 @@ fun ProfileScreen(
|
||||
}
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
val pickAvatarLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
) { uri ->
|
||||
@@ -86,6 +96,8 @@ fun ProfileScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(scrollState)
|
||||
.navigationBarsPadding()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
@@ -94,11 +106,24 @@ fun ProfileScreen(
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
if (!avatarUrl.isBlank()) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = avatarUrl,
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(180.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.daemonlord.messenger.ui.theme
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
private val LightColors = lightColorScheme()
|
||||
private val DarkColors = darkColorScheme()
|
||||
|
||||
@Composable
|
||||
fun MessengerTheme(content: @Composable () -> Unit) {
|
||||
val darkTheme = when (AppCompatDelegate.getDefaultNightMode()) {
|
||||
AppCompatDelegate.MODE_NIGHT_YES -> true
|
||||
AppCompatDelegate.MODE_NIGHT_NO -> false
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = if (darkTheme) DarkColors else LightColors,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user