diff --git a/design/api/current.api b/design/api/current.api index ccba476d..e432bca3 100644 --- a/design/api/current.api +++ b/design/api/current.api @@ -30,6 +30,8 @@ package com.urlaunched.android.design.resources.dimens { method public float getCornerRadiusNormalSpecial(); method public float getCornerRadiusSmall(); method public float getCornerRadiusTiny(); + method public float getIconSizeNormal(); + method public float getIconSizeNormalSpecial(); method public float getSpacingBig(); method public float getSpacingBigSpecial(); method public float getSpacingExtraLarge(); @@ -50,6 +52,8 @@ package com.urlaunched.android.design.resources.dimens { property public final float cornerRadiusNormalSpecial; property public final float cornerRadiusSmall; property public final float cornerRadiusTiny; + property public final float iconSizeNormal; + property public final float iconSizeNormalSpecial; property public final float spacingBig; property public final float spacingBigSpecial; property public final float spacingExtraLarge; @@ -83,6 +87,236 @@ package com.urlaunched.android.design.ui.bottomsheet { } +package com.urlaunched.android.design.ui.camera.delegate { + + public interface CameraDelegate { + method public void bindLifecycle(androidx.lifecycle.LifecycleOwner lifecycleOwner); + method public void bindPreviewView(androidx.camera.view.PreviewView previewView); + method public kotlinx.coroutines.flow.Flow getCameraSideEffect(); + method public kotlinx.coroutines.flow.StateFlow getUiCameraState(); + method public void onGalleryClick(); + method public void onMediaPick(java.util.List uris, kotlin.jvm.functions.Function1,kotlin.Unit> onHandleUris); + method public void onPause(); + method public void onPhotoCameraClick(); + method public void onShutterClick(kotlin.jvm.functions.Function1 onCaptureMedia); + method public void onVideoCameraClick(); + method public void toggleCameraType(); + method public void toggleFlash(); + method public void unbindLifecycle(); + property public abstract kotlinx.coroutines.flow.Flow cameraSideEffect; + property public abstract kotlinx.coroutines.flow.StateFlow uiCameraState; + } + + public final class CameraDelegateImpl extends com.urlaunched.android.design.ui.textfield.hashtags.Delegate implements com.urlaunched.android.design.ui.camera.delegate.CameraDelegate { + ctor public CameraDelegateImpl(kotlinx.coroutines.CoroutineDispatcher ioDispatcher, kotlinx.coroutines.CoroutineDispatcher mainDispatcher, androidx.camera.view.LifecycleCameraController lifecycleCameraController, com.urlaunched.android.design.ui.camera.util.VideoRecordingTempFileProvider videoRecordingTempFileProvider, com.urlaunched.android.design.ui.camera.util.PhotoTempFileProvider photoTempFileProvider, kotlinx.coroutines.CoroutineScope viewModelScope); + method public void bindLifecycle(androidx.lifecycle.LifecycleOwner lifecycleOwner); + method public void bindPreviewView(androidx.camera.view.PreviewView previewView); + method public kotlinx.coroutines.flow.Flow getCameraSideEffect(); + method public kotlinx.coroutines.flow.StateFlow getUiCameraState(); + method public void onGalleryClick(); + method public void onMediaPick(java.util.List uris, kotlin.jvm.functions.Function1,kotlin.Unit> onHandleUris); + method public void onPause(); + method public void onPhotoCameraClick(); + method @RequiresPermission(android.Manifest.permission.RECORD_AUDIO) public void onShutterClick(kotlin.jvm.functions.Function1 onCaptureMedia); + method public void onVideoCameraClick(); + method public void toggleCameraType(); + method public void toggleFlash(); + method public void unbindLifecycle(); + property public kotlinx.coroutines.flow.Flow cameraSideEffect; + property public kotlinx.coroutines.flow.StateFlow uiCameraState; + } + + public abstract static sealed class CameraDelegateImpl.CameraSideEffect { + } + + public static final class CameraDelegateImpl.CameraSideEffect.PickGalleryMedia extends com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect { + field public static final com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect.PickGalleryMedia INSTANCE; + } + + public static final class CameraDelegateImpl.CameraSideEffect.ShowCameraInitErrorSnackbar extends com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect { + field public static final com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect.ShowCameraInitErrorSnackbar INSTANCE; + } + + public static final class CameraDelegateImpl.CameraSideEffect.ShowSnackbar extends com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect { + ctor public CameraDelegateImpl.CameraSideEffect.ShowSnackbar(String message); + method public String component1(); + method public com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect.ShowSnackbar copy(String message); + method public String getMessage(); + property public final String message; + } + + public final class CameraDelegateState { + ctor public CameraDelegateState(com.urlaunched.android.design.ui.camera.delegate.CameraType cameraType, com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel recordingState, com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel cameraMode, boolean isFlashEnabled, boolean shouldRestoreStatusBarColors); + method public com.urlaunched.android.design.ui.camera.delegate.CameraType component1(); + method public com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel component2(); + method public com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel component3(); + method public boolean component4(); + method public boolean component5(); + method public com.urlaunched.android.design.ui.camera.delegate.CameraDelegateState copy(com.urlaunched.android.design.ui.camera.delegate.CameraType cameraType, com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel recordingState, com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel cameraMode, boolean isFlashEnabled, boolean shouldRestoreStatusBarColors); + method public com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel getCameraMode(); + method public com.urlaunched.android.design.ui.camera.delegate.CameraType getCameraType(); + method public com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel getRecordingState(); + method public boolean getShouldRestoreStatusBarColors(); + method public boolean isFlashEnabled(); + property public final com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel cameraMode; + property public final com.urlaunched.android.design.ui.camera.delegate.CameraType cameraType; + property public final boolean isFlashEnabled; + property public final com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel recordingState; + property public final boolean shouldRestoreStatusBarColors; + } + + public enum CameraType { + method public final androidx.camera.core.CameraSelector! getSelector(); + method public static com.urlaunched.android.design.ui.camera.delegate.CameraType valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.urlaunched.android.design.ui.camera.delegate.CameraType[] values(); + property public final androidx.camera.core.CameraSelector! selector; + enum_constant public static final com.urlaunched.android.design.ui.camera.delegate.CameraType BACK; + enum_constant public static final com.urlaunched.android.design.ui.camera.delegate.CameraType FRONT; + } + +} + +package com.urlaunched.android.design.ui.camera.model { + + public enum AppMediaType implements com.urlaunched.android.common.files.MediaType { + method public final java.util.List! getExtensions(); + method public java.util.List! getExtraMimeTypes(); + method public String! getMimeType(); + method public static com.urlaunched.android.design.ui.camera.model.AppMediaType valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.urlaunched.android.design.ui.camera.model.AppMediaType[] values(); + property public final java.util.List! extensions; + property public java.util.List! extraMimeTypes; + property public String! mimeType; + enum_constant public static final com.urlaunched.android.design.ui.camera.model.AppMediaType IMAGE_PREVIEW; + enum_constant public static final com.urlaunched.android.design.ui.camera.model.AppMediaType VIDEO; + } + + public final class AppMediaTypeKt { + method public static com.urlaunched.android.design.ui.camera.model.AppMediaType? getMediaTypeForUri(android.net.Uri, android.content.Context context); + } + + public enum CameraModeTypePresentationModel { + method public static com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel valueOf(String value) throws java.lang.IllegalArgumentException, java.lang.NullPointerException; + method public static com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel[] values(); + enum_constant public static final com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel PHOTO; + enum_constant public static final com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel VIDEO; + } + + public abstract sealed class RecordingStatePresentationModel { + } + + public static final class RecordingStatePresentationModel.Recording extends com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel { + ctor public RecordingStatePresentationModel.Recording(long duration, long durationLimit); + method public long component1-UwyO8pc(); + method public long component2-UwyO8pc(); + method public com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel.Recording copy-QTBD994(long duration, long durationLimit); + method public long getDuration(); + method public long getDurationLimit(); + method public float getProgress(); + property public final long duration; + property public final long durationLimit; + property public final float progress; + } + + public static final class RecordingStatePresentationModel.Stopped extends com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel { + field public static final com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel.Stopped INSTANCE; + } + +} + +package com.urlaunched.android.design.ui.camera.ui { + + public final class CameraContainerColors { + ctor public CameraContainerColors(optional long backgroundColor, optional long surfaceColor, optional androidx.compose.ui.graphics.Brush progressBrush, optional long startRecordButtonColor, optional long recordingButtonColor); + method public long component1-0d7_KjU(); + method public long component2-0d7_KjU(); + method public androidx.compose.ui.graphics.Brush component3(); + method public long component4-0d7_KjU(); + method public long component5-0d7_KjU(); + method public com.urlaunched.android.design.ui.camera.ui.CameraContainerColors copy-w8f9n5g(long backgroundColor, long surfaceColor, androidx.compose.ui.graphics.Brush progressBrush, long startRecordButtonColor, long recordingButtonColor); + method public long getBackgroundColor(); + method public androidx.compose.ui.graphics.Brush getProgressBrush(); + method public long getRecordingButtonColor(); + method public long getStartRecordButtonColor(); + method public long getSurfaceColor(); + property public final long backgroundColor; + property public final androidx.compose.ui.graphics.Brush progressBrush; + property public final long recordingButtonColor; + property public final long startRecordButtonColor; + property public final long surfaceColor; + } + + public final class CameraContainerConfig { + ctor public CameraContainerConfig(optional com.urlaunched.android.design.ui.camera.ui.CameraContainerColors colors, optional float surfaceCornerRadius, optional float surfacePaddingBottom, optional float flashIconSize, optional float flashIconInnerPadding, optional float topButtonsSpacing, optional float controlPaddingHorizontal, optional float controlPaddingBottom); + method public com.urlaunched.android.design.ui.camera.ui.CameraContainerColors component1(); + method public float component2-D9Ej5fM(); + method public float component3-D9Ej5fM(); + method public float component4-D9Ej5fM(); + method public float component5-D9Ej5fM(); + method public float component6-D9Ej5fM(); + method public float component7-D9Ej5fM(); + method public float component8-D9Ej5fM(); + method public com.urlaunched.android.design.ui.camera.ui.CameraContainerConfig copy-a145CXI(com.urlaunched.android.design.ui.camera.ui.CameraContainerColors colors, float surfaceCornerRadius, float surfacePaddingBottom, float flashIconSize, float flashIconInnerPadding, float topButtonsSpacing, float controlPaddingHorizontal, float controlPaddingBottom); + method public com.urlaunched.android.design.ui.camera.ui.CameraContainerColors getColors(); + method public float getControlPaddingBottom(); + method public float getControlPaddingHorizontal(); + method public float getFlashIconInnerPadding(); + method public float getFlashIconSize(); + method public float getSurfaceCornerRadius(); + method public float getSurfacePaddingBottom(); + method public float getTopButtonsSpacing(); + property public final com.urlaunched.android.design.ui.camera.ui.CameraContainerColors colors; + property public final float controlPaddingBottom; + property public final float controlPaddingHorizontal; + property public final float flashIconInnerPadding; + property public final float flashIconSize; + property public final float surfaceCornerRadius; + property public final float surfacePaddingBottom; + property public final float topButtonsSpacing; + } + + public final class CameraContainerKt { + method @androidx.compose.runtime.Composable public static void CameraContainer(optional androidx.compose.ui.Modifier modifier, optional com.urlaunched.android.design.ui.camera.ui.CameraContainerConfig config, com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel recordingState, com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel cameraMode, boolean isFlashEnabled, boolean isGalleryEnabled, kotlin.jvm.functions.Function0 onShutterClick, kotlin.jvm.functions.Function1 onCameraPreviewViewAvailable, kotlin.jvm.functions.Function0 onVideoCameraClick, kotlin.jvm.functions.Function0 onPhotoCameraClick, kotlin.jvm.functions.Function0 onToggleCameraClick, kotlin.jvm.functions.Function0 onToggleFlashClick, kotlin.jvm.functions.Function0 onGalleryClick, kotlin.jvm.functions.Function0 goBack, kotlin.jvm.functions.Function0 selectedCameraOption, kotlin.jvm.functions.Function1,kotlin.Unit> unselectedCameraOption, kotlin.jvm.functions.Function1,kotlin.Unit> galleryButton, kotlin.jvm.functions.Function1,kotlin.Unit> cameraToggleButton, optional kotlin.jvm.functions.Function0 closeButton, optional kotlin.jvm.functions.Function0 flashOnButton, optional kotlin.jvm.functions.Function0 flashOffButton, optional kotlin.jvm.functions.Function1 recordMediaButton); + } + + public final class CircleButtonKt { + method @androidx.compose.runtime.Composable public static void CircleButton(optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional long backgroundColor, optional float iconSize, optional float innerPadding, kotlin.jvm.functions.Function0 onClick, kotlin.jvm.functions.Function0 icon); + } + +} + +package com.urlaunched.android.design.ui.camera.util { + + public final class CacheFoldersConstants { + field public static final com.urlaunched.android.design.ui.camera.util.CacheFoldersConstants INSTANCE; + field public static final String PHOTOS = "photos"; + field public static final String VIDEO_RECORDINGS = "video_recordings"; + } + + public final class MediaTypeExtensionsKt { + method public static com.urlaunched.android.design.ui.camera.model.AppMediaType? mimeToMediaType(String); + } + + public final class PhotoTempFileProvider { + ctor public PhotoTempFileProvider(android.content.Context context); + method public operator java.io.File invoke(); + field public static final com.urlaunched.android.design.ui.camera.util.PhotoTempFileProvider.Companion Companion; + } + + public static final class PhotoTempFileProvider.Companion { + } + + public final class VideoRecordingTempFileProvider { + ctor public VideoRecordingTempFileProvider(android.content.Context context); + method public operator java.io.File invoke(); + field public static final com.urlaunched.android.design.ui.camera.util.VideoRecordingTempFileProvider.Companion Companion; + } + + public static final class VideoRecordingTempFileProvider.Companion { + } + +} + package com.urlaunched.android.design.ui.clickable { public final class DebouncedClickableKt { @@ -456,12 +690,6 @@ package com.urlaunched.android.design.ui.textfield.hashtags { public abstract class Delegate implements java.io.Closeable { ctor public Delegate(kotlinx.coroutines.CoroutineDispatcher coroutineDispatcher); method public void close(); - method public final error.NonExistentClass! getDelegateScope(); - property public final error.NonExistentClass! delegateScope; - } - - public final class HashtagsContainerKt { - method @androidx.compose.runtime.Composable public static void HashtagsContainer(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Modifier innerFieldModifier, optional androidx.compose.ui.text.input.TextFieldValue value, optional String? label, String? placeHolder, optional String? error, optional boolean enabled, optional boolean readOnly, optional com.urlaunched.android.design.ui.textfield.models.TextFieldBorderConfig borderConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldBackgroundConfig backgroundConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldInputTextConfig inputTextConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldInputPlaceholderTextConfig inputPlaceholderTextConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldTopLabelConfig topLabelConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldsSpacerConfig textFieldsSpacerConfig, optional long selectionHandleColor, optional long selectionBackgroundColor, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional int minLines, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional boolean collapseLabel, optional androidx.compose.ui.unit.Dp? textFieldHeight, optional androidx.compose.foundation.layout.PaddingValues innerPadding, optional kotlin.jvm.functions.Function0? trailingIcon, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? labelIcon, optional boolean trailingIconAlwaysShown, kotlin.jvm.functions.Function1 onValueChange); } public interface HashtagsDelegate { @@ -485,6 +713,10 @@ package com.urlaunched.android.design.ui.textfield.hashtags { property public final androidx.compose.ui.text.input.TextFieldValue currentHashtagsText; } + public final class HashtagsTextFieldKt { + method @androidx.compose.runtime.Composable public static void HashtagsTextField(optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.Modifier innerFieldModifier, optional androidx.compose.ui.text.input.TextFieldValue value, optional String? label, String? placeHolder, optional String? error, optional boolean enabled, optional boolean readOnly, optional com.urlaunched.android.design.ui.textfield.models.TextFieldBorderConfig borderConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldBackgroundConfig backgroundConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldInputTextConfig inputTextConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldInputPlaceholderTextConfig inputPlaceholderTextConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldTopLabelConfig topLabelConfig, optional com.urlaunched.android.design.ui.textfield.models.TextFieldsSpacerConfig textFieldsSpacerConfig, optional long selectionHandleColor, optional long selectionBackgroundColor, optional androidx.compose.ui.graphics.Brush cursorBrush, optional androidx.compose.foundation.text.KeyboardOptions keyboardOptions, optional androidx.compose.foundation.text.KeyboardActions keyboardActions, optional boolean singleLine, optional int maxLines, optional int minLines, optional androidx.compose.ui.text.input.VisualTransformation visualTransformation, optional boolean collapseLabel, optional androidx.compose.ui.unit.Dp? textFieldHeight, optional androidx.compose.foundation.layout.PaddingValues innerPadding, optional kotlin.jvm.functions.Function0? trailingIcon, optional kotlin.jvm.functions.Function0? leadingIcon, optional kotlin.jvm.functions.Function0? labelIcon, optional boolean trailingIconAlwaysShown, kotlin.jvm.functions.Function1 onValueChange); + } + } package com.urlaunched.android.design.ui.textfield.models { diff --git a/design/build.gradle b/design/build.gradle index 1b31aea9..235fb3fe 100644 --- a/design/build.gradle +++ b/design/build.gradle @@ -87,7 +87,18 @@ dependencies { // Local modules implementation project(":cdn:models:presentation") + implementation project(":common") // Bottom sheet implementation libs.bottomSheet + + // CameraX + implementation libs.camerax.camera2 + implementation libs.camerax.lifecycle + implementation libs.camerax.video + implementation libs.camerax.view + + // Guava + implementation libs.guava.core + implementation libs.guava.androidExt } \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/resources/dimens/Dimens.kt b/design/src/main/java/com/urlaunched/android/design/resources/dimens/Dimens.kt index 334d4062..ba5d5f56 100644 --- a/design/src/main/java/com/urlaunched/android/design/resources/dimens/Dimens.kt +++ b/design/src/main/java/com/urlaunched/android/design/resources/dimens/Dimens.kt @@ -25,4 +25,7 @@ object Dimens { val cornerRadiusBigSpecial = 20.dp val cornerRadiusBig = 24.dp val cornerRadiusLarge = 32.dp + + val iconSizeNormalSpecial = 20.dp + val iconSizeNormal = 16.dp } \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegate.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegate.kt new file mode 100644 index 00000000..79620cf6 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegate.kt @@ -0,0 +1,32 @@ +package com.urlaunched.android.design.ui.camera.delegate + +import android.net.Uri +import androidx.camera.core.CameraSelector +import androidx.camera.view.PreviewView +import androidx.lifecycle.LifecycleOwner +import com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel +import com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel +import com.urlaunched.android.design.ui.camera.delegate.CameraDelegateImpl.CameraSideEffect +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +interface CameraDelegate { + val uiCameraState: StateFlow + val cameraSideEffect: Flow + + fun bindPreviewView(previewView: PreviewView) + fun bindLifecycle(lifecycleOwner: LifecycleOwner) + fun unbindLifecycle() + + fun toggleCameraType() + fun toggleFlash() + + fun onPhotoCameraClick() + fun onVideoCameraClick() + fun onGalleryClick() + + fun onShutterClick(onCaptureMedia: (uri: Uri) -> Unit) + fun onPause() + + fun onMediaPick(uris: List, onHandleUris: (uris: List) -> Unit) +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegateImpl.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegateImpl.kt new file mode 100644 index 00000000..2a969546 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegateImpl.kt @@ -0,0 +1,257 @@ +package com.urlaunched.android.design.ui.camera.delegate + +import android.Manifest +import android.net.Uri +import androidx.annotation.RequiresPermission +import androidx.camera.core.CameraUnavailableException +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCapture.FLASH_MODE_AUTO +import androidx.camera.core.ImageCapture.FLASH_MODE_OFF +import androidx.camera.core.ImageCaptureException +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.VideoRecordEvent +import androidx.camera.view.CameraController.IMAGE_CAPTURE +import androidx.camera.view.CameraController.VIDEO_CAPTURE +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.camera.view.video.AudioConfig +import androidx.concurrent.futures.await +import androidx.core.util.Consumer +import androidx.lifecycle.LifecycleOwner +import com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel +import com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel +import com.urlaunched.android.design.ui.camera.util.VideoRecordingTempFileProvider +import com.urlaunched.android.design.ui.camera.util.PhotoTempFileProvider +import com.urlaunched.android.design.ui.textfield.hashtags.Delegate +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds + +class CameraDelegateImpl( + private val ioDispatcher: CoroutineDispatcher, + private val mainDispatcher: CoroutineDispatcher, + private val lifecycleCameraController: LifecycleCameraController, + private val videoRecordingTempFileProvider: VideoRecordingTempFileProvider, + private val photoTempFileProvider: PhotoTempFileProvider, + private val viewModelScope: CoroutineScope, +) : Delegate(coroutineDispatcher = ioDispatcher), CameraDelegate { + private val _uiState = MutableStateFlow( + CameraDelegateState( + cameraType = CameraType.BACK, + recordingState = RecordingStatePresentationModel.Stopped, + cameraMode = CameraModeTypePresentationModel.VIDEO, + isFlashEnabled = true, + shouldRestoreStatusBarColors = false, + ) + ) + private val _cameraSideEffect = Channel() + override val cameraSideEffect: Flow get() = _cameraSideEffect.receiveAsFlow() + + override val uiCameraState: StateFlow = _uiState + + private var isCameraInitialized = false + private var recordController: androidx.camera.video.Recording? = null + + init { + lifecycleCameraController.setEnabledUseCases(VIDEO_CAPTURE or IMAGE_CAPTURE) + initCameraController() + } + + private fun initCameraController() { + viewModelScope.launch(mainDispatcher) { + try { + lifecycleCameraController.initializationFuture.await() + isCameraInitialized = true + } catch (e: CameraUnavailableException) { + withContext(ioDispatcher) { + _cameraSideEffect.send(CameraSideEffect.ShowCameraInitErrorSnackbar) + } + } + } + } + + override fun bindPreviewView(previewView: PreviewView) { + previewView.controller = lifecycleCameraController + } + + override fun bindLifecycle(lifecycleOwner: LifecycleOwner) { + lifecycleCameraController.bindToLifecycle(lifecycleOwner) + } + + override fun unbindLifecycle() { + lifecycleCameraController.unbind() + } + + override fun toggleCameraType() { + val newType = if (_uiState.value.cameraType == CameraType.BACK) CameraType.FRONT else CameraType.BACK + setCameraType(newType) + } + + private fun setCameraType(cameraType: CameraType) { + if (_uiState.value.cameraType == cameraType) return + if (hasCamera(cameraType) && !lifecycleCameraController.isRecording) { + if (lifecycleCameraController.cameraSelector != cameraType.selector) { + lifecycleCameraController.cameraSelector = cameraType.selector + _uiState.update { it.copy(cameraType = cameraType) } + } + } + } + + private fun hasCamera(cameraType: CameraType): Boolean = + isCameraInitialized && lifecycleCameraController.hasCamera(cameraType.selector) + + override fun toggleFlash() { + val newFlash = !_uiState.value.isFlashEnabled + lifecycleCameraController.imageCaptureFlashMode = if (newFlash) FLASH_MODE_AUTO else FLASH_MODE_OFF + _uiState.update { it.copy(isFlashEnabled = newFlash) } + } + + override fun onPhotoCameraClick() { + _uiState.update { it.copy(cameraMode = CameraModeTypePresentationModel.PHOTO) } + } + + override fun onVideoCameraClick() { + _uiState.update { it.copy(cameraMode = CameraModeTypePresentationModel.VIDEO) } + } + + override fun onGalleryClick() { + viewModelScope.launch { _cameraSideEffect.send(CameraSideEffect.PickGalleryMedia) } + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + override fun onShutterClick(onCaptureMedia: (uri: Uri) -> Unit) { + when (_uiState.value.cameraMode) { + CameraModeTypePresentationModel.PHOTO -> takePicture(onCaptureMedia) + CameraModeTypePresentationModel.VIDEO -> toggleRecording(onCaptureMedia) + } + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun toggleRecording(onCaptureMedia: (uri: Uri) -> Unit) { + when (_uiState.value.recordingState) { + is RecordingStatePresentationModel.Recording -> stopRecording() + is RecordingStatePresentationModel.Stopped -> startRecording(90.seconds, onCaptureMedia) + } + } + + private fun takePicture(onCaptureMedia: (uri: Uri) -> Unit) { + lifecycleCameraController.takePicture( + ImageCapture.OutputFileOptions.Builder(photoTempFileProvider()).build(), + Runnable::run, + object : ImageCapture.OnImageSavedCallback { + override fun onError(error: ImageCaptureException) { + showSnackbar(error.message.orEmpty()) + } + + override fun onImageSaved(result: ImageCapture.OutputFileResults) { + result.savedUri?.let { uri -> + onCaptureMedia(uri) + + } + } + } + ) + } + + @RequiresPermission(Manifest.permission.RECORD_AUDIO) + private fun startRecording(limit: Duration, onCaptureMedia: (uri: Uri) -> Unit) { + viewModelScope.launch(ioDispatcher) { + val file = videoRecordingTempFileProvider() + withContext(mainDispatcher) { + try { + recordController = lifecycleCameraController.startRecording( + FileOutputOptions.Builder(file) + .setDurationLimitMillis(limit.inWholeMilliseconds) + .build(), + AudioConfig.create(true), + Runnable::run, + withContext(mainDispatcher) { + recordingStateListener(limit, onCaptureMedia) + } + ) + } catch (e: Exception) { + _uiState.update { it.copy(recordingState = RecordingStatePresentationModel.Stopped) } + showSnackbar(e.message.orEmpty()) + } + } + } + } + + private fun stopRecording() { + recordController?.stop() + } + + private fun recordingStateListener( + recordingDurationLimit: Duration, + onCaptureMedia: (uri: Uri) -> Unit + ): Consumer = + Consumer { event -> + viewModelScope.launch(ioDispatcher) { + when (event) { + is VideoRecordEvent.Finalize -> { + _uiState.update { uiState -> + uiState.copy( + recordingState = RecordingStatePresentationModel.Stopped + ) + } + + when { + !event.hasError() || event.error == VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED -> { + withContext(mainDispatcher) { + onCaptureMedia(event.outputResults.outputUri) + } + } + + else -> showSnackbar(event.cause?.message.toString()) + } + + recordController = null + } + + else -> { + _uiState.update { uiState -> + uiState.copy( + recordingState = RecordingStatePresentationModel.Recording( + event.recordingStats.recordedDurationNanos.nanoseconds, + recordingDurationLimit + ) + ) + } + } + } + } + } + + override fun onPause() { + recordController?.stop() + } + + override fun onMediaPick(uris: List, onHandleUris: (uris: List) -> Unit) { + viewModelScope.launch(ioDispatcher) { + _uiState.update { it.copy(shouldRestoreStatusBarColors = true) } + onHandleUris(uris) + } + } + + private fun showSnackbar(msg: String) { + viewModelScope.launch(ioDispatcher) { + _cameraSideEffect.send(CameraSideEffect.ShowSnackbar(msg)) + } + } + + sealed class CameraSideEffect { + data class ShowSnackbar(val message: String) : CameraSideEffect() + data object ShowCameraInitErrorSnackbar : CameraSideEffect() + data object PickGalleryMedia : CameraSideEffect() + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegateState.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegateState.kt new file mode 100644 index 00000000..989f600c --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraDelegateState.kt @@ -0,0 +1,12 @@ +package com.urlaunched.android.design.ui.camera.delegate + +import com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel +import com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel + +data class CameraDelegateState( + val cameraType: CameraType, + val recordingState: RecordingStatePresentationModel, + val cameraMode: CameraModeTypePresentationModel, + val isFlashEnabled: Boolean, + val shouldRestoreStatusBarColors: Boolean, +) \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraType.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraType.kt new file mode 100644 index 00000000..a2f92107 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/delegate/CameraType.kt @@ -0,0 +1,10 @@ +package com.urlaunched.android.design.ui.camera.delegate + +import androidx.camera.core.CameraSelector + +enum class CameraType( + val selector: CameraSelector +) { + FRONT(CameraSelector.DEFAULT_FRONT_CAMERA), + BACK(CameraSelector.DEFAULT_BACK_CAMERA) +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/model/AppMediaType.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/model/AppMediaType.kt new file mode 100644 index 00000000..77cbe9fd --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/model/AppMediaType.kt @@ -0,0 +1,25 @@ +package com.urlaunched.android.design.ui.camera.model + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.webkit.MimeTypeMap +import com.urlaunched.android.common.files.MediaType +import com.urlaunched.android.design.ui.camera.util.mimeToMediaType + +enum class AppMediaType( + override val mimeType: String, + override val extraMimeTypes: List = listOf(), + val extensions: List = listOf() +) : MediaType { + IMAGE_PREVIEW(mimeType = "image/*", extensions = listOf(".jpg")), + VIDEO(mimeType = "video/mp4", extensions = listOf(".mp4")) +} + +fun Uri.getMediaTypeForUri(context: Context) = if (ContentResolver.SCHEME_CONTENT == scheme) { + context.contentResolver.getType(this)?.mimeToMediaType() +} else { + MimeTypeMap.getSingleton() + .getMimeTypeFromExtension(MimeTypeMap.getFileExtensionFromUrl(toString())) + ?.mimeToMediaType() +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/model/CameraModeTypePresentationModel.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/model/CameraModeTypePresentationModel.kt new file mode 100644 index 00000000..a12c38aa --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/model/CameraModeTypePresentationModel.kt @@ -0,0 +1,6 @@ +package com.urlaunched.android.design.ui.camera.model + +enum class CameraModeTypePresentationModel { + PHOTO, + VIDEO +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/model/RecordingStatePresentationModel.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/model/RecordingStatePresentationModel.kt new file mode 100644 index 00000000..039489c9 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/model/RecordingStatePresentationModel.kt @@ -0,0 +1,11 @@ +package com.urlaunched.android.design.ui.camera.model + +import kotlin.time.Duration + +sealed class RecordingStatePresentationModel { + data class Recording(val duration: Duration, val durationLimit: Duration) : RecordingStatePresentationModel() { + val progress = (duration / durationLimit).toFloat() + } + + data object Stopped : RecordingStatePresentationModel() +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraContainer.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraContainer.kt new file mode 100644 index 00000000..62f4d48b --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraContainer.kt @@ -0,0 +1,156 @@ +package com.urlaunched.android.design.ui.camera.ui + +import androidx.camera.view.PreviewView +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.Dp +import com.urlaunched.android.design.resources.dimens.Dimens +import com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel +import com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel + +data class CameraContainerColors( + val backgroundColor: Color = Color.Black, + val surfaceColor: Color = Color.White, + val progressBrush: Brush = SolidColor(Color.Black), + val startRecordButtonColor: Color = Color.Red, + val recordingButtonColor: Color = Color.Red, +) + +data class CameraContainerConfig( + val colors: CameraContainerColors = CameraContainerColors(), + val surfaceCornerRadius: Dp = Dimens.cornerRadiusNormalSpecial, + val surfacePaddingBottom: Dp = Dimens.spacingLarge, + val flashIconSize: Dp = Dimens.iconSizeNormalSpecial, + val flashIconInnerPadding: Dp = CameraRecorderDimens.flashIconPadding, + val topButtonsSpacing: Dp = Dimens.spacingNormalSpecial, + val controlPaddingHorizontal: Dp = Dimens.spacingNormalSpecial, + val controlPaddingBottom: Dp = Dimens.spacingNormalSpecial, +) + +@Composable +fun CameraContainer( + modifier: Modifier = Modifier, + config: CameraContainerConfig = CameraContainerConfig(), + recordingState: RecordingStatePresentationModel, + cameraMode: CameraModeTypePresentationModel, + isFlashEnabled: Boolean, + isGalleryEnabled: Boolean, + onShutterClick: () -> Unit, + onCameraPreviewViewAvailable: (PreviewView) -> Unit, + onVideoCameraClick: () -> Unit, + onPhotoCameraClick: () -> Unit, + onToggleCameraClick: () -> Unit, + onToggleFlashClick: () -> Unit, + onGalleryClick: () -> Unit, + goBack: () -> Unit, + selectedCameraOption: @Composable () -> Unit, + unselectedCameraOption: @Composable (onClick: () -> Unit) -> Unit, + galleryButton: @Composable (onClick: () -> Unit) -> Unit, + cameraToggleButton: @Composable (onClick: () -> Unit) -> Unit, + closeButton: @Composable () -> Unit = {}, + flashOnButton: @Composable () -> Unit = {}, + flashOffButton: @Composable () -> Unit = {}, + recordMediaButton : @Composable BoxScope.() -> Unit = { + RecordMediaButton( + modifier = Modifier + .size(CameraRecorderDimens.recordMediaButtonSize) + .align(Alignment.Center), + isRecording = recordingState is RecordingStatePresentationModel.Recording, + progress = (recordingState as? RecordingStatePresentationModel.Recording)?.progress ?: 0f, + progressBrush = config.colors.progressBrush, + defaultButtonColor = config.colors.startRecordButtonColor, + onClick = onShutterClick, + recordingButtonColor = config.colors.recordingButtonColor, + ) + } +) { + Box( + modifier = modifier + .imePadding() + .fillMaxSize() + .background(config.colors.backgroundColor) + .statusBarsPadding() + .padding(bottom = config.surfacePaddingBottom) + .background( + color = config.colors.surfaceColor, + shape = RoundedCornerShape(config.surfaceCornerRadius) + ) + .clip(RoundedCornerShape(config.surfaceCornerRadius)) + ) { + CameraPreview( + modifier = Modifier.fillMaxSize(), + onCameraPreviewViewAvailable = onCameraPreviewViewAvailable + ) + + AnimatedVisibility( + modifier = Modifier.fillMaxWidth(), + enter = fadeIn(), + exit = fadeOut(), + visible = recordingState !is RecordingStatePresentationModel.Recording + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(config.topButtonsSpacing), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Box(modifier = Modifier.clickable(onClick = goBack)) { + closeButton() + } + + AnimatedContent(isFlashEnabled) { enabled -> + Box(modifier = Modifier.clickable(onClick = onToggleFlashClick)) { + if (enabled) { + flashOnButton() + } else { + flashOffButton() + } + } + } + } + } + + CameraRecorderControls( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = config.controlPaddingHorizontal) + .padding(bottom = config.controlPaddingBottom), + cameraModeType = cameraMode, + recordingState = recordingState, + isGalleryEnabled = isGalleryEnabled, + onVideoCameraClick = onVideoCameraClick, + onToggleCameraClick = onToggleCameraClick, + onPhotoCameraClick = onPhotoCameraClick, + onGalleryClick = onGalleryClick, + selectedCameraOption = selectedCameraOption, + unselectedCameraOption = unselectedCameraOption, + galleryButton = galleryButton, + cameraToggleButton = cameraToggleButton, + recordMediaButton = recordMediaButton + ) + } +} + diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraPreview.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraPreview.kt new file mode 100644 index 00000000..7e26ea8c --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraPreview.kt @@ -0,0 +1,37 @@ +package com.urlaunched.android.design.ui.camera.ui + +import android.view.ViewGroup +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +internal fun CameraPreview(modifier: Modifier = Modifier, onCameraPreviewViewAvailable: (view: PreviewView) -> Unit) { + if (!LocalInspectionMode.current) { + AndroidView( + modifier = modifier, + factory = { context -> + PreviewView(context).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT + ) + + onCameraPreviewViewAvailable(this) + + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + scaleType = PreviewView.ScaleType.FILL_CENTER + } + }, + onRelease = { view -> + view.controller = null + } + ) + } else { + Box(modifier.background(color = Color(0xFF4CAF50))) + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraRecorderControls.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraRecorderControls.kt new file mode 100644 index 00000000..dae571d7 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraRecorderControls.kt @@ -0,0 +1,130 @@ +package com.urlaunched.android.design.ui.camera.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.layout.Layout +import com.urlaunched.android.design.resources.dimens.Dimens +import com.urlaunched.android.design.ui.camera.model.CameraModeTypePresentationModel +import com.urlaunched.android.design.ui.camera.model.RecordingStatePresentationModel + +@Composable +internal fun CameraRecorderControls( + modifier: Modifier = Modifier, + recordingState: RecordingStatePresentationModel, + cameraModeType: CameraModeTypePresentationModel, + isGalleryEnabled: Boolean, + onVideoCameraClick: () -> Unit, + onPhotoCameraClick: () -> Unit, + onGalleryClick: () -> Unit, + onToggleCameraClick: () -> Unit, + selectedCameraOption: @Composable () -> Unit, + unselectedCameraOption: @Composable (onClick: () -> Unit) -> Unit, + galleryButton: @Composable (onClick: () -> Unit) -> Unit, + cameraToggleButton: @Composable (onClick: () -> Unit) -> Unit, + recordMediaButton: @Composable BoxScope.() -> Unit +) { + Column(modifier = modifier) { + AnimatedContent( + modifier = Modifier + .fillMaxWidth() + .wrapContentWidth(), + contentAlignment = Alignment.Center, + targetState = recordingState is RecordingStatePresentationModel.Recording + ) { isRecording -> + if (isRecording) { + selectedCameraOption() + } else { + AnimatedContent(targetState = cameraModeType) { type -> + CameraOptionLayout( + firstOption = { selectedCameraOption() }, + secondOption = { + unselectedCameraOption( + if (type == CameraModeTypePresentationModel.PHOTO) { + onVideoCameraClick + } else { + onPhotoCameraClick + } + ) + } + ) + } + } + } + + Spacer(Modifier.height(Dimens.spacingSmall)) + + Box(modifier = Modifier.height(IntrinsicSize.Max)) { + recordMediaButton() + + Row( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedVisibility(visible = recordingState !is RecordingStatePresentationModel.Recording && isGalleryEnabled) { + galleryButton(onGalleryClick) + } + + AnimatedVisibility(visible = recordingState !is RecordingStatePresentationModel.Recording) { + cameraToggleButton(onToggleCameraClick) + } + } + } + } +} + +@Composable +private fun CameraOptionLayout( + modifier: Modifier = Modifier, + firstOption: @Composable () -> Unit, + secondOption: @Composable () -> Unit +) { + Layout( + modifier = modifier, + content = { + firstOption() + secondOption() + } + ) { measurables, constraints -> + if (measurables.size < 2) { + return@Layout layout(0, 0) { } + } + + val firstButton = measurables[0].measure(constraints) + val secondButton = measurables[1].measure(constraints) + + val width = firstButton.width + secondButton.width * 2 + Dimens.spacingBig.roundToPx() * 2 + val height = maxOf(secondButton.height, firstButton.height) + + layout(width, height) { + val firstButtonX = secondButton.width + Dimens.spacingBig.roundToPx() + + firstButton.placeRelative(firstButtonX, 0) + secondButton.placeRelative( + x = firstButtonX + firstButton.width + Dimens.spacingBig.roundToPx(), + 0 + ) + } + } +} + diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraRecorderDimens.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraRecorderDimens.kt new file mode 100644 index 00000000..839b58ca --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CameraRecorderDimens.kt @@ -0,0 +1,10 @@ +package com.urlaunched.android.design.ui.camera.ui + +import androidx.compose.ui.unit.dp + +internal object CameraRecorderDimens { + val recordMediaButtonProgressWidth = 4.dp + val recordMediaButtonBorderWidth = 2.dp + val recordMediaButtonSize = 72.dp + val flashIconPadding = 10.dp +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CircleButton.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CircleButton.kt new file mode 100644 index 00000000..a78a7f9a --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/CircleButton.kt @@ -0,0 +1,40 @@ +package com.urlaunched.android.design.ui.camera.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import com.urlaunched.android.design.resources.dimens.Dimens + +@Composable +fun CircleButton( + modifier: Modifier = Modifier, + enabled: Boolean = true, + backgroundColor: Color = Color.LightGray, + iconSize: Dp = Dimens.iconSizeNormal, + innerPadding: Dp = Dimens.spacingNormalSpecial, + onClick: () -> Unit, + icon: @Composable () -> Unit +) { + Box( + modifier = modifier + .background( + color = backgroundColor, + shape = CircleShape + ) + .clip(CircleShape) + .clickable(enabled = enabled, onClick = onClick) + .padding(innerPadding) + ) { + Box(modifier = Modifier.size(iconSize), propagateMinConstraints = true) { + icon() + } + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/RecordMediaButton.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/RecordMediaButton.kt new file mode 100644 index 00000000..8dd75dae --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/ui/RecordMediaButton.kt @@ -0,0 +1,154 @@ +package com.urlaunched.android.design.ui.camera.ui + +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateInt +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.urlaunched.android.design.resources.dimens.Dimens + +private const val recordMediaButtonRecordingCornersPercent = 20 +private const val recordMediaButtonDefaultCornersPercent = 100 +private const val recordLabel = "recordingTransition" +private const val backgroundColorAlpha = 0.6f +private const val progressStartAngle = 270f + +@Composable +internal fun RecordMediaButton( + modifier: Modifier = Modifier, + isRecording: Boolean, + progress: Float, + progressBrush: Brush, + recordingButtonColor: Color = Color.Black, + defaultButtonColor: Color = Color.Red, + onClick: () -> Unit +) { + val backgroundWhite: Color = Color.White + val backgroundWhiteAlpha: Color = backgroundWhite.copy(alpha = backgroundColorAlpha) + val transparentWhite: Color = backgroundWhite.copy(alpha = 0f) + val recordingTransition = updateTransition(isRecording, label = recordLabel) + + val cornerRadius by recordingTransition.animateInt { recordingState -> + if (recordingState) recordMediaButtonRecordingCornersPercent + else recordMediaButtonDefaultCornersPercent + } + + val innerPadding by recordingTransition.animateDp { recordingState -> + if (recordingState) Dimens.spacingBig + else Dimens.spacingSmall + } + + val buttonColor by recordingTransition.animateColor { + if (it) recordingButtonColor else defaultButtonColor + } + + val backgroundColor by recordingTransition.animateColor { + if (it) backgroundWhiteAlpha else transparentWhite + } + + val borderColor by recordingTransition.animateColor { + if (it) transparentWhite else backgroundWhite + } + + val coercedProgress = progress.coerceIn(0f, 1f) + val stroke = with(LocalDensity.current) { + Stroke( + width = CameraRecorderDimens.recordMediaButtonProgressWidth.toPx(), + cap = StrokeCap.Round + ) + } + + Box( + modifier = modifier + .drawWithCache { + onDrawWithContent { + drawContent() + if (isRecording) { + val sweep = coercedProgress * 360f + drawCircularIndicator(progressStartAngle, sweep, progressBrush, stroke) + } + } + } + .clip(CircleShape) + .clickable(onClick = onClick) + .border( + width = CameraRecorderDimens.recordMediaButtonBorderWidth, + color = borderColor, + shape = CircleShape + ) + .background( + color = backgroundColor, + shape = CircleShape + ) + ) { + Box( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .background( + color = buttonColor, + shape = RoundedCornerShape(cornerRadius) + ) + ) + } +} + + +private fun DrawScope.drawCircularIndicator(startAngle: Float, sweep: Float, brush: Brush, stroke: Stroke) { + // To draw this circle we need a rect with edges that line up with the midpoint of the stroke. + // To do this we need to remove half the stroke width from the total diameter for both sides. + val diameterOffset = stroke.width / 2 + val arcDimen = size.width - 2 * diameterOffset + drawArc( + brush = brush, + startAngle = startAngle, + sweepAngle = sweep, + useCenter = false, + topLeft = Offset(diameterOffset, diameterOffset), + size = Size(arcDimen, arcDimen), + style = stroke + ) +} + +@Preview(showBackground = true, backgroundColor = 0xD8FFFFFF) +@Composable +private fun RecordMediaButtonPreview() { + var isRecording by remember { mutableStateOf(false) } + + RecordMediaButton( + modifier = Modifier.size(80.dp), + isRecording = isRecording, + progress = 1f, + progressBrush = SolidColor(Color.Black), + onClick = { + isRecording = !isRecording + } + ) +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/util/CacheFoldersConstants.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/CacheFoldersConstants.kt new file mode 100644 index 00000000..19e0a5bf --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/CacheFoldersConstants.kt @@ -0,0 +1,6 @@ +package com.urlaunched.android.design.ui.camera.util + +object CacheFoldersConstants { + const val VIDEO_RECORDINGS = "video_recordings" + const val PHOTOS = "photos" +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/util/MediaTypeExtensions.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/MediaTypeExtensions.kt new file mode 100644 index 00000000..2cc8ab77 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/MediaTypeExtensions.kt @@ -0,0 +1,17 @@ +package com.urlaunched.android.design.ui.camera.util + +import com.urlaunched.android.design.ui.camera.model.AppMediaType + +fun String.mimeToMediaType() = when { + startsWith("image/") -> { + AppMediaType.IMAGE_PREVIEW + } + + startsWith("video/") -> { + AppMediaType.VIDEO + } + + else -> { + null + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/util/PhotoTempFileProvider.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/PhotoTempFileProvider.kt new file mode 100644 index 00000000..7b5f49ae --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/PhotoTempFileProvider.kt @@ -0,0 +1,17 @@ +package com.urlaunched.android.design.ui.camera.util + +import android.content.Context +import com.urlaunched.android.design.ui.camera.model.AppMediaType +import java.io.File + +class PhotoTempFileProvider ( private val context: Context) { + operator fun invoke(): File = File(context.cacheDir, CacheFoldersConstants.PHOTOS).apply { + mkdir() + }.let { dir -> + File.createTempFile(TEMP_FILE_PREFIX, AppMediaType.IMAGE_PREVIEW.extensions.first(), dir) + } + + companion object { + private const val TEMP_FILE_PREFIX = "photo" + } +} \ No newline at end of file diff --git a/design/src/main/java/com/urlaunched/android/design/ui/camera/util/VideoRecordingTempFileProvider.kt b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/VideoRecordingTempFileProvider.kt new file mode 100644 index 00000000..e0aacca5 --- /dev/null +++ b/design/src/main/java/com/urlaunched/android/design/ui/camera/util/VideoRecordingTempFileProvider.kt @@ -0,0 +1,17 @@ +package com.urlaunched.android.design.ui.camera.util + +import android.content.Context +import com.urlaunched.android.design.ui.camera.model.AppMediaType +import java.io.File + +class VideoRecordingTempFileProvider (private val context: Context) { + operator fun invoke(): File = File(context.cacheDir, CacheFoldersConstants.VIDEO_RECORDINGS).apply { + mkdir() + }.let { dir -> + File.createTempFile(TEMP_FILE_PREFIX, AppMediaType.VIDEO.extensions.first(), dir) + } + + companion object { + private const val TEMP_FILE_PREFIX = "video" + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31fa6a59..4bc4b105 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -82,6 +82,13 @@ aspectjweaverVersion = "1.9.22.1" # bottomSheet bottomSheetVersion = "1.33.0" +# cameraxDependencies +cameraxVersion = "1.4.2" + +# guavaDependencies +guavaVersion = "33.4.0-android" +guavaAndroidxVersion = "1.2.0" + # plugins # dependencies androidApplicationPluginVersion = "8.4.2" @@ -185,6 +192,13 @@ junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCoreVersion" } material = { group = "com.google.android.material", name = "material", version.ref = "materialVersion" } +camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraxVersion" } +camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraxVersion" } +camerax-video = { module = "androidx.camera:camera-video", version.ref = "cameraxVersion" } +camerax-view = { module = "androidx.camera:camera-view", version.ref = "cameraxVersion" } + +guava-core = { module = "com.google.guava:guava", version.ref = "guavaVersion" } +guava-androidExt = { module = "androidx.concurrent:concurrent-futures-ktx", version.ref = "guavaAndroidxVersion" } [plugins] android-application = { id = "com.android.application", version.ref = "androidApplicationPluginVersion" }