feat: add camera capture during voice mode conversations
Allow attaching camera images to messages sent via voice mode. A camera toggle button in VoiceModeOverlay shows/hides a live CameraX preview, and automatically captures a frame when speech recognition completes in the SENDING phase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
34df2490f5
commit
643aa0d47c
10 changed files with 193 additions and 7 deletions
|
|
@ -24,6 +24,7 @@ import io.picoclaw.android.feature.chat.ChatViewModel
|
||||||
import io.picoclaw.android.feature.chat.SettingsViewModel
|
import io.picoclaw.android.feature.chat.SettingsViewModel
|
||||||
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
|
import io.picoclaw.android.feature.chat.voice.SpeechRecognizerWrapper
|
||||||
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
|
import io.picoclaw.android.feature.chat.voice.TextToSpeechWrapper
|
||||||
|
import io.picoclaw.android.feature.chat.voice.CameraCaptureManager
|
||||||
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
|
import io.picoclaw.android.feature.chat.voice.VoiceModeManager
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -92,7 +93,8 @@ val appModule = module {
|
||||||
// Voice
|
// Voice
|
||||||
factory { SpeechRecognizerWrapper(androidContext()) }
|
factory { SpeechRecognizerWrapper(androidContext()) }
|
||||||
single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) }
|
single { TextToSpeechWrapper(androidContext(), get<TtsSettingsRepository>().ttsConfig) }
|
||||||
single { VoiceModeManager(get(), get(), get(), get(), get()) }
|
single { CameraCaptureManager(androidContext()) }
|
||||||
|
single { VoiceModeManager(get(), get(), get(), get(), get(), get()) }
|
||||||
|
|
||||||
// ViewModel
|
// ViewModel
|
||||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,11 @@ dependencies {
|
||||||
|
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|
||||||
|
implementation(libs.camerax.core)
|
||||||
|
implementation(libs.camerax.camera2)
|
||||||
|
implementation(libs.camerax.lifecycle)
|
||||||
|
implementation(libs.camerax.view)
|
||||||
|
|
||||||
implementation(libs.markdown.renderer.m3)
|
implementation(libs.markdown.renderer.m3)
|
||||||
|
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,5 @@ sealed interface ChatEvent {
|
||||||
data object OnVoiceModeStart : ChatEvent
|
data object OnVoiceModeStart : ChatEvent
|
||||||
data object OnVoiceModeStop : ChatEvent
|
data object OnVoiceModeStop : ChatEvent
|
||||||
data object OnVoiceModeInterrupt : ChatEvent
|
data object OnVoiceModeInterrupt : ChatEvent
|
||||||
|
data object OnVoiceCameraToggle : ChatEvent
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,9 @@ class ChatViewModel(
|
||||||
is ChatEvent.OnVoiceModeInterrupt -> {
|
is ChatEvent.OnVoiceModeInterrupt -> {
|
||||||
voiceModeManager.interrupt()
|
voiceModeManager.interrupt()
|
||||||
}
|
}
|
||||||
|
is ChatEvent.OnVoiceCameraToggle -> {
|
||||||
|
voiceModeManager.toggleCamera()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,10 @@ import io.picoclaw.android.feature.chat.component.ImagePreviewRow
|
||||||
import io.picoclaw.android.feature.chat.component.MessageInput
|
import io.picoclaw.android.feature.chat.component.MessageInput
|
||||||
import io.picoclaw.android.feature.chat.component.MessageList
|
import io.picoclaw.android.feature.chat.component.MessageList
|
||||||
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
import io.picoclaw.android.feature.chat.component.StatusIndicator
|
||||||
|
import io.picoclaw.android.feature.chat.voice.CameraCaptureManager
|
||||||
import io.picoclaw.android.feature.chat.voice.VoiceModeOverlay
|
import io.picoclaw.android.feature.chat.voice.VoiceModeOverlay
|
||||||
import org.koin.androidx.compose.koinViewModel
|
import org.koin.androidx.compose.koinViewModel
|
||||||
|
import org.koin.compose.koinInject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -63,9 +65,32 @@ fun ChatScreen(
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val cameraCaptureManager: CameraCaptureManager = koinInject()
|
||||||
|
|
||||||
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
var cameraImageUri by remember { mutableStateOf<Uri?>(null) }
|
||||||
|
|
||||||
|
val voiceCameraPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) {
|
||||||
|
viewModel.onEvent(ChatEvent.OnVoiceCameraToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val onVoiceCameraToggle = {
|
||||||
|
if (uiState.voiceModeState.isCameraActive) {
|
||||||
|
viewModel.onEvent(ChatEvent.OnVoiceCameraToggle)
|
||||||
|
} else {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
|
||||||
|
== PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
viewModel.onEvent(ChatEvent.OnVoiceCameraToggle)
|
||||||
|
} else {
|
||||||
|
voiceCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val cameraLauncher = rememberLauncherForActivityResult(
|
val cameraLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.TakePicture()
|
contract = ActivityResultContracts.TakePicture()
|
||||||
) { success ->
|
) { success ->
|
||||||
|
|
@ -263,7 +288,9 @@ fun ChatScreen(
|
||||||
VoiceModeOverlay(
|
VoiceModeOverlay(
|
||||||
state = uiState.voiceModeState,
|
state = uiState.voiceModeState,
|
||||||
onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) },
|
onClose = { viewModel.onEvent(ChatEvent.OnVoiceModeStop) },
|
||||||
onInterrupt = { viewModel.onEvent(ChatEvent.OnVoiceModeInterrupt) }
|
onInterrupt = { viewModel.onEvent(ChatEvent.OnVoiceModeInterrupt) },
|
||||||
|
onCameraToggle = onVoiceCameraToggle,
|
||||||
|
cameraCaptureManager = cameraCaptureManager
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.ImageCaptureException
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import io.picoclaw.android.core.domain.model.ImageAttachment
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import java.io.File
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
class CameraCaptureManager(private val context: Context) {
|
||||||
|
|
||||||
|
private var imageCapture: ImageCapture? = null
|
||||||
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
|
|
||||||
|
fun bind(lifecycleOwner: LifecycleOwner, previewView: PreviewView) {
|
||||||
|
val providerFuture = ProcessCameraProvider.getInstance(context)
|
||||||
|
providerFuture.addListener({
|
||||||
|
val provider = providerFuture.get()
|
||||||
|
cameraProvider = provider
|
||||||
|
|
||||||
|
val preview = Preview.Builder().build().also {
|
||||||
|
it.surfaceProvider = previewView.surfaceProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
val capture = ImageCapture.Builder()
|
||||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
|
||||||
|
.build()
|
||||||
|
imageCapture = capture
|
||||||
|
|
||||||
|
provider.unbindAll()
|
||||||
|
provider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||||
|
preview,
|
||||||
|
capture
|
||||||
|
)
|
||||||
|
}, ContextCompat.getMainExecutor(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbind() {
|
||||||
|
cameraProvider?.unbindAll()
|
||||||
|
cameraProvider = null
|
||||||
|
imageCapture = null
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun captureFrame(): ImageAttachment? {
|
||||||
|
val capture = imageCapture ?: return null
|
||||||
|
val imagesDir = File(context.cacheDir, "images").apply { mkdirs() }
|
||||||
|
val file = File(imagesDir, "voice_cam_${System.currentTimeMillis()}.jpg")
|
||||||
|
val outputOptions = ImageCapture.OutputFileOptions.Builder(file).build()
|
||||||
|
|
||||||
|
return suspendCancellableCoroutine { cont ->
|
||||||
|
capture.takePicture(
|
||||||
|
outputOptions,
|
||||||
|
ContextCompat.getMainExecutor(context),
|
||||||
|
object : ImageCapture.OnImageSavedCallback {
|
||||||
|
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
||||||
|
val uri = androidx.core.content.FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${context.packageName}.fileprovider",
|
||||||
|
file
|
||||||
|
)
|
||||||
|
cont.resume(ImageAttachment(uri = uri.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
cont.resume(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,8 @@ class VoiceModeManager(
|
||||||
private val ttsWrapper: TextToSpeechWrapper,
|
private val ttsWrapper: TextToSpeechWrapper,
|
||||||
private val sendMessage: SendMessageUseCase,
|
private val sendMessage: SendMessageUseCase,
|
||||||
private val observeMessages: ObserveMessagesUseCase,
|
private val observeMessages: ObserveMessagesUseCase,
|
||||||
private val observeStatus: ObserveStatusUseCase
|
private val observeStatus: ObserveStatusUseCase,
|
||||||
|
private val cameraCaptureManager: CameraCaptureManager
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(VoiceModeState())
|
private val _state = MutableStateFlow(VoiceModeState())
|
||||||
|
|
@ -37,11 +38,15 @@ class VoiceModeManager(
|
||||||
private var loopJob: Job? = null
|
private var loopJob: Job? = null
|
||||||
private var parentScope: CoroutineScope? = null
|
private var parentScope: CoroutineScope? = null
|
||||||
|
|
||||||
|
fun toggleCamera() {
|
||||||
|
_state.update { it.copy(isCameraActive = !it.isCameraActive) }
|
||||||
|
}
|
||||||
|
|
||||||
fun start(scope: CoroutineScope) {
|
fun start(scope: CoroutineScope) {
|
||||||
if (loopJob?.isActive == true) return
|
if (loopJob?.isActive == true) return
|
||||||
parentScope = scope
|
parentScope = scope
|
||||||
_state.update {
|
_state.update {
|
||||||
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING)
|
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive)
|
||||||
}
|
}
|
||||||
loopJob = scope.launch {
|
loopJob = scope.launch {
|
||||||
voiceLoop()
|
voiceLoop()
|
||||||
|
|
@ -53,6 +58,7 @@ class VoiceModeManager(
|
||||||
loopJob = null
|
loopJob = null
|
||||||
parentScope = null
|
parentScope = null
|
||||||
ttsWrapper.stop()
|
ttsWrapper.stop()
|
||||||
|
cameraCaptureManager.unbind()
|
||||||
_state.value = VoiceModeState()
|
_state.value = VoiceModeState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +71,7 @@ class VoiceModeManager(
|
||||||
loopJob = null
|
loopJob = null
|
||||||
|
|
||||||
_state.update {
|
_state.update {
|
||||||
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING)
|
VoiceModeState(isActive = true, phase = VoicePhase.LISTENING, isCameraActive = it.isCameraActive)
|
||||||
}
|
}
|
||||||
loopJob = scope.launch { voiceLoop() }
|
loopJob = scope.launch { voiceLoop() }
|
||||||
}
|
}
|
||||||
|
|
@ -119,7 +125,10 @@ class VoiceModeManager(
|
||||||
if (!text.isNullOrBlank()) {
|
if (!text.isNullOrBlank()) {
|
||||||
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) }
|
_state.update { it.copy(phase = VoicePhase.SENDING, recognizedText = text) }
|
||||||
try {
|
try {
|
||||||
sendMessage(text, inputMode = "voice")
|
val images = if (_state.value.isCameraActive) {
|
||||||
|
listOfNotNull(cameraCaptureManager.captureFrame())
|
||||||
|
} else emptyList()
|
||||||
|
sendMessage(text, images = images, inputMode = "voice")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました")
|
it.copy(phase = VoicePhase.ERROR, errorMessage = "送信に失敗しました")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package io.picoclaw.android.feature.chat.voice
|
package io.picoclaw.android.feature.chat.voice
|
||||||
|
|
||||||
|
import androidx.camera.view.PreviewView
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
|
|
@ -14,6 +15,8 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
|
@ -21,16 +24,20 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.picoclaw.android.core.domain.model.VoicePhase
|
import io.picoclaw.android.core.domain.model.VoicePhase
|
||||||
import io.picoclaw.android.core.ui.theme.DeepBlack
|
import io.picoclaw.android.core.ui.theme.DeepBlack
|
||||||
import io.picoclaw.android.core.ui.theme.GradientCyan
|
import io.picoclaw.android.core.ui.theme.GradientCyan
|
||||||
|
|
@ -44,6 +51,8 @@ fun VoiceModeOverlay(
|
||||||
state: VoiceModeState,
|
state: VoiceModeState,
|
||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onInterrupt: () -> Unit,
|
onInterrupt: () -> Unit,
|
||||||
|
onCameraToggle: () -> Unit,
|
||||||
|
cameraCaptureManager: CameraCaptureManager,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
|
|
@ -154,6 +163,48 @@ fun VoiceModeOverlay(
|
||||||
textAlign = TextAlign.Center
|
textAlign = TextAlign.Center
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
AnimatedVisibility(visible = state.isCameraActive) {
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(160.dp)
|
||||||
|
.height(120.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
PreviewView(ctx).also { preview ->
|
||||||
|
cameraCaptureManager.bind(lifecycleOwner, preview)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
cameraCaptureManager.unbind()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
IconButton(onClick = onCameraToggle) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
if (state.isCameraActive) LucideR.drawable.lucide_ic_camera_off
|
||||||
|
else LucideR.drawable.lucide_ic_camera
|
||||||
|
),
|
||||||
|
contentDescription = if (state.isCameraActive) "Turn off camera" else "Turn on camera",
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
|
tint = if (state.isCameraActive) GradientCyan else TextSecondary
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,6 @@ data class VoiceModeState(
|
||||||
val responseText: String = "",
|
val responseText: String = "",
|
||||||
val statusText: String? = null,
|
val statusText: String? = null,
|
||||||
val errorMessage: String? = null,
|
val errorMessage: String? = null,
|
||||||
val amplitudeNormalized: Float = 0f
|
val amplitudeNormalized: Float = 0f,
|
||||||
|
val isCameraActive: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ coil = "3.1.0"
|
||||||
markdown-renderer = "0.39.0"
|
markdown-renderer = "0.39.0"
|
||||||
navigation = "2.9.0"
|
navigation = "2.9.0"
|
||||||
datastore = "1.1.7"
|
datastore = "1.1.7"
|
||||||
|
camerax = "1.5.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Compose
|
# Compose
|
||||||
|
|
@ -61,6 +62,12 @@ navigation-compose = { group = "androidx.navigation", name = "navigation-compose
|
||||||
# DataStore
|
# DataStore
|
||||||
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
|
||||||
|
# CameraX
|
||||||
|
camerax-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||||
|
camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||||
|
camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||||
|
camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||||
|
|
||||||
# Markdown
|
# Markdown
|
||||||
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
markdown-renderer-m3 = { group = "com.mikepenz", name = "multiplatform-markdown-renderer-m3", version.ref = "markdown-renderer" }
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue