yacwc
This commit is contained in:
123
.idea/codeStyles/Project.xml
generated
Normal file
123
.idea/codeStyles/Project.xml
generated
Normal file
@@ -0,0 +1,123 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<code_scheme name="Project" version="173">
|
||||
<JetCodeStyleSettings>
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</JetCodeStyleSettings>
|
||||
<codeStyleSettings language="XML">
|
||||
<option name="FORCE_REARRANGE_MODE" value="1" />
|
||||
<indentOptions>
|
||||
<option name="CONTINUATION_INDENT_SIZE" value="4" />
|
||||
</indentOptions>
|
||||
<arrangement>
|
||||
<rules>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:android</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>xmlns:.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:id</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*:name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>name</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>style</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>^$</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>ANDROID_ATTRIBUTE_ORDER</order>
|
||||
</rule>
|
||||
</section>
|
||||
<section>
|
||||
<rule>
|
||||
<match>
|
||||
<AND>
|
||||
<NAME>.*</NAME>
|
||||
<XML_ATTRIBUTE />
|
||||
<XML_NAMESPACE>.*</XML_NAMESPACE>
|
||||
</AND>
|
||||
</match>
|
||||
<order>BY_NAME</order>
|
||||
</rule>
|
||||
</section>
|
||||
</rules>
|
||||
</arrangement>
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="kotlin">
|
||||
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
|
||||
</codeStyleSettings>
|
||||
</code_scheme>
|
||||
</component>
|
||||
6
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
6
.idea/codeStyles/codeStyleConfig.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="Default" />
|
||||
</state>
|
||||
</component>
|
||||
8
.idea/deploymentTargetSelector.xml
generated
8
.idea/deploymentTargetSelector.xml
generated
@@ -4,6 +4,14 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="wear">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2024-09-04T15:12:51.439753961Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/isp/.config/.android/avd/Wear_OS_Large_Round_API_34.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
<SelectionState runConfigName="mobile">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
|
||||
1
.idea/misc.xml
generated
1
.idea/misc.xml
generated
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "8.5.2"
|
||||
agp = "8.6.0"
|
||||
kotlin = "2.0.0"
|
||||
coreKtx = "1.13.1"
|
||||
junit = "4.13.2"
|
||||
@@ -18,6 +18,8 @@ activityCompose = "1.9.1"
|
||||
coreSplashscreen = "1.0.1"
|
||||
composeNavigation = "1.4.0-rc01"
|
||||
media3Common = "1.4.0"
|
||||
composeMaterial3 = "1.0.0-alpha23"
|
||||
workRuntimeKtx = "2.9.1"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -43,6 +45,8 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
|
||||
androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "coreSplashscreen" }
|
||||
androidx-compose-navigation = { group = "androidx.wear.compose", name = "compose-navigation", version.ref = "composeNavigation" }
|
||||
androidx-media3-common = { group = "androidx.media3", name = "media3-common", version.ref = "media3Common" }
|
||||
androidx-compose-material3 = { group = "androidx.wear.compose", name = "compose-material3", version.ref = "composeMaterial3" }
|
||||
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
@@ -53,7 +53,7 @@ dependencies {
|
||||
implementation("com.google.android.horologist:horologist-compose-material:0.6.8")
|
||||
implementation("com.google.android.horologist:horologist-media-ui:0.6.8")
|
||||
implementation("com.google.android.horologist:horologist-media-data:0.6.8")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.8.1")
|
||||
implementation("androidx.media3:media3-exoplayer:1.4.0")
|
||||
implementation(libs.play.services.wearable)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
@@ -67,7 +67,8 @@ dependencies {
|
||||
implementation(libs.androidx.core.splashscreen)
|
||||
implementation(libs.androidx.compose.navigation)
|
||||
implementation(libs.androidx.media3.common)
|
||||
// androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.work.runtime.ktx) // androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
// androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||
// debugImplementation(libs.androidx.ui.tooling)
|
||||
// debugImplementation(libs.androidx.ui.test.manifest)
|
||||
|
||||
@@ -1,110 +1,68 @@
|
||||
package com.birdsounds.identify.presentation
|
||||
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.birdsounds.identify.R
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.wear.compose.material.Button
|
||||
import androidx.wear.compose.material.CircularProgressIndicator
|
||||
import androidx.wear.compose.material.Icon
|
||||
import com.google.android.horologist.annotations.ExperimentalHorologistApi
|
||||
import androidx.wear.compose.material.CompactChip
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
|
||||
/**
|
||||
* The component responsible for drawing the main 3 controls, with their expanded and minimized
|
||||
* states.
|
||||
*
|
||||
* The state for this class is driven by a [ControlDashboardUiState], which contains a
|
||||
* [ControlDashboardButtonUiState] for each of the three buttons.
|
||||
*/
|
||||
@Composable
|
||||
fun ControlDashboard(
|
||||
controlDashboardUiState: ControlDashboardUiState,
|
||||
onMicClicked: () -> Unit,
|
||||
recordingProgress: Float,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
fun ControlDashboard(controlDashboardUiState: ControlDashboardUiState, onMicClicked: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
ControlDashboardButton(
|
||||
buttonState = controlDashboardUiState.micState,
|
||||
ControlDashboardButton(buttonState = controlDashboardUiState.micState,
|
||||
onClick = onMicClicked,
|
||||
imageVector = Icons.Filled.Mic,
|
||||
contentDescription = if (controlDashboardUiState.micState.expanded) {
|
||||
stringResource(id = R.string.stop_recording)
|
||||
labelText = if (controlDashboardUiState.micState.expanded) {
|
||||
"Stop"
|
||||
} else {
|
||||
stringResource(id = R.string.record)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
"Start"
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A single control dashboard button
|
||||
*/
|
||||
|
||||
@Composable
|
||||
private fun ControlDashboardButton(
|
||||
buttonState: ControlDashboardButtonUiState,
|
||||
private fun ControlDashboardButton(buttonState: ControlDashboardButtonUiState,
|
||||
onClick: () -> Unit,
|
||||
imageVector: ImageVector,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
enabled = buttonState.enabled && buttonState.visible,
|
||||
onClick = onClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription
|
||||
labelText: String,
|
||||
modifier: Modifier = Modifier) {
|
||||
CompactChip(
|
||||
modifier = Modifier,
|
||||
onClick = onClick ,
|
||||
enabled = true,
|
||||
contentPadding = PaddingValues(5.dp, 1.dp, 5.dp, 1.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
label = {
|
||||
Text(
|
||||
text = labelText
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The state for a single [ControlDashboardButton].
|
||||
*/
|
||||
data class ControlDashboardButtonUiState(
|
||||
val expanded: Boolean,
|
||||
val enabled: Boolean,
|
||||
val visible: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* The state for a [ControlDashboard].
|
||||
*/
|
||||
data class ControlDashboardUiState(
|
||||
val micState: ControlDashboardButtonUiState,
|
||||
val playState: ControlDashboardButtonUiState
|
||||
) {
|
||||
init {
|
||||
// Check that at most one of the buttons is expanded
|
||||
require(
|
||||
listOf(
|
||||
micState.expanded,
|
||||
playState.expanded
|
||||
).count { it } <= 1
|
||||
)
|
||||
|
||||
// Button(modifier = modifier, enabled = buttonState.enabled && buttonState.visible, onClick = onClick) {
|
||||
// Text(contentDescription);
|
||||
//// Icon(imageVector = imageVector, contentDescription = contentDescription)
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
data class ControlDashboardButtonUiState(val expanded: Boolean, val enabled: Boolean, val visible: Boolean)
|
||||
|
||||
|
||||
data class ControlDashboardUiState(val micState: ControlDashboardButtonUiState) {
|
||||
init { // Check that at most one of the buttons is expanded
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,74 +13,69 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.ManagedActivityResultLauncher
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.google.android.horologist.audio.ui.VolumeViewModel
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.google.android.horologist.compose.layout.AppScaffold
|
||||
import com.google.android.horologist.compose.layout.ScreenScaffold
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.wear.compose.material.MaterialTheme
|
||||
import androidx.wear.compose.material.Text
|
||||
import androidx.wear.compose.material.TimeText
|
||||
import androidx.wear.compose.material.dialog.Confirmation
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.wear.compose.navigation.SwipeDismissableNavHost
|
||||
import androidx.wear.compose.navigation.composable
|
||||
import androidx.wear.compose.navigation.rememberSwipeDismissableNavController
|
||||
import androidx.wear.tooling.preview.devices.WearDevices
|
||||
import com.birdsounds.identify.R
|
||||
import com.birdsounds.identify.presentation.theme.IdentifyTheme
|
||||
import com.google.android.horologist.annotations.ExperimentalHorologistApi
|
||||
import com.google.android.horologist.audio.ui.VolumeScreen
|
||||
import com.google.android.horologist.compose.material.AlertContent
|
||||
import com.google.android.horologist.audio.ui.VolumeViewModel
|
||||
import com.google.android.horologist.compose.layout.AppScaffold
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
val flow_stream = MutableStateFlow<String>("")
|
||||
|
||||
val Any.TAG: String
|
||||
get() {
|
||||
val tag = javaClass.simpleName
|
||||
return if (tag.length <= 23) tag else tag.substring(0, 23)
|
||||
}
|
||||
|
||||
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
setTheme(android.R.style.Theme_DeviceDefault)
|
||||
|
||||
setContent {
|
||||
WearApp("Android")
|
||||
WearApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHorologistApi::class)
|
||||
@Composable
|
||||
fun WearApp(greetingName: String) {
|
||||
fun WearApp() {
|
||||
|
||||
IdentifyTheme {
|
||||
lateinit var requestPermissionLauncher: ManagedActivityResultLauncher<String, Boolean>
|
||||
|
||||
// val job = launch {
|
||||
// flow_stream.collect {
|
||||
// print("$it ")
|
||||
// }
|
||||
// }
|
||||
|
||||
val context = LocalContext.current
|
||||
val activity = context.findActivity()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
val volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory)
|
||||
|
||||
val navController = rememberSwipeDismissableNavController()
|
||||
|
||||
val mainState = remember(activity) {
|
||||
@@ -93,25 +88,20 @@ fun WearApp(greetingName: String) {
|
||||
}
|
||||
|
||||
requestPermissionLauncher = rememberLauncherForActivityResult(RequestPermission()) {
|
||||
// We ignore the direct result here, since we're going to check anyway.
|
||||
scope.launch {
|
||||
mainState.permissionResultReturned()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
|
||||
AppScaffold {
|
||||
// Notify the state holder whenever we become stopped to reset the state
|
||||
DisposableEffect(mainState, scope, lifecycleOwner) {
|
||||
val lifecycleObserver = object : DefaultLifecycleObserver {
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
super.onStop(owner)
|
||||
scope.launch { mainState.onStopped() }
|
||||
}
|
||||
}
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(lifecycleObserver)
|
||||
|
||||
onDispose {
|
||||
@@ -120,63 +110,19 @@ fun WearApp(greetingName: String) {
|
||||
}
|
||||
SwipeDismissableNavHost(navController = navController, startDestination = "speaker") {
|
||||
composable("speaker") {
|
||||
SpeakerRecordingScreen(
|
||||
playbackState = mainState.playbackState,
|
||||
StartRecordingScreen(
|
||||
appState = mainState.appState,
|
||||
isPermissionDenied = mainState.isPermissionDenied,
|
||||
recordingProgress = mainState.recordingProgress,
|
||||
onMicClicked = {
|
||||
scope.launch {
|
||||
mainState.onMicClicked()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (mainState.showPermissionRationale) {
|
||||
AlertContent(
|
||||
onOk = {
|
||||
requestPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||
mainState.showPermissionRationale = false
|
||||
},
|
||||
onCancel = {
|
||||
mainState.showPermissionRationale = false
|
||||
},
|
||||
title = stringResource(
|
||||
id = R.string.rationale_for_microphone_permission
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (mainState.showSpeakerNotSupported) {
|
||||
Confirmation(
|
||||
onTimeout = { mainState.showSpeakerNotSupported = false }
|
||||
) {
|
||||
Text(text = stringResource(id = R.string.no_speaker_supported))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Greeting(greetingName: String) {
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.primary,
|
||||
text = stringResource(R.string.hello_world, greetingName)
|
||||
)
|
||||
}
|
||||
|
||||
@Preview(device = WearDevices.SMALL_ROUND, showSystemUi = true)
|
||||
@Composable
|
||||
fun DefaultPreview() {
|
||||
WearApp("Preview Android")
|
||||
}
|
||||
|
||||
tailrec fun Context.findActivity(): Activity =
|
||||
|
||||
@@ -3,185 +3,105 @@ package com.birdsounds.identify.presentation
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioDeviceInfo
|
||||
import android.media.AudioManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.compose.foundation.MutatorMutex
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import java.time.Duration
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.time.Duration
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class MainState(
|
||||
private val activity: Activity,
|
||||
private val requestPermission: () -> Unit
|
||||
) {
|
||||
/**
|
||||
* The [MutatorMutex] that guards the playback state of the app.
|
||||
*
|
||||
* Due to being a [MutatorMutex], this automatically handles cleanup of any ongoing asynchronous
|
||||
* work, like playing music or recording, ensuring that only one operation is occurring at a
|
||||
* time.
|
||||
*
|
||||
* For example, if the user is currently recording, and they hit the mic button again, the
|
||||
* second [onMicClicked] will cancel the previous [onMicClicked] that was doing the recording,
|
||||
* waiting for everything to be cleaned up, before running its own code.
|
||||
*/
|
||||
class MainState(private val activity: Activity, private val requestPermission: () -> Unit) {
|
||||
private val playbackStateMutatorMutex = MutatorMutex()
|
||||
|
||||
/**
|
||||
* The primary playback state.
|
||||
*/
|
||||
var playbackState by mutableStateOf<PlaybackState>(PlaybackState.Ready)
|
||||
var appState by mutableStateOf<AppState>(AppState.Ready)
|
||||
private set
|
||||
|
||||
/**
|
||||
* The progress of an ongoing recording.
|
||||
*
|
||||
* Note that this value can be read even when recording is not occurring, in which case it
|
||||
* corresponds to the last known value of recording progress (or 0), where that value is useful
|
||||
* for animations.
|
||||
*/
|
||||
var recordingProgress by mutableStateOf(0f)
|
||||
private set
|
||||
|
||||
/**
|
||||
* `true` if we know the user has denied the record audio permission.
|
||||
*/
|
||||
var isPermissionDenied by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
/**
|
||||
* `true` if we the permission rationale should be shown.
|
||||
*/
|
||||
var showPermissionRationale by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* `true` if we the speaker not supported rationale should be shown.
|
||||
*/
|
||||
var showSpeakerNotSupported by mutableStateOf(false)
|
||||
|
||||
/**
|
||||
* The [SoundRecorder] for recording and playing audio captured on-device.
|
||||
*/
|
||||
private val soundRecorder = SoundRecorder(activity, "audiorecord.opus")
|
||||
|
||||
suspend fun onStopped() {
|
||||
playbackStateMutatorMutex.mutate {
|
||||
playbackState = PlaybackState.Ready
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onMicClicked() {
|
||||
playbackStateMutatorMutex.mutate {
|
||||
when (playbackState) {
|
||||
is PlaybackState.Ready,
|
||||
PlaybackState.PlayingVoice ->
|
||||
// If we weren't recording, check our permission to start recording.
|
||||
when {
|
||||
ContextCompat.checkSelfPermission(
|
||||
activity,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED -> {
|
||||
// We have the permission, we can start recording now
|
||||
playbackState = PlaybackState.Recording
|
||||
record(
|
||||
soundRecorder = soundRecorder,
|
||||
setProgress = { progress ->
|
||||
recordingProgress = progress
|
||||
}
|
||||
)
|
||||
playbackState = PlaybackState.Ready
|
||||
}
|
||||
activity.shouldShowRequestPermissionRationale(
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) -> {
|
||||
// If we should show the rationale prior to requesting the permission,
|
||||
// send that event
|
||||
showPermissionRationale = true
|
||||
playbackState = PlaybackState.Ready
|
||||
when (appState) {
|
||||
is AppState.Ready -> when {
|
||||
(ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) -> {
|
||||
Log.e(TAG, "Permissions granted, continuing to record");
|
||||
appState = AppState.Recording
|
||||
record(soundRecorder = soundRecorder, setProgress = { progress -> recordingProgress = progress })
|
||||
}
|
||||
|
||||
else -> {
|
||||
// Request the permission
|
||||
Log.e(TAG, "Requesting permissions");
|
||||
requestPermission()
|
||||
playbackState = PlaybackState.Ready
|
||||
}
|
||||
}
|
||||
// If we were already recording, transition back to ready
|
||||
PlaybackState.Recording -> {
|
||||
playbackState = PlaybackState.Ready
|
||||
|
||||
AppState.Recording -> {
|
||||
appState = AppState.Ready
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun permissionResultReturned() {
|
||||
playbackStateMutatorMutex.mutate {
|
||||
// Check if the user granted the permission
|
||||
if (
|
||||
ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
// The user granted the permission, continue on to start recording
|
||||
playbackState = PlaybackState.Recording
|
||||
record(
|
||||
soundRecorder = soundRecorder,
|
||||
setProgress = { progress ->
|
||||
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
|
||||
appState = AppState.Recording
|
||||
record(soundRecorder = soundRecorder, setProgress = { progress ->
|
||||
recordingProgress = progress
|
||||
}
|
||||
)
|
||||
playbackState = PlaybackState.Ready
|
||||
})
|
||||
appState = AppState.Ready
|
||||
} else {
|
||||
// We have confirmation now that the user denied the permission
|
||||
isPermissionDenied = true
|
||||
playbackState = PlaybackState.Ready
|
||||
appState = AppState.Ready
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The three playback states of the application.
|
||||
*/
|
||||
sealed class PlaybackState {
|
||||
object Ready : PlaybackState()
|
||||
object PlayingVoice : PlaybackState()
|
||||
object Recording : PlaybackState()
|
||||
sealed class AppState {
|
||||
object Ready : AppState()
|
||||
object Recording : AppState()
|
||||
}
|
||||
|
||||
/**
|
||||
* A helper function to record, updating the progress state while recording.
|
||||
*
|
||||
* This requires the [Manifest.permission.RECORD_AUDIO] permission to run.
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
private suspend fun record(
|
||||
soundRecorder: SoundRecorder,
|
||||
private suspend fun record(soundRecorder: SoundRecorder,
|
||||
setProgress: (progress: Float) -> Unit,
|
||||
maxRecordingDuration: Duration = Duration.ofSeconds(10),
|
||||
numberTicks: Int = 10
|
||||
) {
|
||||
coroutineScope {
|
||||
// Kick off a parallel job to record
|
||||
val recordingJob = launch { soundRecorder.record() }
|
||||
numberTicks: Int = 10) {
|
||||
|
||||
val delayPerTickMs = maxRecordingDuration.toMillis() / numberTicks
|
||||
val startTime = System.currentTimeMillis()
|
||||
coroutineScope { // Kick off a parallel job to
|
||||
|
||||
repeat(numberTicks) { index ->
|
||||
setProgress(index.toFloat() / numberTicks)
|
||||
delay(startTime + delayPerTickMs * (index + 1) - System.currentTimeMillis())
|
||||
}
|
||||
// Update the progress to be complete
|
||||
setProgress(1f)
|
||||
|
||||
// Stop recording
|
||||
recordingJob.cancel()
|
||||
Log.e(TAG, "Mock recording"); // val recordingJob = launch { soundRecorder.record() }
|
||||
val ByteFlow: Flow<String> = flow{
|
||||
for (i in 1..3) {
|
||||
var string_send = LocalDateTime.now().toString()
|
||||
emit(string_send);
|
||||
delay(250);
|
||||
Log.e(TAG, "Emitting " + string_send)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// val delayPerTickMs = maxRecordingDuration.toMillis() / numberTicks
|
||||
// val startTime = System.currentTimeMillis()
|
||||
//
|
||||
// repeat(numberTicks) { index ->
|
||||
// setProgress(index.toFloat() / numberTicks)
|
||||
// delay(startTime + delayPerTickMs * (index + 1) - System.currentTimeMillis())
|
||||
// } // Update the progress to be complete
|
||||
// setProgress(1f)
|
||||
//
|
||||
// // Stop recording
|
||||
// recordingJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.birdsounds.identify.presentation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
|
||||
class MessageSendRecv(private val context: Context)
|
||||
{
|
||||
//MainState.
|
||||
}
|
||||
@@ -3,11 +3,17 @@ package com.birdsounds.identify.presentation
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaRecorder
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.google.android.gms.wearable.Wearable
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.time.LocalDateTime
|
||||
|
||||
/**
|
||||
* A helper class to provide methods to record audio input from the MIC to the internal storage.
|
||||
@@ -17,27 +23,25 @@ class SoundRecorder(
|
||||
outputFileName: String
|
||||
) {
|
||||
private val audioFile = File(context.filesDir, outputFileName)
|
||||
|
||||
private var state = State.IDLE
|
||||
|
||||
private enum class State {
|
||||
IDLE, RECORDING
|
||||
}
|
||||
|
||||
/**
|
||||
* Records from the microphone.
|
||||
*
|
||||
* This method is cancellable, and cancelling it will stop recording.
|
||||
*/
|
||||
|
||||
|
||||
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
suspend fun record() {
|
||||
if (state != State.IDLE) {
|
||||
Log.w(TAG, "Requesting to start recording while state was not IDLE")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
val mediaRecorder = MediaRecorder().apply {
|
||||
setAudioSource(MediaRecorder.AudioSource.MIC)
|
||||
setOutputFormat(MediaRecorder.OutputFormat.OGG)
|
||||
@@ -58,7 +62,7 @@ class SoundRecorder(
|
||||
|
||||
mediaRecorder.prepare()
|
||||
mediaRecorder.start()
|
||||
|
||||
Log.e("com.birdsounds.identify","Hey I'm recording")
|
||||
state = State.RECORDING
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package com.birdsounds.identify.presentation
|
||||
|
||||
import androidx.activity.compose.ReportDrawnAfter
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import com.google.android.horologist.annotations.ExperimentalHorologistApi
|
||||
import com.google.android.horologist.audio.ui.VolumeViewModel
|
||||
import com.google.android.horologist.audio.ui.components.actions.SetVolumeButton
|
||||
import com.google.android.horologist.media.ui.components.PodcastControlButtons
|
||||
import com.google.android.horologist.media.ui.screens.player.DefaultMediaInfoDisplay
|
||||
import com.google.android.horologist.media.ui.screens.player.PlayerScreen
|
||||
import com.google.android.horologist.media.ui.state.PlayerUiController
|
||||
import com.google.android.horologist.media.ui.state.PlayerUiState
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
@OptIn(ExperimentalHorologistApi::class)
|
||||
@Composable
|
||||
fun SpeakerPlayerScreen(
|
||||
onVolumeClick: () -> Unit,
|
||||
volumeViewModel: VolumeViewModel = viewModel(factory = VolumeViewModel.Factory),
|
||||
playerViewModel: SpeakerPlayerViewModel = viewModel(factory = SpeakerPlayerViewModel.Factory),
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val volumeUiState by volumeViewModel.volumeUiState.collectAsStateWithLifecycle()
|
||||
|
||||
PlayerScreen(
|
||||
modifier = modifier,
|
||||
background = {},
|
||||
playerViewModel = playerViewModel,
|
||||
volumeViewModel = volumeViewModel,
|
||||
mediaDisplay = { playerUiState ->
|
||||
DefaultMediaInfoDisplay(playerUiState)
|
||||
},
|
||||
buttons = { state ->
|
||||
SetVolumeButton(
|
||||
volumeUiState = volumeUiState,
|
||||
onVolumeClick = onVolumeClick,
|
||||
enabled = state.connected && state.media != null
|
||||
)
|
||||
},
|
||||
controlButtons = { playerUiController, playerUiState ->
|
||||
PlayerScreenPodcastControlButtons(playerUiController, playerUiState)
|
||||
}
|
||||
)
|
||||
|
||||
ReportDrawnAfter {
|
||||
playerViewModel.playerState.filterNotNull().first()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalHorologistApi::class)
|
||||
@Composable
|
||||
fun PlayerScreenPodcastControlButtons(
|
||||
playerUiController: PlayerUiController,
|
||||
playerUiState: PlayerUiState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
PodcastControlButtons(
|
||||
modifier = modifier,
|
||||
playerController = playerUiController,
|
||||
playerUiState = playerUiState
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package com.birdsounds.identify.presentation
|
||||
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import com.google.android.horologist.annotations.ExperimentalHorologistApi
|
||||
import com.google.android.horologist.media.data.repository.PlayerRepositoryImpl
|
||||
import com.google.android.horologist.media.model.Media
|
||||
import com.google.android.horologist.media.ui.state.PlayerViewModel
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@UnstableApi
|
||||
@OptIn(ExperimentalHorologistApi::class)
|
||||
class SpeakerPlayerViewModel(
|
||||
playerRepository: PlayerRepositoryImpl,
|
||||
player: Player,
|
||||
audioFile: File,
|
||||
audioFileUri: String
|
||||
) : PlayerViewModel(playerRepository) {
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
playerRepository.connect(player) {}
|
||||
if (audioFile.exists()) {
|
||||
val media = Media(
|
||||
id = "",
|
||||
uri = audioFileUri,
|
||||
title = "Recorded audio",
|
||||
artist = ""
|
||||
)
|
||||
playerRepository.setMedia(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val playerState = playerRepository.player
|
||||
|
||||
@ExperimentalHorologistApi
|
||||
public companion object {
|
||||
private const val TAG = "SpeakerPlayerViewModel"
|
||||
|
||||
public val Factory: ViewModelProvider.Factory = viewModelFactory {
|
||||
initializer {
|
||||
val application = this[APPLICATION_KEY]!!
|
||||
|
||||
val outputFileName = "audiorecord.opus"
|
||||
val audioFile = File(application.filesDir, outputFileName)
|
||||
val audioFileUri = application.filesDir.path + "/" + outputFileName
|
||||
|
||||
val player = ExoPlayer.Builder(application)
|
||||
.setSeekForwardIncrementMs(5000L)
|
||||
.setSeekBackIncrementMs(5000L)
|
||||
.build()
|
||||
|
||||
SpeakerPlayerViewModel(
|
||||
PlayerRepositoryImpl(),
|
||||
player,
|
||||
audioFile,
|
||||
audioFileUri
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package com.birdsounds.identify.presentation
|
||||
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
|
||||
import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales
|
||||
import com.google.android.horologist.compose.layout.ScreenScaffold
|
||||
|
||||
/**
|
||||
* The composable responsible for displaying the main UI.
|
||||
*
|
||||
* This composable is stateless, and simply displays the state given to it.
|
||||
*/
|
||||
@Composable
|
||||
fun SpeakerRecordingScreen(
|
||||
playbackState: PlaybackState,
|
||||
isPermissionDenied: Boolean,
|
||||
recordingProgress: Float,
|
||||
onMicClicked: () -> Unit
|
||||
) {
|
||||
ScreenScaffold {
|
||||
// Determine the control dashboard state.
|
||||
// This converts the main app state into a control dashboard state for rendering
|
||||
val controlDashboardUiState = computeControlDashboardUiState(
|
||||
playbackState = playbackState,
|
||||
isPermissionDenied = isPermissionDenied
|
||||
)
|
||||
ControlDashboard(
|
||||
controlDashboardUiState = controlDashboardUiState,
|
||||
onMicClicked = onMicClicked,
|
||||
recordingProgress = recordingProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeControlDashboardUiState(
|
||||
playbackState: PlaybackState,
|
||||
isPermissionDenied: Boolean
|
||||
): ControlDashboardUiState =
|
||||
when (playbackState) {
|
||||
PlaybackState.PlayingVoice -> ControlDashboardUiState(
|
||||
micState = ControlDashboardButtonUiState(
|
||||
expanded = false,
|
||||
enabled = false,
|
||||
visible = false
|
||||
),
|
||||
playState = ControlDashboardButtonUiState(
|
||||
expanded = true,
|
||||
enabled = true,
|
||||
visible = true
|
||||
)
|
||||
)
|
||||
PlaybackState.Ready -> ControlDashboardUiState(
|
||||
micState = ControlDashboardButtonUiState(
|
||||
expanded = false,
|
||||
enabled = !isPermissionDenied,
|
||||
visible = true
|
||||
),
|
||||
playState = ControlDashboardButtonUiState(
|
||||
expanded = false,
|
||||
enabled = true,
|
||||
visible = true
|
||||
)
|
||||
)
|
||||
PlaybackState.Recording -> ControlDashboardUiState(
|
||||
micState = ControlDashboardButtonUiState(
|
||||
expanded = true,
|
||||
enabled = true,
|
||||
visible = true
|
||||
),
|
||||
playState = ControlDashboardButtonUiState(
|
||||
expanded = false,
|
||||
enabled = false,
|
||||
visible = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private class PlaybackStatePreviewProvider : CollectionPreviewParameterProvider<PlaybackState>(
|
||||
listOf(
|
||||
PlaybackState.Ready,
|
||||
PlaybackState.Recording,
|
||||
PlaybackState.PlayingVoice
|
||||
)
|
||||
)
|
||||
|
||||
@WearPreviewDevices
|
||||
@WearPreviewFontScales
|
||||
@Composable
|
||||
fun SpeakerScreenPreview(
|
||||
@PreviewParameter(PlaybackStatePreviewProvider::class) playbackState: PlaybackState
|
||||
) {
|
||||
SpeakerRecordingScreen(
|
||||
playbackState = playbackState,
|
||||
isPermissionDenied = true,
|
||||
recordingProgress = 0.25f,
|
||||
onMicClicked = {}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.birdsounds.identify.presentation
|
||||
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
||||
import androidx.wear.compose.ui.tooling.preview.WearPreviewDevices
|
||||
import androidx.wear.compose.ui.tooling.preview.WearPreviewFontScales
|
||||
import com.google.android.horologist.compose.layout.ScreenScaffold
|
||||
|
||||
@Composable
|
||||
fun StartRecordingScreen(
|
||||
appState: AppState,
|
||||
isPermissionDenied: Boolean,
|
||||
onMicClicked: () -> Unit
|
||||
) {
|
||||
ScreenScaffold {
|
||||
val controlDashboardUiState = computeControlDashboardUiState(
|
||||
appState = appState,
|
||||
isPermissionDenied = isPermissionDenied
|
||||
)
|
||||
ControlDashboard(
|
||||
controlDashboardUiState = controlDashboardUiState,
|
||||
onMicClicked = onMicClicked
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeControlDashboardUiState(
|
||||
appState: AppState,
|
||||
isPermissionDenied: Boolean
|
||||
): ControlDashboardUiState =
|
||||
when (appState) {
|
||||
AppState.Ready -> ControlDashboardUiState(
|
||||
micState = ControlDashboardButtonUiState(
|
||||
expanded = false,
|
||||
enabled = !isPermissionDenied,
|
||||
visible = true
|
||||
),
|
||||
)
|
||||
AppState.Recording -> ControlDashboardUiState(
|
||||
micState = ControlDashboardButtonUiState(
|
||||
expanded = true,
|
||||
enabled = true,
|
||||
visible = true
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class PlaybackStatePreviewProvider : CollectionPreviewParameterProvider<AppState>(
|
||||
listOf(
|
||||
AppState.Recording,
|
||||
AppState.Ready
|
||||
)
|
||||
)
|
||||
|
||||
@WearPreviewDevices
|
||||
@WearPreviewFontScales
|
||||
@Composable
|
||||
fun SpeakerScreenPreview(
|
||||
@PreviewParameter(PlaybackStatePreviewProvider::class) appState: AppState
|
||||
) {
|
||||
StartRecordingScreen(
|
||||
appState = appState,
|
||||
isPermissionDenied = true,
|
||||
onMicClicked = {}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user