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:
|
- Added accessibility refinements for key surfaces and controls:
|
||||||
- explicit content descriptions for avatars and tab-like controls,
|
- explicit content descriptions for avatars and tab-like controls,
|
||||||
- voice record button semantic label for TalkBack.
|
- 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:roundIcon="@android:drawable/sym_def_app_icon"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -2,10 +2,8 @@ package ru.daemonlord.messenger
|
|||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -13,12 +11,14 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
import ru.daemonlord.messenger.core.notifications.NotificationIntentExtras
|
||||||
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
import ru.daemonlord.messenger.ui.navigation.MessengerNavHost
|
||||||
|
import ru.daemonlord.messenger.ui.theme.MessengerTheme
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
private var pendingInviteToken by mutableStateOf<String?>(null)
|
private var pendingInviteToken by mutableStateOf<String?>(null)
|
||||||
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
private var pendingVerifyEmailToken by mutableStateOf<String?>(null)
|
||||||
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
private var pendingResetPasswordToken by mutableStateOf<String?>(null)
|
||||||
@@ -35,7 +35,7 @@ class MainActivity : ComponentActivity() {
|
|||||||
pendingNotificationMessageId = notificationPayload?.second
|
pendingNotificationMessageId = notificationPayload?.second
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
MaterialTheme {
|
MessengerTheme {
|
||||||
Surface(modifier = Modifier.fillMaxSize()) {
|
Surface(modifier = Modifier.fillMaxSize()) {
|
||||||
AppRoot(
|
AppRoot(
|
||||||
inviteToken = pendingInviteToken,
|
inviteToken = pendingInviteToken,
|
||||||
|
|||||||
@@ -94,10 +94,24 @@ fun ChatRoute(
|
|||||||
) == PackageManager.PERMISSION_GRANTED
|
) == 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(
|
val audioPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
) { granted ->
|
) { granted ->
|
||||||
hasAudioPermission = granted
|
hasAudioPermission = granted
|
||||||
|
if (granted && startRecordingAfterPermissionGrant) {
|
||||||
|
startVoiceRecording()
|
||||||
|
}
|
||||||
|
startRecordingAfterPermissionGrant = false
|
||||||
}
|
}
|
||||||
DisposableEffect(voiceRecorder) {
|
DisposableEffect(voiceRecorder) {
|
||||||
onDispose {
|
onDispose {
|
||||||
@@ -137,15 +151,10 @@ fun ChatRoute(
|
|||||||
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
onPickMedia = { pickMediaLauncher.launch("*/*") },
|
||||||
onVoiceRecordStart = {
|
onVoiceRecordStart = {
|
||||||
if (!hasAudioPermission) {
|
if (!hasAudioPermission) {
|
||||||
|
startRecordingAfterPermissionGrant = true
|
||||||
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
audioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
} else {
|
} else {
|
||||||
val started = voiceRecorder.start()
|
startVoiceRecording()
|
||||||
if (started) {
|
|
||||||
AppAudioFocusCoordinator.request("voice-recording")
|
|
||||||
viewModel.onVoiceRecordStarted()
|
|
||||||
} else {
|
|
||||||
viewModel.onVoiceRecordCancelled()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onVoiceRecordTick = {
|
onVoiceRecordTick = {
|
||||||
@@ -1263,7 +1272,7 @@ private fun VoiceHoldToRecordButton(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = {},
|
onClick = onStart,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
|
modifier = Modifier.semantics { contentDescription = "Hold to record voice message" },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ class VoiceRecorder(private val context: Context) {
|
|||||||
fun start(): Boolean {
|
fun start(): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
val file = File(context.cacheDir, "voice_${UUID.randomUUID()}.m4a")
|
||||||
val mediaRecorder = MediaRecorder(context).apply {
|
val mediaRecorder = MediaRecorder().apply {
|
||||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||||
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
|
||||||
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
|
||||||
|
setAudioChannels(1)
|
||||||
setAudioEncodingBitRate(64_000)
|
setAudioEncodingBitRate(64_000)
|
||||||
setAudioSamplingRate(44_100)
|
setAudioSamplingRate(44_100)
|
||||||
setOutputFile(file.absolutePath)
|
setOutputFile(file.absolutePath)
|
||||||
|
|||||||
@@ -8,15 +8,23 @@ import android.os.Build
|
|||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.Arrangement
|
||||||
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.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
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.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.windowInsetsPadding
|
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.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -29,6 +37,7 @@ 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.draw.clip
|
||||||
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
|
||||||
@@ -69,6 +78,7 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
val pickAvatarLauncher = rememberLauncherForActivityResult(
|
val pickAvatarLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.GetContent(),
|
contract = ActivityResultContracts.GetContent(),
|
||||||
) { uri ->
|
) { uri ->
|
||||||
@@ -86,6 +96,8 @@ fun ProfileScreen(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
|
.verticalScroll(scrollState)
|
||||||
|
.navigationBarsPadding()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
@@ -94,11 +106,24 @@ fun ProfileScreen(
|
|||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
if (!avatarUrl.isBlank()) {
|
if (!avatarUrl.isBlank()) {
|
||||||
AsyncImage(
|
Box(
|
||||||
model = avatarUrl,
|
|
||||||
contentDescription = "Avatar",
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
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(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
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