diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0b9c0f3..1781f70 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,10 +3,12 @@ xmlns:tools="http://schemas.android.com/tools"> + + Unit) { @@ -302,7 +305,7 @@ fun NavGraphBuilder.settingsScreen(onNavigateBack: () -> Unit, viewModel: Naviga } } -fun NavGraphBuilder.cameraScreen(onNavigateBack: () -> Unit,viewModel: NavigationViewModel) { +fun NavGraphBuilder.cameraScreen(onNavigateBack: () -> Unit, onNavigateTo: (Screen) -> Unit, viewModel: NavigationViewModel) { composable( route = Screen.CameraScreen.route, enterTransition = { @@ -324,7 +327,39 @@ fun NavGraphBuilder.cameraScreen(onNavigateBack: () -> Unit,viewModel: Navigatio val profileViewModel = hiltViewModel() CameraScreen( navigateBack = onNavigateBack, - profileViewModel = profileViewModel + profileViewModel = profileViewModel, + navigateToVideoPlayer = { videoUri -> + onNavigateTo(Screen.VideoPlayerScreen.navWith(videoUri)) + } ) } +} + +fun NavGraphBuilder.videoPlayerScreen(viewModel: NavigationViewModel) { + composable( + route = Screen.VideoPlayerScreen.withVideoUri(), + arguments = listOf( + navArgument(VIDEO_URI) { + type = NavType.StringType + } + ), + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(500) + ) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(200) + ) + } + ) { + LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { + viewModel.setBottomBarVisible(false) + } + val videoPlayerViewModel = hiltViewModel() + VideoPlayerScreen(viewModel = videoPlayerViewModel) + } } \ No newline at end of file diff --git a/core/navigation/src/main/java/com/loki/navigation/Screen.kt b/core/navigation/src/main/java/com/loki/navigation/Screen.kt index b23e239..27ea7ba 100644 --- a/core/navigation/src/main/java/com/loki/navigation/Screen.kt +++ b/core/navigation/src/main/java/com/loki/navigation/Screen.kt @@ -6,6 +6,7 @@ import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Newspaper import androidx.compose.ui.graphics.vector.ImageVector import com.loki.ui.utils.Constants.REPORT_ID +import com.loki.ui.utils.Constants.VIDEO_URI sealed class Screen( val route: String, @@ -24,11 +25,15 @@ sealed class Screen( return "${ReportScreen.route}/{$REPORT_ID}" } + fun withVideoUri(): String { + return "${VideoPlayerScreen.route}/{$VIDEO_URI}" + } + object LoginScreen: Screen("login_screen") object RegisterScreen: Screen("register_screen") object ForgotPasswordScreen: Screen("forgot_password_screen") object HomeScreen: Screen("home_screen") - object ReportListScreen: Screen(route = "report_list_screen", title = "Home", icon = Icons.Filled.Home) + object ReportListScreen: Screen(route = "report_list_screen", title = "Home", icon = Icons.Filled.Home, restoreState = false) object NewsScreen: Screen(route = "news_screen", title = "News", restoreState = false, icon = Icons.Filled.Newspaper) object NewReportScreen: Screen("new_report_screen") object ReportScreen: Screen("report_screen", restoreState = false) @@ -36,5 +41,6 @@ sealed class Screen( object UsernameChangeScreen: Screen("change_username_screen") object SettingsScreen: Screen("settings_screen") object CameraScreen: Screen("camera_screen") + object VideoPlayerScreen: Screen("video_player_screen") } \ No newline at end of file diff --git a/core/navigation/src/main/java/com/loki/navigation/graph/AccountGraph.kt b/core/navigation/src/main/java/com/loki/navigation/graph/AccountGraph.kt index 293b2d5..b87c0b0 100644 --- a/core/navigation/src/main/java/com/loki/navigation/graph/AccountGraph.kt +++ b/core/navigation/src/main/java/com/loki/navigation/graph/AccountGraph.kt @@ -11,6 +11,7 @@ import com.loki.navigation.ext.navigateTo import com.loki.navigation.profileScreen import com.loki.navigation.settingsScreen import com.loki.navigation.usernameChangeScreen +import com.loki.navigation.videoPlayerScreen @Composable fun AccountNavGraph( @@ -28,6 +29,7 @@ fun AccountNavGraph( profileScreen(onNavigateTo = navController::navigateTo, viewModel = viewModel, onNavigateToLogin = onNavigateToLogin) usernameChangeScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) settingsScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) - cameraScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) + cameraScreen(onNavigateBack = navController::navigateUp, onNavigateTo = navController::navigateTo, viewModel = viewModel) + videoPlayerScreen(viewModel = viewModel) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/components/NotificationBubble.kt b/core/ui/src/main/java/com/loki/ui/components/NotificationBubble.kt new file mode 100644 index 0000000..132e2c7 --- /dev/null +++ b/core/ui/src/main/java/com/loki/ui/components/NotificationBubble.kt @@ -0,0 +1,83 @@ +package com.loki.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.loki.ui.theme.md_theme_light_primary +import kotlinx.coroutines.delay + +@Composable +fun NotificationBubble( + modifier: Modifier = Modifier, + message: String = "" +) { + + var isBubbleVisible by remember { mutableStateOf(false) } + var mess by remember { mutableStateOf(message) } + + LaunchedEffect(key1 = mess) { + + isBubbleVisible = true + + delay(3000L) + + isBubbleVisible = false + mess = "" + } + + AnimatedVisibility( + visible = isBubbleVisible, + enter = slideInVertically { -(it / 2) }, + exit = slideOutVertically { 0 } + ) { + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = 24.dp, + vertical = 16.dp + ) + .clip(RoundedCornerShape(12.dp)) + .height(70.dp) + .background(md_theme_light_primary.copy(.5f)) + .border( + border = BorderStroke(width = 1.dp, color = md_theme_light_primary), + shape = RoundedCornerShape(12.dp) + ) + .alpha(1f) + .align(Alignment.TopCenter), + contentAlignment = Alignment.CenterStart + ) { + + Text( + text = message, + color = md_theme_light_primary, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + } + } + } +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/permission/PermissionDialog.kt b/core/ui/src/main/java/com/loki/ui/permission/PermissionDialog.kt index 7b5950c..088bfb4 100644 --- a/core/ui/src/main/java/com/loki/ui/permission/PermissionDialog.kt +++ b/core/ui/src/main/java/com/loki/ui/permission/PermissionDialog.kt @@ -18,50 +18,71 @@ import androidx.core.content.ContextCompat @Composable fun PermissionDialog( context: Context, - permission: String, - permissionRationale: String, + permissions: List, + permissionRationale: Map, snackbarHostState: SnackbarHostState, - permissionAction: (PermissionAction) -> Unit + permissionAction: (Map) -> Unit ) { - val isPermissionGranted = checkIfPermissionGranted(context, permission) + val grantedPermissions = mutableMapOf() - if (isPermissionGranted) { - permissionAction(PermissionAction.PermissionAlreadyGranted) - return - } + permissions.forEach { permission -> + val isPermissionGranted = checkIfPermissionGranted(context, permission) - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> - if (isGranted) { - permissionAction(PermissionAction.PermissionGranted) + if (isPermissionGranted) { + grantedPermissions[permission] = PermissionAction.PermissionAlreadyGranted } else { - permissionAction(PermissionAction.PermissionDenied) - } - } - val showPermissionRationale = shouldShowPermissionRationale(context, permission) + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> - if (showPermissionRationale) { - LaunchedEffect(key1 = showPermissionRationale ) { - val snackbarResult = snackbarHostState.showSnackbar( - message = permissionRationale, - actionLabel = "Grant Access", - duration = SnackbarDuration.Long + val action = if (isGranted) { + PermissionAction.PermissionGranted + } else { + PermissionAction.PermissionDenied + } - ) + grantedPermissions[permission] = action - when(snackbarResult) { - SnackbarResult.Dismissed -> permissionAction(PermissionAction.PermissionDenied) - SnackbarResult.ActionPerformed -> permissionLauncher.launch(permission) + //checks if we all collected responses for all permission + if (grantedPermissions.size == permissions.size) { + permissionAction(grantedPermissions) + } + } + + val showPermissionRationale = shouldShowPermissionRationale(context, permission) + + if (showPermissionRationale) { + LaunchedEffect(key1 = showPermissionRationale ) { + + val rationale = permissionRationale[permission] + val snackbarResult = snackbarHostState.showSnackbar( + message = rationale ?: "Permission Required", + actionLabel = "Grant Access", + duration = SnackbarDuration.Long + + ) + + when(snackbarResult) { + SnackbarResult.Dismissed -> { + grantedPermissions[permission] = PermissionAction.PermissionDenied + + // Check if we have collected responses for all permissions + if (grantedPermissions.size == permissions.size) { + permissionAction(grantedPermissions) + } + } + SnackbarResult.ActionPerformed -> permissionLauncher.launch(permission) + } + } + } + else { + SideEffect { + permissionLauncher.launch(permission) + } } - } - } - else { - SideEffect { - permissionLauncher.launch(permission) } } } diff --git a/core/ui/src/main/java/com/loki/ui/utils/Constants.kt b/core/ui/src/main/java/com/loki/ui/utils/Constants.kt index 8b9084e..1d4910d 100644 --- a/core/ui/src/main/java/com/loki/ui/utils/Constants.kt +++ b/core/ui/src/main/java/com/loki/ui/utils/Constants.kt @@ -3,4 +3,5 @@ package com.loki.ui.utils object Constants { const val REPORT_ID = "reportId" + const val VIDEO_URI = "videoUri" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/utils/DateUtil.kt b/core/ui/src/main/java/com/loki/ui/utils/DateUtil.kt index 12e0bbf..b46f5a7 100644 --- a/core/ui/src/main/java/com/loki/ui/utils/DateUtil.kt +++ b/core/ui/src/main/java/com/loki/ui/utils/DateUtil.kt @@ -29,6 +29,11 @@ object DateUtil { return dateFormat.format(date) } + fun getFileName(): String { + return SimpleDateFormat(FILE_NAME_FORMAT, Locale.ENGLISH).format(System.currentTimeMillis()) + } + private const val DATE_FORMAT = "EEE, d MMM yyyy" private const val TIME_FORMAT = "hh:mm a" + private const val FILE_NAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS" } \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/viewmodel/ReetViewModel.kt b/core/ui/src/main/java/com/loki/ui/viewmodel/ReetViewModel.kt index c644063..3c587b1 100644 --- a/core/ui/src/main/java/com/loki/ui/viewmodel/ReetViewModel.kt +++ b/core/ui/src/main/java/com/loki/ui/viewmodel/ReetViewModel.kt @@ -1,6 +1,5 @@ package com.loki.ui.viewmodel -import android.util.Log import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -8,13 +7,10 @@ import com.google.firebase.FirebaseException import com.loki.local.datastore.DataStoreStorage import com.loki.local.datastore.model.LocalProfile import com.loki.local.datastore.model.LocalUser -import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import javax.inject.Inject - open class ReetViewModel( private val dataStore: DataStoreStorage diff --git a/di/build.gradle.kts b/di/build.gradle.kts index b8a92b3..94ce5b2 100644 --- a/di/build.gradle.kts +++ b/di/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { implementation(libs.androidx.datastore) implementation(libs.bundles.retrofit) + implementation(libs.bundles.exoPlayer) testImplementation(libs.bundles.test.common) androidTestImplementation(libs.bundles.android.test) diff --git a/di/src/main/java/com/loki/di/VideoPlayerModule.kt b/di/src/main/java/com/loki/di/VideoPlayerModule.kt new file mode 100644 index 0000000..0ed6c6b --- /dev/null +++ b/di/src/main/java/com/loki/di/VideoPlayerModule.kt @@ -0,0 +1,22 @@ +package com.loki.di + +import android.app.Application +import androidx.media3.common.Player +import androidx.media3.exoplayer.ExoPlayer +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import dagger.hilt.android.scopes.ViewModelScoped + +@Module +@InstallIn(ViewModelComponent::class) +object VideoPlayerModule { + + @Provides + @ViewModelScoped + fun provideVideoPlayer(app: Application): Player { + return ExoPlayer.Builder(app) + .build() + } +} \ No newline at end of file diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts index 0df9ab2..ed698d6 100644 --- a/feature/camera/build.gradle.kts +++ b/feature/camera/build.gradle.kts @@ -57,7 +57,10 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.bundles.firebase) implementation(libs.coil.kt.compose) + + implementation(libs.permissions) implementation(libs.bundles.cameraX) + implementation(libs.bundles.exoPlayer) testImplementation(libs.bundles.test.common) androidTestImplementation(libs.bundles.android.test) diff --git a/feature/camera/src/main/AndroidManifest.xml b/feature/camera/src/main/AndroidManifest.xml index a5918e6..3939fdd 100644 --- a/feature/camera/src/main/AndroidManifest.xml +++ b/feature/camera/src/main/AndroidManifest.xml @@ -1,4 +1,5 @@ + \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/CameraPreview.kt b/feature/camera/src/main/java/com/loki/camera/CameraPreview.kt deleted file mode 100644 index eee0328..0000000 --- a/feature/camera/src/main/java/com/loki/camera/CameraPreview.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.loki.camera - -import android.util.Log -import android.view.ViewGroup -import androidx.camera.core.CameraSelector -import androidx.camera.core.ImageCapture -import androidx.camera.core.ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY -import androidx.camera.core.Preview -import androidx.camera.core.UseCase -import androidx.camera.view.PreviewView -import androidx.compose.foundation.Image -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.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Cameraswitch -import androidx.compose.material.icons.filled.Image -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import com.loki.camera.util.executor -import com.loki.camera.util.getCameraProvider -import com.loki.camera.util.takePicture -import com.loki.ui.theme.md_theme_dark_background -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.io.File - -@Composable -fun CameraPreview( - modifier: Modifier = Modifier, - scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER, - onUseCase: (UseCase) -> Unit = {} -) { - - AndroidView( - modifier = modifier, - factory = { context -> - val previewView = PreviewView(context).apply { - this.scaleType = scaleType - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ) - } - - // camerax preview usecase - onUseCase( - Preview.Builder() - .build().also { - it.setSurfaceProvider(previewView.surfaceProvider) - } - ) - - previewView - } - ) -} - -@Composable -fun CameraCapture( - modifier: Modifier = Modifier, - onImageFile: (File) -> Unit, - onGalleryClick: () -> Unit -) { - - Box(modifier = modifier) { - - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val coroutineScope = rememberCoroutineScope() - - var previewUseCase by remember { mutableStateOf(Preview.Builder().build()) } - val imageCaptureUseCase by remember { - mutableStateOf( - ImageCapture.Builder().setCaptureMode(CAPTURE_MODE_MAXIMIZE_QUALITY) - .build() - ) - } - - var isFrontCamera by remember { mutableStateOf(false) } - - val cameraSelector = if (isFrontCamera) CameraSelector.DEFAULT_FRONT_CAMERA - else CameraSelector.DEFAULT_BACK_CAMERA - - Box( - modifier = Modifier.background(md_theme_dark_background) - ) { - CameraPreview( - modifier = Modifier.fillMaxSize(), - onUseCase = { - previewUseCase = it - } - ) - - Box( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 32.dp) - .align(Alignment.BottomCenter) - ) { - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - - IconButton( - onClick = onGalleryClick, - modifier = Modifier - .padding(start = 24.dp), - ) { - Image( - imageVector = Icons.Filled.Image, - contentDescription = "image_icon", - modifier = Modifier.size(30.dp), - colorFilter = ColorFilter.tint( - Color.White - ) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - Box( - modifier = Modifier - .size(70.dp) - .clip(CircleShape) - .background(Color.White.copy(.8f)) - .clickable { - coroutineScope.launch(Dispatchers.IO) { - imageCaptureUseCase - .takePicture(context.executor) - .let { - onImageFile(it) - } - } - } - ) - Spacer(modifier = Modifier.weight(1f)) - - IconButton( - onClick = { - isFrontCamera = !isFrontCamera - }, - modifier = Modifier - .padding(end = 24.dp) - ) { - Image( - imageVector = Icons.Filled.Cameraswitch, - contentDescription = "rotate_camera_icon", - modifier = Modifier.size(30.dp), - colorFilter = ColorFilter.tint( - Color.White - ) - ) - } - } - } - } - - LaunchedEffect(key1 = cameraSelector) { - val cameraProvider = context.getCameraProvider() - - try { - cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - previewUseCase, - imageCaptureUseCase - ) - } catch (e: Exception) { - Log.e("Camera Capture", "Failed to bind camera use case", e) - } - } - } -} \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/CameraScreen.kt b/feature/camera/src/main/java/com/loki/camera/CameraScreen.kt deleted file mode 100644 index 7611a86..0000000 --- a/feature/camera/src/main/java/com/loki/camera/CameraScreen.kt +++ /dev/null @@ -1,200 +0,0 @@ -package com.loki.camera - -import android.net.Uri -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.PickVisualMediaRequest -import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -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.material.icons.Icons -import androidx.compose.material.icons.filled.Done -import androidx.compose.material.icons.filled.Sync -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.SideEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import coil.compose.rememberAsyncImagePainter -import com.loki.camera.util.StatusBarUtil -import com.loki.profile.ProfileViewModel -import com.loki.ui.components.Loading -import com.loki.ui.theme.md_theme_dark_background -import com.loki.ui.theme.md_theme_dark_secondaryContainer - -private val EMPTY_IMAGE_URI: Uri = Uri.parse("file://dev/null") - -@Composable -fun CameraScreen( - profileViewModel: ProfileViewModel, - navigateBack: () -> Unit -) { - - val appTheme by profileViewModel.isDarkTheme - - val context = LocalContext.current - val view = LocalView.current - - var imageUri by remember { mutableStateOf(EMPTY_IMAGE_URI) } - var isGalleryClicked by rememberSaveable { mutableStateOf(false) } - - // if not dark theme change colors to dark theme in this screen composable - var statusBarColor by remember { mutableStateOf(md_theme_dark_background.toArgb()) } - var isDark by remember { mutableStateOf(appTheme) } - var navBarColor by remember { mutableStateOf(md_theme_dark_background.toArgb()) } - - val defaultNavColor = StatusBarUtil.defaultNavColor(darkTheme = appTheme) - val defaultStatusBarColor = StatusBarUtil.statusBarColor() - - if (!appTheme) { - StatusBarUtil.DefaultStatusColors( - view = view, - isDark = isDark, - statusBarColor = statusBarColor, - navigationBarColor = navBarColor - ) - - BackHandler(true) { - statusBarColor = defaultStatusBarColor - navBarColor = defaultNavColor - isDark = !isDark - navigateBack() - } - } - - // open files to select images - val galleryLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.PickVisualMedia() - ) { uri -> - isGalleryClicked = false - - uri?.let { - imageUri = it - } - } - - if (isGalleryClicked) { - SideEffect { - galleryLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - } - - // onBackPress will not navigate back if image has been captured or selected - if (imageUri != EMPTY_IMAGE_URI) { - BackHandler(true) { - imageUri = EMPTY_IMAGE_URI - } - } - - if (profileViewModel.errorMessage.value.isNotBlank()) { - LaunchedEffect(key1 = profileViewModel.errorMessage.value) { - Toast.makeText( - context, - profileViewModel.errorMessage.value, - Toast.LENGTH_LONG - ).show() - } - } - - if (imageUri != EMPTY_IMAGE_URI) { - - Box( - modifier = Modifier - .fillMaxSize() - .background(md_theme_dark_background) - ) { - Image( - modifier = Modifier.fillMaxSize(), - painter = rememberAsyncImagePainter(imageUri), - contentDescription = "Captured image" - ) - IconButton( - modifier = Modifier - .align(Alignment.BottomStart) - .padding(16.dp), - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onBackground, - containerColor = md_theme_dark_secondaryContainer - ), - onClick = { - imageUri = EMPTY_IMAGE_URI - } - ) { - Image( - imageVector = Icons.Filled.Sync, - contentDescription = "Retry icon", - modifier = Modifier.size(30.dp), - colorFilter = ColorFilter.tint( - Color.White - ) - ) - } - IconButton( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(16.dp), - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onBackground, - containerColor = md_theme_dark_secondaryContainer - ), - onClick = { - profileViewModel.updateProfilePicture(imageUri) { - navigateBack() - } - } - ) { - Image( - imageVector = Icons.Filled.Done, - contentDescription = "Done icon", - modifier = Modifier.size(30.dp), - colorFilter = ColorFilter.tint( - Color.White - ) - ) - } - - if (profileViewModel.isLoading.value) { - Loading() - } - } - } else { - - Box( - modifier = Modifier - .fillMaxSize() - .background(md_theme_dark_background) - ) { - CameraCapture( - modifier = Modifier.fillMaxSize(), - onImageFile = { file -> - imageUri = file.toUri() - }, - onGalleryClick = { - isGalleryClicked = true - } - ) - } - } -} \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraPreview.kt b/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraPreview.kt new file mode 100644 index 0000000..8c5dc78 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraPreview.kt @@ -0,0 +1,428 @@ +package com.loki.camera.camera_screen + +import android.annotation.SuppressLint +import android.content.Context +import android.net.Uri +import android.util.Log +import android.view.ViewGroup +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY +import androidx.camera.core.Preview +import androidx.camera.core.UseCase +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cameraswitch +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.net.toUri +import androidx.core.util.Consumer +import com.loki.camera.util.executor +import com.loki.camera.util.getCameraProvider +import com.loki.camera.util.takePicture +import com.loki.ui.theme.md_theme_dark_background +import com.loki.ui.utils.DateUtil +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executor + +@Composable +fun CameraPreview( + modifier: Modifier = Modifier, + scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER, + onUseCase: (UseCase) -> Unit = {} +) { + + AndroidView( + modifier = modifier, + factory = { context -> + val previewView = PreviewView(context).apply { + this.scaleType = scaleType + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + // camerax preview usecase + onUseCase( + Preview.Builder() + .build().also { + it.setSurfaceProvider(previewView.surfaceProvider) + } + ) + + previewView + } + ) +} + +@Composable +fun CameraCapture( + modifier: Modifier = Modifier, + isCameraView: Boolean, + isPermissionGranted: Boolean, + onImageUri: (imageUri: Uri) -> Unit, + onGalleryClick: () -> Unit, + onPermissionRequired: (isPermissionRequired: Boolean) -> Unit, + bottomSwitchContent: @Composable () -> Unit +) { + + Box(modifier = modifier) { + + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val coroutineScope = rememberCoroutineScope() + + var previewUseCase by remember { mutableStateOf(Preview.Builder().build()) } + val imageCaptureUseCase by remember { + mutableStateOf( + ImageCapture.Builder().setCaptureMode(CAPTURE_MODE_MAXIMIZE_QUALITY) + .build() + ) + } + + var isFrontCamera by remember { mutableStateOf(false) } + val cameraSelector = if (isFrontCamera) CameraSelector.DEFAULT_FRONT_CAMERA + else CameraSelector.DEFAULT_BACK_CAMERA + + Box( + modifier = Modifier.background(md_theme_dark_background) + ) { + CameraPreview( + modifier = Modifier.fillMaxSize(), + onUseCase = { + previewUseCase = it + } + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + CaptureControls( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + isCamera = isCameraView, + onLeftControlClick = onGalleryClick, + onRotateCamera = { + isFrontCamera = !isFrontCamera + }, + onCapture = { + if (isCameraView && isPermissionGranted) { + coroutineScope.launch(Dispatchers.IO) { + imageCaptureUseCase + .takePicture(context.executor) + .let { + onImageUri(it.toUri()) + } + } + } + else { + onPermissionRequired(true) + } + } + ) + + bottomSwitchContent() + } + + } + + LaunchedEffect(key1 = cameraSelector) { + val cameraProvider = context.getCameraProvider() + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + imageCaptureUseCase + ) + } catch (e: Exception) { + Log.e("Camera Capture", "Failed to bind camera use case", e) + } + } + } +} + +@Composable +fun VideoCapture( + modifier: Modifier = Modifier, + isCameraView: Boolean, + isPermissionGranted: Boolean, + onVideoUri: (videoUri: String?) -> Unit, + onPermissionRequired: (isPermissionRequired: Boolean) -> Unit, + bottomSwitchContent: @Composable () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val coroutineScope = rememberCoroutineScope() + + var previewUseCase by remember { mutableStateOf(Preview.Builder().build()) } + + val qualitySelector = QualitySelector.from( + Quality.HD, + FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST) + ) + + val record = Recorder.Builder() + .setExecutor(context.executor) + .setQualitySelector(qualitySelector) + .build() + + val videoCaptureUseCase = VideoCapture.withOutput(record) + + var recording = remember { null } + + var recordingStarted by remember { mutableStateOf(false) } + var audioEnabled by remember { mutableStateOf(true) } + + var isFrontCamera by remember { mutableStateOf(false) } + val cameraSelector = if (isFrontCamera) CameraSelector.DEFAULT_FRONT_CAMERA + else CameraSelector.DEFAULT_BACK_CAMERA + + Box(modifier = modifier) { + CameraPreview( + modifier = Modifier.fillMaxSize(), + onUseCase = { + previewUseCase = it + } + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + ) { + CaptureControls( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + isCamera = isCameraView, + isRecording = recordingStarted, + audioEnabled = audioEnabled, + onLeftControlClick = { + if (!recordingStarted && !isCameraView) { + audioEnabled = !audioEnabled + } + }, + onRotateCamera = { + if (!recordingStarted && !isCameraView) { + isFrontCamera = !isFrontCamera + } + }, + onCapture = { + + coroutineScope.launch(Dispatchers.IO) { + if (!isCameraView && isPermissionGranted) { + if (!recordingStarted) { + recordingStarted = true + + val mediaDir = context.externalCacheDirs.firstOrNull()?.let { + File(it, "Reet").apply { mkdirs() } + } + + val outputDirectory = + if (mediaDir != null && mediaDir.exists()) mediaDir else + context.filesDir + + recording = startRecordingVideo( + context = context, + videoCapture = videoCaptureUseCase, + outputDirectory = outputDirectory, + executor = context.executor, + audioEnabled = audioEnabled, + consumer = { event -> + + when (event) { + + is VideoRecordEvent.Finalize -> { + val uri = event.outputResults.outputUri + + if (uri != Uri.EMPTY) { + val uriEncoded = URLEncoder.encode( + uri.toString(), + StandardCharsets.UTF_8.toString() + ) + onVideoUri(uriEncoded) + } + } + } + } + ) + } else { + recordingStarted = false + recording?.stop() + recording = null + } + } else { + onPermissionRequired(true) + } + } + } + ) + bottomSwitchContent() + } + } + + LaunchedEffect(key1 = recordingStarted, key2 = cameraSelector, key3 = recording) { + val cameraProvider = context.getCameraProvider() + + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + videoCaptureUseCase + ) + } catch (e: Exception) { + Log.e("Camera Capture", "Failed to bind camera use case", e) + } + } +} + +@SuppressLint("MissingPermission") +fun startRecordingVideo( + context: Context, + videoCapture: VideoCapture, + outputDirectory: File, + executor: Executor, + audioEnabled: Boolean, + consumer: Consumer +): Recording { + + val videoFile = File( + outputDirectory, + DateUtil.getFileName() + ".mp4" + ) + + val outputOptions = FileOutputOptions.Builder(videoFile).build() + + + return videoCapture.output + .prepareRecording(context, outputOptions) + .apply { + if (audioEnabled) withAudioEnabled() + } + .start(executor, consumer) + +} + +@Composable +fun CaptureControls( + modifier: Modifier = Modifier, + isCamera: Boolean, + isRecording: Boolean = false, + audioEnabled: Boolean = true, + onLeftControlClick: () -> Unit, + onRotateCamera: () -> Unit, + onCapture: () -> Unit +) { + + val recordingBackground = if (isRecording) Color.Red else Color.Red.copy(.4f) + val captureBackground = if (isCamera) Color.White.copy(.8f) else recordingBackground + + val recordingLeftControlIcon = if (audioEnabled) Icons.Filled.Mic else Icons.Filled.MicOff + val leftControl = if (isCamera) Icons.Filled.Image else recordingLeftControlIcon + + Box( + modifier = modifier + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + + IconButton( + onClick = onLeftControlClick, + modifier = Modifier + .padding(start = 24.dp), + ) { + Icon( + imageVector = leftControl, + contentDescription = "image_icon", + modifier = Modifier.size(30.dp), + tint = Color.White + ) + } + + Spacer(modifier = Modifier.weight(1f)) + Box( + modifier = Modifier + .size(70.dp) + .clip(CircleShape) + .background(captureBackground) + .border( + width = if (isRecording) 2.dp else 0.dp, + color = if (isRecording) Color.White else Color.Transparent, + shape = CircleShape + ) + .clickable { + onCapture() + } + ) + Spacer(modifier = Modifier.weight(1f)) + + IconButton( + onClick = onRotateCamera, + modifier = Modifier + .padding(end = 24.dp) + ) { + Icon( + imageVector = Icons.Filled.Cameraswitch, + contentDescription = "rotate_camera_icon", + modifier = Modifier.size(30.dp), + tint = Color.White + ) + } + } + } +} \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraScreen.kt b/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraScreen.kt new file mode 100644 index 0000000..3fc65f8 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraScreen.kt @@ -0,0 +1,379 @@ +package com.loki.camera.camera_screen + +import android.Manifest +import android.net.Uri +import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.rememberAsyncImagePainter +import com.loki.camera.util.StatusBarUtil +import com.loki.profile.ProfileViewModel +import com.loki.ui.components.Loading +import com.loki.ui.permission.PermissionAction +import com.loki.ui.permission.PermissionDialog +import com.loki.ui.theme.md_theme_dark_background +import com.loki.ui.theme.md_theme_dark_secondaryContainer + +private val EMPTY_IMAGE_URI: Uri = Uri.parse("file://dev/null") + +@Composable +fun CameraScreen( + profileViewModel: ProfileViewModel, + navigateToVideoPlayer: (uri: String) -> Unit, + navigateBack: () -> Unit +) { + + val appTheme by profileViewModel.isDarkTheme + + val context = LocalContext.current + val view = LocalView.current + + val snackbarHostState = remember { SnackbarHostState() } + var showPermissionDialog by remember { mutableStateOf(false) } + var isPermissionGranted by remember { mutableStateOf(false) } + + // camera states + var isCameraView by remember { mutableStateOf(true) } + var imageUri by remember { mutableStateOf(EMPTY_IMAGE_URI) } + var isGalleryClicked by rememberSaveable { mutableStateOf(false) } + + // if not dark theme change colors to dark theme in this screen composable + var statusBarColor by remember { mutableStateOf(md_theme_dark_background.toArgb()) } + var isDark by remember { mutableStateOf(appTheme) } + var navBarColor by remember { mutableStateOf(md_theme_dark_background.toArgb()) } + + val defaultNavColor = StatusBarUtil.defaultNavColor(darkTheme = appTheme) + val defaultStatusBarColor = StatusBarUtil.statusBarColor() + + if (!appTheme) { + StatusBarUtil.DefaultStatusColors( + view = view, + isDark = isDark, + statusBarColor = statusBarColor, + navigationBarColor = navBarColor + ) + + BackHandler(true) { + statusBarColor = defaultStatusBarColor + navBarColor = defaultNavColor + isDark = !isDark + navigateBack() + } + } + + val permissions = listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + val permissionRationales = mapOf( + Manifest.permission.CAMERA to "Camera permission is required for taking photos and video recording", + Manifest.permission.RECORD_AUDIO to "Record Audio permission is required for video recording" + ) + + if (showPermissionDialog) { + PermissionDialog( + context = context, + permissions = permissions, + permissionRationale = permissionRationales, + snackbarHostState = snackbarHostState, + permissionAction = { permissionsAction -> + + for ((permission, action) in permissionsAction) { + when (action) { + is PermissionAction.PermissionGranted -> { + isPermissionGranted = true + Toast.makeText( + context, + "Permission Granted", + Toast.LENGTH_SHORT + ).show() + } + + is PermissionAction.PermissionDenied -> { + isPermissionGranted = false + Toast.makeText( + context, + "Permission Not Granted", + Toast.LENGTH_SHORT + ).show() + } + + is PermissionAction.PermissionAlreadyGranted -> { + isPermissionGranted = true + } + } + } + } + ) + } + + // open files to select images + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + isGalleryClicked = false + + uri?.let { + imageUri = it + } + } + + if (isGalleryClicked) { + SideEffect { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + } + + // onBackPress will not navigate back if image has been captured or selected + if (imageUri != EMPTY_IMAGE_URI) { + BackHandler(true) { + imageUri = EMPTY_IMAGE_URI + } + } + + if (profileViewModel.errorMessage.value.isNotBlank()) { + LaunchedEffect(key1 = profileViewModel.errorMessage.value) { + Toast.makeText( + context, + profileViewModel.errorMessage.value, + Toast.LENGTH_LONG + ).show() + } + } + + if (imageUri != EMPTY_IMAGE_URI) { + + Box( + modifier = Modifier + .fillMaxSize() + .background(md_theme_dark_background) + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = rememberAsyncImagePainter(imageUri), + contentDescription = "Captured image" + ) + IconButton( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + containerColor = md_theme_dark_secondaryContainer + ), + onClick = { + imageUri = EMPTY_IMAGE_URI + } + ) { + Image( + imageVector = Icons.Filled.Sync, + contentDescription = "Retry icon", + modifier = Modifier.size(30.dp), + colorFilter = ColorFilter.tint( + Color.White + ) + ) + } + IconButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(16.dp), + colors = IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.onBackground, + containerColor = md_theme_dark_secondaryContainer + ), + onClick = { + profileViewModel.updateProfilePicture(imageUri) { + navigateBack() + } + } + ) { + Image( + imageVector = Icons.Filled.Done, + contentDescription = "Done icon", + modifier = Modifier.size(30.dp), + colorFilter = ColorFilter.tint( + Color.White + ) + ) + } + + if (profileViewModel.isLoading.value) { + Loading() + } + } + + } else { + + if (isCameraView) { + + Box( + modifier = Modifier + .fillMaxSize() + .background(md_theme_dark_background) + ) { + CameraCapture( + modifier = Modifier.fillMaxSize(), + isCameraView = isCameraView, + isPermissionGranted = isPermissionGranted, + onImageUri = { uri -> + imageUri = uri + }, + onPermissionRequired = { + showPermissionDialog = it + }, + onGalleryClick = { + isGalleryClicked = true + } + ) { + BottomContent( + onViewSelected = { + isCameraView = it == Preview.CAMERA + } + ) + } + } + + } else { + + VideoCapture( + modifier = Modifier.fillMaxSize(), + isCameraView = isCameraView, + isPermissionGranted = isPermissionGranted, + onVideoUri = { uri -> + uri?.let(navigateToVideoPlayer) + }, + onPermissionRequired = { + showPermissionDialog = it + }, + bottomSwitchContent = { + BottomContent( + onViewSelected = { + isCameraView = it == Preview.CAMERA + } + ) + } + ) + } + } +} + +@Composable +fun BottomContent( + modifier: Modifier = Modifier, + onViewSelected: (Preview) -> Unit +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center + ) { + + val titles = listOf("Camera", "Video") + var selectedTitle by remember { mutableStateOf(titles[0]) } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + + titles.forEachIndexed { index, title -> + + val selected = titles[index] == title + + PreviewSelectables( + title = title, + selected = selected, + onClick = { + selectedTitle = title + if (title == "Camera") { + onViewSelected(Preview.CAMERA) + } else { + onViewSelected(Preview.VIDEO) + } + } + ) + } + } + } +} + +enum class Preview { + CAMERA, + VIDEO +} + +@Composable +fun PreviewSelectables( + modifier: Modifier = Modifier, + title: String, + titleSize: Int = 18, + cornerRadius: Dp = 16.dp, + spacing: Dp = 8.dp, + selected: Boolean, + selectedBackground: Color = Color.Black.copy(.3f), + selectedTitleColor: Color = Color.White, + onClick: () -> Unit +) { + + val backgroundColor by rememberUpdatedState( if (selected) selectedBackground else Color.Transparent) + val titleColor by rememberUpdatedState( if(selected) selectedTitleColor else Color.White.copy(.5f)) + + Box( + modifier = modifier + .background( + color = backgroundColor, + shape = RoundedCornerShape(cornerRadius) + ) + .clip(RoundedCornerShape(cornerRadius)) + .clickable { onClick() } + ) { + Text( + text = title, + color = titleColor, + fontSize = titleSize.sp, + modifier = Modifier.padding(spacing) + ) + } +} \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt b/feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt index e344fa5..c6f0903 100644 --- a/feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt +++ b/feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt @@ -2,10 +2,19 @@ package com.loki.camera.util import android.content.Context import android.util.Log +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.video.FallbackStrategy +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture +import androidx.camera.view.PreviewView import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File diff --git a/feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerScreen.kt b/feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerScreen.kt new file mode 100644 index 0000000..6fcf9e4 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerScreen.kt @@ -0,0 +1,66 @@ +package com.loki.camera.video_screen + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +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.platform.LocalLifecycleOwner +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.media3.ui.PlayerView + +@Composable +fun VideoPlayerScreen( + viewModel: VideoPlayerViewModel +) { + + var lifecycle by remember { mutableStateOf(Lifecycle.Event.ON_CREATE) } + + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(key1 = lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + lifecycle = event + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + Box(modifier = Modifier.fillMaxSize()) { + + AndroidView( + factory = { context -> + PlayerView(context).also { + it.player = viewModel.player + } + }, + update = { + when(lifecycle) { + Lifecycle.Event.ON_PAUSE -> { + it.onPause() + it.player?.pause() + } + + Lifecycle.Event.ON_RESUME -> { + it.onResume() + } + + else -> Unit + } + }, + modifier = Modifier.fillMaxSize() + ) + } +} \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerViewModel.kt b/feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerViewModel.kt new file mode 100644 index 0000000..5802def --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerViewModel.kt @@ -0,0 +1,35 @@ +package com.loki.camera.video_screen + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import com.loki.ui.utils.Constants.VIDEO_URI +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class VideoPlayerViewModel @Inject constructor( + val player: Player, + savedStateHandle: SavedStateHandle +): ViewModel() { + + init { + savedStateHandle.get(VIDEO_URI)?.let { + playVideo(it) + } + player.prepare() + } + + private fun playVideo(uri: String) { + player.setMediaItem( + MediaItem.fromUri(uri) + ) + } + + override fun onCleared() { + super.onCleared() + player.release() + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/loki/home/home/HomeScreen.kt b/feature/home/src/main/java/com/loki/home/home/HomeScreen.kt index e628444..9858081 100644 --- a/feature/home/src/main/java/com/loki/home/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/loki/home/home/HomeScreen.kt @@ -2,7 +2,6 @@ package com.loki.home.home import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LocalAbsoluteTonalElevation import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -13,8 +12,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( nestedNavGraph: @Composable () -> Unit, @@ -26,7 +23,9 @@ fun HomeScreen( Scaffold(bottomBar = bottomBar) { padding -> Surface( - modifier = Modifier.fillMaxSize().padding(padding), + modifier = Modifier + .fillMaxSize() + .padding(padding), color = if (color == Color.Unspecified) Color.Transparent else color ) { CompositionLocalProvider( diff --git a/feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt index 479b9d2..ac7f6b0 100644 --- a/feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt +++ b/feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt @@ -1,6 +1,5 @@ package com.loki.profile.profile -import android.Manifest import android.content.Intent import android.net.Uri import android.widget.Toast @@ -33,15 +32,10 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -55,8 +49,6 @@ import com.loki.profile.ProfileViewModel import com.loki.ui.components.AppTopBar import com.loki.ui.components.ExtendedRowItem import com.loki.ui.components.ProfileCircleBox -import com.loki.ui.permission.PermissionAction -import com.loki.ui.permission.PermissionDialog @Composable fun ProfileScreen( @@ -70,9 +62,6 @@ fun ProfileScreen( val localProfile by viewModel.localProfile.collectAsStateWithLifecycle() val localUser by viewModel.localUser.collectAsStateWithLifecycle() val context = LocalContext.current - val snackbarHostState = remember { SnackbarHostState() } - - var isPermissionDialogClicked by rememberSaveable { mutableStateOf(false) } val openIntent = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), @@ -89,43 +78,6 @@ fun ProfileScreen( } } - if (isPermissionDialogClicked) { - PermissionDialog( - context = context, - permission = Manifest.permission.CAMERA, - permissionRationale = Manifest.permission.CAMERA, - snackbarHostState = snackbarHostState, - permissionAction = { action -> - - when (action) { - is PermissionAction.PermissionGranted -> { - Toast.makeText( - context, - "Permission Granted", - Toast.LENGTH_SHORT - ).show() - isPermissionDialogClicked = false - navigateToCamera() - } - - is PermissionAction.PermissionDenied -> { - Toast.makeText( - context, - "Permission Not Granted", - Toast.LENGTH_SHORT - ).show() - isPermissionDialogClicked = false - } - - is PermissionAction.PermissionAlreadyGranted -> { - isPermissionDialogClicked = false - navigateToCamera() - } - } - } - ) - } - Scaffold ( topBar = { AppTopBar( @@ -149,9 +101,7 @@ fun ProfileScreen( backgroundColor = Color(localProfile.profileBackground), initials = localProfile.userNameInitials, imageUri = localProfile.profileImage, - onEditClick = { - isPermissionDialogClicked = true - } + onEditClick = navigateToCamera ) } diff --git a/feature/report/src/main/java/com/loki/report/ReportViewModel.kt b/feature/report/src/main/java/com/loki/report/ReportViewModel.kt index 440575e..e686545 100644 --- a/feature/report/src/main/java/com/loki/report/ReportViewModel.kt +++ b/feature/report/src/main/java/com/loki/report/ReportViewModel.kt @@ -149,6 +149,7 @@ class ReportViewModel @Inject constructor( fun deleteComment() { launchCatching { comments.deleteComment(editableComment.value.id) + message.value = "Comment Deleted" } } } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f71ad18..551a2fd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -44,6 +44,8 @@ camerax = "1.3.0-rc01" junit = "1.1.5" appcompat = "1.6.1" material = "1.8.0" +exoPlayer = "1.1.1" +accompanistPermission = "0.33.1-alpha" [libraries] @@ -140,6 +142,14 @@ androidx-camera-video = { group = "androidx.camera", name = "camera-video", ver androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } androidx-camera-extensions = { group = "androidx.camera", name = "camera-extensions", version.ref = "camerax" } + +# exo player +exoPlayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref="exoPlayer" } +exoPlayerUi = { group = "androidx.media3", name = "media3-ui", version.ref="exoPlayer" } + +# accompanist libs +permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermission" } + junit = { group = "androidx.test.ext", name = "junit", version.ref = "junit" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -242,4 +252,9 @@ cameraX = [ "androidx-camera-view", "androidx-camera-camera2", "androidx-camera-extensions" +] + +exoPlayer = [ + "exoPlayer", + "exoPlayerUi" ] \ No newline at end of file