From ca60e8f24e9a5754e63899ff1be6626ff340a2f2 Mon Sep 17 00:00:00 2001 From: lokified Date: Tue, 29 Aug 2023 17:53:39 +0300 Subject: [PATCH 1/5] add a forgot password screen and change username screen --- .../java/com/loki/navigation/Navigation.kt | 33 +++- .../main/java/com/loki/navigation/Screen.kt | 2 + .../com/loki/navigation/graph/AccountGraph.kt | 2 + .../com/loki/navigation/graph/RootGraph.kt | 4 +- .../com/loki/ui/utils/TextFieldColorUtil.kt | 2 +- .../com/loki/ui/viewmodel/ReetViewModel.kt | 38 ++-- .../loki/local/datastore/DataStoreStorage.kt | 1 + .../local/datastore/DataStoreStorageImpl.kt | 5 +- .../local/datastore/model/LocalProfile.kt | 1 + .../remote/profiles/ProfilesRepositoryImpl.kt | 8 +- .../forgotPassword/ForgotPasswordScreen.kt | 186 ++++++++++++++++++ .../forgotPassword/ForgotPasswordState.kt | 7 + .../forgotPassword/ForgotPasswordViewModel.kt | 44 +++++ .../java/com/loki/auth/login/LoginScreen.kt | 36 ++-- .../com/loki/auth/login/LoginViewModel.kt | 155 +++++++-------- .../com/loki/auth/register/RegisterScreen.kt | 10 +- .../loki/auth/register/RegisterViewModel.kt | 62 +++--- .../loki/home/report_list/ReportListScreen.kt | 5 +- .../home/report_list/ReportListViewModel.kt | 2 - .../com/loki/new_report/NewReportScreen.kt | 42 ++-- .../com/loki/new_report/NewReportViewModel.kt | 5 - .../java/com/loki/profile/ProfileViewModel.kt | 55 ++++-- .../profile/{ => profile}/ProfileScreen.kt | 123 +++--------- .../profile/{ => profile}/ProfileUiState.kt | 2 +- .../profile/username/UsernameChangeScreen.kt | 99 ++++++++++ .../main/java/com/loki/report/ReportScreen.kt | 9 +- .../java/com/loki/report/ReportViewModel.kt | 2 - 27 files changed, 615 insertions(+), 325 deletions(-) create mode 100644 feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordScreen.kt create mode 100644 feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordState.kt create mode 100644 feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordViewModel.kt rename feature/profile/src/main/java/com/loki/profile/{ => profile}/ProfileScreen.kt (69%) rename feature/profile/src/main/java/com/loki/profile/{ => profile}/ProfileUiState.kt (80%) create mode 100644 feature/profile/src/main/java/com/loki/profile/username/UsernameChangeScreen.kt diff --git a/core/navigation/src/main/java/com/loki/navigation/Navigation.kt b/core/navigation/src/main/java/com/loki/navigation/Navigation.kt index 2c5d232..84e9d20 100644 --- a/core/navigation/src/main/java/com/loki/navigation/Navigation.kt +++ b/core/navigation/src/main/java/com/loki/navigation/Navigation.kt @@ -10,6 +10,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.loki.auth.forgotPassword.ForgotPasswordScreen +import com.loki.auth.forgotPassword.ForgotPasswordViewModel import com.loki.auth.login.LoginScreen import com.loki.auth.login.LoginViewModel import com.loki.auth.register.RegisterScreen @@ -25,8 +27,9 @@ import com.loki.new_report.NewReportScreen import com.loki.new_report.NewReportViewModel import com.loki.news.NewsScreen import com.loki.news.NewsViewModel -import com.loki.profile.ProfileScreen +import com.loki.profile.profile.ProfileScreen import com.loki.profile.ProfileViewModel +import com.loki.profile.username.UsernameChangeScreen import com.loki.report.ReportScreen import com.loki.report.ReportViewModel import com.loki.settings.SettingsScreen @@ -89,22 +92,32 @@ fun NavGraphBuilder.loginScreen(viewModel: NavigationViewModel, navigateTo: (Scr LoginScreen( viewModel = loginViewModel, navigateToRegister = { navigateTo(Screen.RegisterScreen) }, + navigateToForgotScreen = { navigateTo(Screen.ForgotPasswordScreen) }, navigateToHome = { navigateTo(Screen.HomeScreen.withClearBackStack()) } ) } } } -fun NavGraphBuilder.registerScreen(navigateTo: (Screen) -> Unit) { +fun NavGraphBuilder.registerScreen(navigateBack: () -> Unit) { composable(route = Screen.RegisterScreen.route) { val viewModel = hiltViewModel() RegisterScreen( viewModel = viewModel, - navigateToLogin = { navigateTo(Screen.LoginScreen) } + navigateToLogin = navigateBack ) } } +fun NavGraphBuilder.forgotPasswordScreen(navigateBack: () -> Unit) { + composable(route = Screen.ForgotPasswordScreen.route) { + val viewModel = hiltViewModel() + ForgotPasswordScreen( + viewModel = viewModel, + navigateBack = navigateBack + ) + } +} fun NavGraphBuilder.reportNavGraph(viewModel: NavigationViewModel) { composable(route = Screen.ReportListScreen.route) { @@ -199,11 +212,25 @@ fun NavGraphBuilder.profileScreen(onNavigateTo: (Screen) -> Unit, onNavigateToLo ProfileScreen( viewModel = profileViewModel, navigateToSettings = { onNavigateTo(Screen.SettingsScreen) }, + navigateToChangeUsername = { onNavigateTo(Screen.UsernameChangeScreen) }, navigateToLogin = { onNavigateToLogin(Screen.LoginScreen) } ) } } +fun NavGraphBuilder.usernameChangeScreen(onNavigateBack: () -> Unit, viewModel: NavigationViewModel) { + composable(route = Screen.UsernameChangeScreen.route) { + LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { + viewModel.setBottomBarVisible(false) + } + val profileViewModel = hiltViewModel() + UsernameChangeScreen( + viewModel = profileViewModel, + navigateBack = onNavigateBack + ) + } +} + fun NavGraphBuilder.settingsScreen(onNavigateBack: () -> Unit, viewModel: NavigationViewModel) { composable(route = Screen.SettingsScreen.route) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { 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 ba7b97c..1c5c5da 100644 --- a/core/navigation/src/main/java/com/loki/navigation/Screen.kt +++ b/core/navigation/src/main/java/com/loki/navigation/Screen.kt @@ -26,12 +26,14 @@ sealed class Screen( 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 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) object ProfileScreen: Screen("profile_screen", title = "Profile", icon = Icons.Filled.AccountCircle) + object UsernameChangeScreen: Screen("change_username_screen") object SettingsScreen: Screen("settings_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 9a2e3bb..6da8d2e 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 @@ -9,6 +9,7 @@ import com.loki.navigation.Screen import com.loki.navigation.ext.navigateTo import com.loki.navigation.profileScreen import com.loki.navigation.settingsScreen +import com.loki.navigation.usernameChangeScreen @Composable fun AccountNavGraph( @@ -24,6 +25,7 @@ fun AccountNavGraph( modifier = modifier ) { profileScreen(onNavigateTo = navController::navigateTo, viewModel = viewModel, onNavigateToLogin = onNavigateToLogin) + usernameChangeScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) settingsScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) } } \ No newline at end of file diff --git a/core/navigation/src/main/java/com/loki/navigation/graph/RootGraph.kt b/core/navigation/src/main/java/com/loki/navigation/graph/RootGraph.kt index 37c4ad7..b72b36f 100644 --- a/core/navigation/src/main/java/com/loki/navigation/graph/RootGraph.kt +++ b/core/navigation/src/main/java/com/loki/navigation/graph/RootGraph.kt @@ -11,6 +11,7 @@ import com.loki.navigation.NavigationViewModel import com.loki.navigation.Screen import com.loki.navigation.ext.clearAndRestart import com.loki.navigation.ext.navigateTo +import com.loki.navigation.forgotPasswordScreen import com.loki.navigation.homeNavGraph import com.loki.navigation.loginScreen import com.loki.navigation.registerScreen @@ -40,7 +41,8 @@ fun RootNavGraph( loginScreen(viewModel = viewModel, navigateTo = navController::navigateTo) - registerScreen(navigateTo = navController::navigateTo) + registerScreen(navigateBack = navController::navigateUp) + forgotPasswordScreen(navigateBack = navController::navigateUp) homeNavGraph( onNavigateToLogin = { logoutEvent.value = true diff --git a/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt b/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt index b3abc8f..90046c7 100644 --- a/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt +++ b/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.graphics.Color object TextFieldColorUtil { @Composable - fun defaultColorField(isDarkTheme: Boolean) = if (isDarkTheme) MaterialTheme.colorScheme.onBackground + private fun defaultColorField(isDarkTheme: Boolean) = if (isDarkTheme) MaterialTheme.colorScheme.onBackground else MaterialTheme.colorScheme.primary @Composable 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 5b98243..1cd0271 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 @@ -18,7 +18,7 @@ import javax.inject.Inject open class ReetViewModel( private val dataStore: DataStoreStorage - ): ViewModel() { +): ViewModel() { // app theme val isDarkTheme = mutableStateOf(true) @@ -38,44 +38,34 @@ open class ReetViewModel( var errorMessage = mutableStateOf("") var isLoading = mutableStateOf(false) - //user setup values - val localUser = mutableStateOf(LocalUser()) - var userId = mutableStateOf("") - var completeProfileUserName = mutableStateOf("") + //user values + private val _localUser = MutableStateFlow(LocalUser()) + val localUser = _localUser.asStateFlow() //profile values - val userInitial = mutableStateOf("") - var localProfile = mutableStateOf(LocalProfile()) + private val _localProfile = MutableStateFlow(LocalProfile()) + val localProfile = _localProfile.asStateFlow() init { + getUser() + getLocalProfile() getAppTheme() } fun getUser() { viewModelScope.launch { dataStore.getUser().collect { - localUser.value = LocalUser( + _localUser.value = LocalUser( userId = it.userId, name = it.name, email = it.email, isLoggedIn = it.isLoggedIn, ) - - var firstName = "R" - var lastName = "U" - - if (it.name.isNotBlank()) { - val userList = it.name.split(" ") - firstName = userList[0][0].toString() - lastName = userList[1][0].toString() - } - - userInitial.value = firstName + lastName } } } - suspend fun updateUser(localUser: LocalUser) { + fun updateUser(localUser: LocalUser) { viewModelScope.launch { dataStore.saveUser(localUser) } @@ -84,17 +74,17 @@ open class ReetViewModel( fun getLocalProfile() { viewModelScope.launch { dataStore.getProfile().collect { - localProfile.value = LocalProfile( + _localProfile.value = LocalProfile( + id = it.id, userName = it.userName, + userNameInitials = it.userNameInitials, profileBackground = it.profileBackground ) - Log.d("profile:reet", it.userName) - } } } - suspend fun updateProfile(localProfile: LocalProfile) { + fun updateProfile(localProfile: LocalProfile) { viewModelScope.launch { dataStore.saveProfile(localProfile) } diff --git a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt index 203baa0..55b4f7f 100644 --- a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt +++ b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt @@ -31,6 +31,7 @@ interface DataStoreStorage { object ProfilePreference { val PROFILE_ID_KEY = stringPreferencesKey("profile_id_key") val USER_USERNAME_KEY = stringPreferencesKey("user_username_key") + val USER_USERNAME_INITIALS_KEY = stringPreferencesKey("user_username_initials_key") val USER_BACKGROUND_KEY = longPreferencesKey("user_background_key") } diff --git a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt index c6f3226..ca6b978 100644 --- a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt +++ b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.loki.local.datastore.DataStoreStorage.ProfilePreference.PROFILE_ID_KEY import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_BACKGROUND_KEY +import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_USERNAME_INITIALS_KEY import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_USERNAME_KEY import com.loki.local.datastore.DataStoreStorage.ThemePreference.IS_DARK_THEME_KEY import com.loki.local.datastore.DataStoreStorage.UserPreferences.USER_EMAIL_KEY @@ -47,6 +48,7 @@ class DataStoreStorageImpl @Inject constructor( datastore.edit { preference -> preference[PROFILE_ID_KEY] = localProfile.id preference[USER_USERNAME_KEY] = localProfile.userName + preference[USER_USERNAME_INITIALS_KEY] = localProfile.userNameInitials preference[USER_BACKGROUND_KEY] = localProfile.profileBackground!! } } @@ -56,9 +58,10 @@ class DataStoreStorageImpl @Inject constructor( return datastore.data.map { preferences -> val id = preferences[PROFILE_ID_KEY] ?: "" val username = preferences[USER_USERNAME_KEY] ?: "" + val usernameInitials = preferences[USER_USERNAME_INITIALS_KEY] ?: "" val background = preferences[USER_BACKGROUND_KEY] ?: 0xFFF1736A - LocalProfile(id, username, background) + LocalProfile(id, username, usernameInitials, background) } } diff --git a/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt b/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt index 9ceb905..159ad01 100644 --- a/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt +++ b/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt @@ -3,5 +3,6 @@ package com.loki.local.datastore.model data class LocalProfile( val id: String = "", val userName: String = "", + val userNameInitials: String = "", val profileBackground: Long = 0xFFF1736A ) diff --git a/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt b/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt index 6894305..4322614 100644 --- a/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt +++ b/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt @@ -28,7 +28,6 @@ class ProfilesRepositoryImpl @Inject constructor( profile = if (profiles.size == 0) { null } else { - profileIds.value = profiles[0].id profiles[0] } @@ -47,18 +46,15 @@ class ProfilesRepositoryImpl @Inject constructor( override suspend fun updateUsername(profile: Profile) { trace(UPDATE_PROFILE_USERNAME_TRACE) { - val profileWithUsername = profile.copy(userName = profile.userName) storage.collection(USER_PROFILE_COLLECTIONS) - .document(profileIds.value) - .set(profileWithUsername) + .document(profile.id) + .set(profile) .await() } } companion object { - val profileIds = mutableStateOf("") - //collections const val USER_PROFILE_COLLECTIONS = "profile_collections" const val USER_FIELD_ID = "userId" diff --git a/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordScreen.kt b/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordScreen.kt new file mode 100644 index 0000000..50496dc --- /dev/null +++ b/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordScreen.kt @@ -0,0 +1,186 @@ +package com.loki.auth.forgotPassword + +import android.widget.Toast +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.loki.ui.components.AppTopBar +import com.loki.ui.utils.TextFieldColorUtil.colors + +@Composable +fun ForgotPasswordScreen( + viewModel: ForgotPasswordViewModel, + navigateBack: () -> Unit +) { + + val uiState by viewModel.state + var isFieldVisible by remember { mutableStateOf(true) } + val context = LocalContext.current + val float by animateFloatAsState(targetValue = 0f, label = "button_animation") + + Scaffold( + topBar = { + AppTopBar( + leadingItem = { + IconButton(onClick = navigateBack) { + Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null) + } + Text(text = "Change Password", fontSize = 22.sp, fontWeight = FontWeight.Bold) + } + ) + } + ) { padding -> + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(padding) + .padding(16.dp) + ) { + + if(isFieldVisible) { + SendResetLinkContent( + value = uiState.email, + onValueChange = viewModel::onEmailChange, + error = uiState.emailError, + isError = uiState.isEmailError, + isEnabled = !viewModel.isLoading.value, + isDarkTheme = viewModel.isDarkTheme.value, + onSendClick = { + viewModel.sendResetLink { + isFieldVisible = false + } + } + ) + } + else{ + SuccessResetLinkContent { + navigateBack() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + if (viewModel.isLoading.value) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + + if (viewModel.errorMessage.value.isNotBlank()) { + LaunchedEffect(key1 = viewModel.errorMessage.value ) { + + Toast.makeText( + context, + viewModel.errorMessage.value, + Toast.LENGTH_LONG + ).show() + } + } + } + } + } +} + +@Composable +fun SendResetLinkContent( + value: String, + onValueChange: (String) -> Unit, + error: String, + isError: Boolean, + isEnabled: Boolean, + isDarkTheme: Boolean, + onSendClick: () -> Unit +) { + + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + + Text(text = "An Email will be sent with instructions on how to set up your new password.") + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + isError = isError, + label = { + Text(text = "Enter Email") + }, + modifier = Modifier + .fillMaxWidth(), + supportingText = { + if (isError) { + Text(text = error, color = MaterialTheme.colorScheme.error, fontSize = 12.sp) + } + }, + enabled = isEnabled, + colors = colors(isDarkTheme) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onSendClick, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(text = "Send Password Reset Link") + } + } +} + +@Composable +fun SuccessResetLinkContent( + onLoginClick: () -> Unit +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(24.dp) + ) { + + Text(text = "Instructions has been sent to your email.") + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = onLoginClick, + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + ) { + Text(text = "Go Back To Login") + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordState.kt b/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordState.kt new file mode 100644 index 0000000..1a79334 --- /dev/null +++ b/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordState.kt @@ -0,0 +1,7 @@ +package com.loki.auth.forgotPassword + +data class ForgotPasswordState( + val email: String = "", + val emailError: String = "", + val isEmailError: Boolean = false +) diff --git a/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordViewModel.kt b/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordViewModel.kt new file mode 100644 index 0000000..4a7c34e --- /dev/null +++ b/feature/auth/src/main/java/com/loki/auth/forgotPassword/ForgotPasswordViewModel.kt @@ -0,0 +1,44 @@ +package com.loki.auth.forgotPassword + +import androidx.compose.runtime.mutableStateOf +import com.loki.auth.util.ext.isValidEmail +import com.loki.local.datastore.DataStoreStorage +import com.loki.remote.auth.AuthRepository +import com.loki.ui.viewmodel.ReetViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ForgotPasswordViewModel @Inject constructor( + dataStore: DataStoreStorage, + private val auth: AuthRepository +): ReetViewModel(dataStore) { + + var state = mutableStateOf(ForgotPasswordState()) + private set + + private val email + get() = state.value.email + + fun onEmailChange(newValue: String) { + state.value = state.value.copy(email = newValue.trim()) + if (email.isValidEmail()) { + state.value = state.value.copy(emailError = "", isEmailError = false) + } + } + + fun sendResetLink(onSuccess: () -> Unit) { + if (!email.isValidEmail()) { + state.value = state.value.copy( + emailError = "Email is not valid", + isEmailError = true + ) + return + } + + launchCatching { + auth.sendRecoveryEmail(email) + onSuccess() + } + } +} \ No newline at end of file diff --git a/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt b/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt index 013b52c..43da000 100644 --- a/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt +++ b/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt @@ -1,6 +1,7 @@ package com.loki.auth.login import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,6 +47,7 @@ import com.loki.ui.utils.TextFieldColorUtil.colors fun LoginScreen( viewModel: LoginViewModel, navigateToRegister: () -> Unit, + navigateToForgotScreen: () -> Unit, navigateToHome: () -> Unit ) { @@ -78,8 +80,23 @@ fun LoginScreen( } } + if (viewModel.isLoading.value) { + Loading() + } + + if (viewModel.errorMessage.value.isNotBlank()) { + LaunchedEffect(key1 = viewModel.errorMessage.value) { + Toast.makeText( + context, + viewModel.errorMessage.value, + Toast.LENGTH_LONG + ).show() + } + } + Box( modifier = Modifier.fillMaxSize() + .background(MaterialTheme.colorScheme.background) ) { Column( @@ -133,7 +150,7 @@ fun LoginScreen( fontSize = 18.sp, modifier = Modifier .padding(vertical = 4.dp) - .clickable { }, + .clickable { navigateToForgotScreen() }, color = textColor ) @@ -157,6 +174,7 @@ fun LoginScreen( modifier = Modifier .fillMaxWidth() .align(CenterHorizontally) + .height(48.dp) ) { Text(text = "Login") } @@ -196,7 +214,7 @@ fun LoginScreen( ) }, isEnabled = !viewModel.isLoading.value, - name = viewModel.completeProfileUserName.value + name = viewModel.names.value ) { OutlinedTextField( label = { @@ -217,18 +235,4 @@ fun LoginScreen( ) } } - - if (viewModel.isLoading.value) { - Loading() - } - - if (viewModel.errorMessage.value.isNotBlank()) { - LaunchedEffect(key1 = viewModel.errorMessage.value) { - Toast.makeText( - context, - viewModel.errorMessage.value, - Toast.LENGTH_LONG - ).show() - } - } } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt b/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt index ab3d539..08df30d 100644 --- a/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt +++ b/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt @@ -2,7 +2,6 @@ package com.loki.auth.login import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.viewModelScope -import com.google.firebase.FirebaseException import com.loki.auth.util.ext.isValidEmail import com.loki.local.datastore.DataStoreStorage import com.loki.local.datastore.model.LocalProfile @@ -11,6 +10,7 @@ import com.loki.remote.auth.AuthRepository import com.loki.remote.model.Profile import com.loki.remote.profiles.ProfilesRepository import com.loki.ui.utils.ColorUtil +import com.loki.ui.utils.ext.toInitials import com.loki.ui.viewmodel.ReetViewModel import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.delay @@ -36,6 +36,9 @@ class LoginViewModel @Inject constructor( private val userName get() = state.value.userName + private var userId = mutableStateOf("") + var names = mutableStateOf("") + var isProfileSheetVisible = mutableStateOf(false) private var localProfileState = mutableStateOf(LocalProfileState()) @@ -87,54 +90,45 @@ class LoginViewModel @Inject constructor( launchCatching { - try { - isLoading.value = true - - //logins user - val user = auth.authenticate( - email = email, - password = password - ) + //logins user + val user = auth.authenticate( + email = email, + password = password + ) - completeProfileUserName.value = user.username + names.value = user.username - //checks if user has remote profile - val isProfile = getRemoteProfile(user.id) + //checks if user has remote profile + val isProfile = getRemoteProfile(user.id) - if (isProfile) { - // saves logged in user - updateUser( - LocalUser( - userId = user.id, - email = user.email, - name = user.username, - isLoggedIn = true - ) + if (isProfile) { + // saves logged in user + updateUser( + LocalUser( + userId = user.id, + email = user.email, + name = user.username, + isLoggedIn = true ) - //update local profile - updateProfile( - LocalProfile( - id = localProfileState.value.id, - userName = localProfileState.value.username, - profileBackground = localProfileState.value.profileBackground!! - ) + ) + //update local profile + updateProfile( + LocalProfile( + id = localProfileState.value.id, + userName = localProfileState.value.username, + userNameInitials = names.value.toInitials(), + profileBackground = localProfileState.value.profileBackground!! ) - isLoading.value = false - delay(1000L) - resetField() - navigateToHome() - } - isLoading.value = false - - if (!isProfile) { - isProfileSheetVisible.value = true - return@launchCatching - } + ) + delay(1000L) + resetField() + navigateToHome() } - catch (e: FirebaseException) { - isLoading.value = false - errorMessage.value = e.message ?: "something went wrong" + + if (!isProfile) { + isProfileSheetVisible.value = true + return@launchCatching } } } @@ -156,55 +150,48 @@ class LoginViewModel @Inject constructor( } launchCatching { - try { - isLoading.value = true - - val color = ColorUtil.profileBackgroundColors.random() - - // setsUpProfile - profileRepository.setUpProfile( - Profile( - userName = userName, - name = completeProfileUserName.value, - profileBackgroundColor = color, - userId = userId.value - ) + val color = ColorUtil.profileBackgroundColors.random() + + // setsUpProfile + profileRepository.setUpProfile( + Profile( + userName = userName, + name = names.value, + profileBackgroundColor = color, + userId = userId.value ) + ) - delay(1000L) - // get remote profile again to get profile id - getRemoteProfile( - id = userId.value, - ) + delay(1000L) + // get remote profile again to get profile id + getRemoteProfile( + id = userId.value, + ) - delay(2000L) - //update local profile - updateProfile( - LocalProfile( - id = localProfileState.value.id, - userName = userName, - profileBackground = color - ) + delay(2000L) + //update local profile + updateProfile( + LocalProfile( + id = localProfileState.value.id, + userName = userName, + userNameInitials = names.value.toInitials(), + profileBackground = color ) + ) - delay(3000L) - //saves logged in user - updateUser( - LocalUser( - userId = userId.value, - email = email, - name = completeProfileUserName.value, - isLoggedIn = true - ) + delay(3000L) + //saves logged in user + updateUser( + LocalUser( + userId = userId.value, + email = email, + name = names.value, + isLoggedIn = true ) + ) - resetField() - navigateToHome() - } - catch (e: FirebaseException) { - isLoading.value = false - errorMessage.value = e.message ?: "something went wrong" - } + resetField() + navigateToHome() } } diff --git a/feature/auth/src/main/java/com/loki/auth/register/RegisterScreen.kt b/feature/auth/src/main/java/com/loki/auth/register/RegisterScreen.kt index efae191..98feb47 100644 --- a/feature/auth/src/main/java/com/loki/auth/register/RegisterScreen.kt +++ b/feature/auth/src/main/java/com/loki/auth/register/RegisterScreen.kt @@ -1,6 +1,7 @@ package com.loki.auth.register import android.widget.Toast +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -61,7 +62,9 @@ fun RegisterScreen( else MaterialTheme.colorScheme.onBackground Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) ) { Column( @@ -169,7 +172,8 @@ fun RegisterScreen( }, modifier = Modifier .fillMaxWidth() - .align(Alignment.CenterHorizontally), + .align(Alignment.CenterHorizontally) + .height(48.dp), enabled = !viewModel.isLoading.value ) { Text(text = "Sign Up") @@ -211,7 +215,7 @@ fun RegisterScreen( ) }, isEnabled = !viewModel.isLoading.value, - name = viewModel.completeProfileUserName.value + name = viewModel.names.value ) { OutlinedTextField( label = { diff --git a/feature/auth/src/main/java/com/loki/auth/register/RegisterViewModel.kt b/feature/auth/src/main/java/com/loki/auth/register/RegisterViewModel.kt index 2914766..76cbde8 100644 --- a/feature/auth/src/main/java/com/loki/auth/register/RegisterViewModel.kt +++ b/feature/auth/src/main/java/com/loki/auth/register/RegisterViewModel.kt @@ -1,7 +1,6 @@ package com.loki.auth.register import androidx.compose.runtime.mutableStateOf -import com.google.firebase.FirebaseException import com.loki.auth.util.ext.isValidEmail import com.loki.auth.util.ext.passwordMatches import com.loki.local.datastore.DataStoreStorage @@ -36,6 +35,9 @@ class RegisterViewModel @Inject constructor( private val userName get() = state.value.userName + private var userId = mutableStateOf("") + var names = mutableStateOf("") + fun onFirstNameChange(newValue: String) { state.value = state.value.copy(firstName = newValue.trim()) if (firstName.isNotBlank()) { @@ -92,27 +94,18 @@ class RegisterViewModel @Inject constructor( } launchCatching { - try { - isLoading.value = true - - // sets up remote Profile - profile.setUpProfile( - Profile( - userName = userName, - name = "$firstName $lastName", - profileBackgroundColor = ColorUtil.profileBackgroundColors.random(), - userId = userId.value - ) + // sets up remote Profile + profile.setUpProfile( + Profile( + userName = userName, + name = "$firstName $lastName", + profileBackgroundColor = ColorUtil.profileBackgroundColors.random(), + userId = userId.value ) + ) - resetField() - isLoading.value = false - navigateToLogin() - } - catch (e: FirebaseException) { - isLoading.value = false - errorMessage.value = e.message ?: "something went wrong" - } + resetField() + navigateToLogin() } } @@ -159,28 +152,17 @@ class RegisterViewModel @Inject constructor( } launchCatching { + //creates new account + val id = auth.createAccount( + names = "$firstName $lastName", + email = email, + password = password + ) - try { - isLoading.value = true - - //creates new account - val id = auth.createAccount( - names = "$firstName $lastName", - email = email, - password = password - ) - - completeProfileUserName.value = "$firstName $lastName" - - userId.value = id!! + names.value = "$firstName $lastName" + userId.value = id!! - isLoading.value = false - onRegister() - } - catch (e: FirebaseException) { - isLoading.value = false - errorMessage.value = e.message ?: "something went wrong" - } + onRegister() } } diff --git a/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt b/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt index dd32fef..55f7376 100644 --- a/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt +++ b/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt @@ -42,6 +42,7 @@ fun ReportListScreen( ) { val uiState by viewModel.reportsUiState.collectAsStateWithLifecycle() + val localProfile by viewModel.localProfile.collectAsStateWithLifecycle() val context = LocalContext.current Scaffold( @@ -50,8 +51,8 @@ fun ReportListScreen( modifier = Modifier.padding(horizontal = 16.dp), leadingItem = { ProfileCircleBox( - initials = viewModel.userInitial.value, - backgroundColor = Color(viewModel.localProfile.value.profileBackground), + initials = localProfile.userNameInitials, + backgroundColor = Color(localProfile.profileBackground), initialsSize = 20, modifier = Modifier.size(40.dp) ) diff --git a/feature/home/src/main/java/com/loki/home/report_list/ReportListViewModel.kt b/feature/home/src/main/java/com/loki/home/report_list/ReportListViewModel.kt index ae58b63..27a392e 100644 --- a/feature/home/src/main/java/com/loki/home/report_list/ReportListViewModel.kt +++ b/feature/home/src/main/java/com/loki/home/report_list/ReportListViewModel.kt @@ -21,8 +21,6 @@ class ReportListViewModel @Inject constructor( val reportsUiState = _reportUiState.asStateFlow() init { - getUser() - getLocalProfile() getReports() } diff --git a/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt b/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt index 9cbd7c6..85e3c03 100644 --- a/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt +++ b/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.loki.ui.components.AppTopBar import com.loki.ui.components.ProfileCircleBox import kotlinx.coroutines.job @@ -56,6 +57,7 @@ fun NewReportScreen( ) { val uiState by viewModel.state + val localProfile by viewModel.localProfile.collectAsStateWithLifecycle() val context = LocalContext.current @@ -79,6 +81,24 @@ fun NewReportScreen( } } + if (viewModel.errorMessage.value.isNotBlank()) { + LaunchedEffect(key1 = viewModel.errorMessage.value) { + Toast.makeText( + context, + viewModel.errorMessage.value, + Toast.LENGTH_LONG + ).show() + } + } + + if (isGalleryClicked) { + SideEffect { + galleryLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + } + Column(modifier = Modifier.fillMaxSize()) { AppTopBar( @@ -100,8 +120,8 @@ fun NewReportScreen( ProfileCircleBox( - initials = viewModel.userInitial.value, - backgroundColor = Color(viewModel.localProfile.value.profileBackground), + initials = localProfile.userNameInitials, + backgroundColor = Color(localProfile.profileBackground), initialsSize = 20, modifier = Modifier .size(80.dp) @@ -164,22 +184,4 @@ fun NewReportScreen( } } - - if (viewModel.errorMessage.value.isNotBlank()) { - LaunchedEffect(key1 = Unit) { - Toast.makeText( - context, - viewModel.errorMessage.value, - Toast.LENGTH_LONG - ).show() - } - } - - if (isGalleryClicked) { - SideEffect { - galleryLauncher.launch( - PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) - ) - } - } } \ No newline at end of file diff --git a/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt b/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt index 55ae226..559ab30 100644 --- a/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt +++ b/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt @@ -26,11 +26,6 @@ class NewReportViewModel @Inject constructor( private val imageUri get() = state.value.imageUri - init { - getUser() - getLocalProfile() - } - fun onChangeReportContent(newValue: String) { state.value = state.value.copy(reportContent = newValue) } diff --git a/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt index 96e49bb..d7e23aa 100644 --- a/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt @@ -1,15 +1,17 @@ package com.loki.profile import androidx.compose.runtime.mutableStateOf -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 com.loki.profile.profile.ProfileUiState import com.loki.remote.auth.AuthRepository import com.loki.remote.model.Profile import com.loki.remote.profiles.ProfilesRepository +import com.loki.ui.utils.ext.toInitials import com.loki.ui.viewmodel.ReetViewModel import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay import javax.inject.Inject @HiltViewModel @@ -25,46 +27,59 @@ class ProfileViewModel @Inject constructor( private val username get() = state.value.username - private var profileId = mutableStateOf("") + private var editableProfile = mutableStateOf(Profile()) init { - getUser() - getLocalProfile() state.value = state.value.copy(username = localProfile.value.userName) + editableProfile.value = Profile( + id = localProfile.value.id, + name = localUser.value.name, + userName = localProfile.value.userName, + profileBackgroundColor = localProfile.value.profileBackground, + userId = localUser.value.userId + ) } fun onUsernameChange(newValue: String) { - state.value = state.value.copy(username = newValue.trim()) + state.value = state.value.copy(username = newValue) if (username.isNotBlank()) { state.value = state.value.copy(usernameError = "", isUsernameError = false) } } - fun updateUsername() { + fun updateProfilePicture() { launchCatching { - try { - isLoading.value = true + auth.updateUser( + name = null, + profilePhoto = null + ) + } + } - profilesRepository.updateUsername( - Profile( - id = profileId.value, - userName = username - ) + fun updateUsername(onSuccess: () -> Unit) { + launchCatching { + profilesRepository.updateUsername( + editableProfile.value.copy( + userName = username ) + ) - isLoading.value = false - } - catch (e: FirebaseException) { - isLoading.value = false - errorMessage.value = e.message ?: "Something went wrong" - } + updateProfile( + LocalProfile( + id = editableProfile.value.id, + userName = editableProfile.value.userName, + userNameInitials = editableProfile.value.name.toInitials(), + profileBackground = editableProfile.value.profileBackgroundColor!! + ) + ) + onSuccess() } } fun logOut(navigateToLogin: () -> Unit) { launchCatching { auth.signOut() - updateUser(LocalUser(name = "R U")) + updateUser(LocalUser()) updateProfile(LocalProfile()) navigateToLogin() } diff --git a/feature/profile/src/main/java/com/loki/profile/ProfileScreen.kt b/feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt similarity index 69% rename from feature/profile/src/main/java/com/loki/profile/ProfileScreen.kt rename to feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt index 63cee9b..735c34a 100644 --- a/feature/profile/src/main/java/com/loki/profile/ProfileScreen.kt +++ b/feature/profile/src/main/java/com/loki/profile/profile/ProfileScreen.kt @@ -1,7 +1,7 @@ -package com.loki.profile +package com.loki.profile.profile import android.content.Intent -import android.net.Uri +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -13,8 +13,6 @@ 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.height -import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -23,58 +21,61 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowForwardIos import androidx.compose.material.icons.filled.Call import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold -import androidx.compose.material3.SheetState import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +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.utils.ext.toInitials @Composable fun ProfileScreen( viewModel: ProfileViewModel, navigateToSettings: () -> Unit, + navigateToChangeUsername: () -> Unit, navigateToLogin: () -> Unit ) { - val uiState by viewModel.state - var openBottomSheet by rememberSaveable{ mutableStateOf(false) } + val localProfile by viewModel.localProfile.collectAsState() + val localUser by viewModel.localUser.collectAsState() + val context = LocalContext.current val openIntent = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult(), onResult = {} ) + if (viewModel.errorMessage.value.isNotBlank()) { + LaunchedEffect(key1 = viewModel.errorMessage.value) { + Toast.makeText( + context, + viewModel.errorMessage.value, + Toast.LENGTH_LONG + ).show() + } + } + Scaffold ( topBar = { AppTopBar( @@ -95,15 +96,22 @@ fun ProfileScreen( Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { ProfileBox( - backgroundColor = Color(viewModel.localProfile.value.profileBackground), - initials = viewModel.localUser.value.name.toInitials(), + backgroundColor = Color(localProfile.profileBackground), + initials = localProfile.userNameInitials, onEditClick = {} ) } - ExtendedRowItem(content = "Names", subContent = viewModel.localUser.value.name) - ExtendedRowItem(content = "Email", subContent = viewModel.localUser.value.email) - ExtendedRowItem(content = "Username", subContent = viewModel.localProfile.value.userName) + ExtendedRowItem(content = "Names", subContent = localUser.name) + ExtendedRowItem(content = "Email", subContent = localUser.email) + ExtendedRowItem( + content = "Username", + subContent = localProfile.userName, + isEditable = true, + onRowClick = { + navigateToChangeUsername() + } + ) Text(text = "SETTINGS", modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) @@ -149,23 +157,12 @@ fun ProfileScreen( ) Text( - text = "1.0.0", + text = "v1.1.0", fontSize = 12.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } } - if (openBottomSheet) { - EditUsernameSheet(viewModel = viewModel, uiState = uiState) { - openBottomSheet = false - } - } - - LaunchedEffect(key1 = Unit) { - if (!viewModel.isLoading.value) { - openBottomSheet = false - } - } } @Composable @@ -268,60 +265,4 @@ fun RowItem( Text(text = content) } } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun EditUsernameSheet( - viewModel: ProfileViewModel, - uiState: ProfileUiState, - onDismiss: () -> Unit -) { - - ModalBottomSheet( - onDismissRequest = { onDismiss() }, - shape = RoundedCornerShape( - topStart = 8.dp, - topEnd = 8.dp - ) - ) { - - Column( - verticalArrangement = Arrangement.Center, - modifier = Modifier - .padding(16.dp) - ) { - OutlinedTextField( - label = { - Text( - text = "Username", - color = MaterialTheme.colorScheme.onBackground.copy(.5f) - ) - }, - value = uiState.username, - onValueChange = viewModel::onUsernameChange, - placeholder = { - Text( - text = "Enter username", - color = MaterialTheme.colorScheme.onBackground.copy(.5f) - ) - }, - modifier = Modifier.fillMaxWidth(), - enabled = !viewModel.isLoading.value, - colors = TextFieldDefaults.textFieldColors( - containerColor = MaterialTheme.colorScheme.primary.copy(.02f) - ) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Button( - onClick = viewModel::updateUsername, - modifier = Modifier.fillMaxWidth() - ) { - Text(text = "Update Username") - } - Spacer(modifier = Modifier.height(20.dp)) - } - } } \ No newline at end of file diff --git a/feature/profile/src/main/java/com/loki/profile/ProfileUiState.kt b/feature/profile/src/main/java/com/loki/profile/profile/ProfileUiState.kt similarity index 80% rename from feature/profile/src/main/java/com/loki/profile/ProfileUiState.kt rename to feature/profile/src/main/java/com/loki/profile/profile/ProfileUiState.kt index eeb11db..16cc184 100644 --- a/feature/profile/src/main/java/com/loki/profile/ProfileUiState.kt +++ b/feature/profile/src/main/java/com/loki/profile/profile/ProfileUiState.kt @@ -1,4 +1,4 @@ -package com.loki.profile +package com.loki.profile.profile data class ProfileUiState( val username: String = "", diff --git a/feature/profile/src/main/java/com/loki/profile/username/UsernameChangeScreen.kt b/feature/profile/src/main/java/com/loki/profile/username/UsernameChangeScreen.kt new file mode 100644 index 0000000..6d871a7 --- /dev/null +++ b/feature/profile/src/main/java/com/loki/profile/username/UsernameChangeScreen.kt @@ -0,0 +1,99 @@ +package com.loki.profile.username + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.loki.profile.ProfileViewModel +import com.loki.ui.components.AppTopBar +import com.loki.ui.utils.TextFieldColorUtil + +@Composable +fun UsernameChangeScreen( + viewModel: ProfileViewModel, + navigateBack: () -> Unit +) { + + val uiState by viewModel.state + val focusRequester = remember { FocusRequester() } + val float by animateFloatAsState(targetValue = 0f, label = "button_animation") + + Scaffold( + topBar = { + AppTopBar( + leadingItem = { + IconButton(onClick = navigateBack) { + Icon(imageVector = Icons.Filled.ArrowBack, contentDescription = null) + } + Text(text = "Change Username", fontSize = 22.sp, fontWeight = FontWeight.Bold) + } + ) + } + ) { padding -> + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .padding(padding) + .padding(16.dp) + ) { + OutlinedTextField( + label = { + Text( + text = "Username", + color = MaterialTheme.colorScheme.onBackground.copy(.5f) + ) + }, + value = uiState.username, + onValueChange = viewModel::onUsernameChange, + placeholder = { + Text( + text = "Enter new username", + color = MaterialTheme.colorScheme.onBackground.copy(.5f) + ) + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + enabled = !viewModel.isLoading.value, + colors = TextFieldColorUtil.colors(viewModel.isDarkTheme.value) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { + viewModel.updateUsername( + onSuccess = navigateBack + ) }, + enabled = !viewModel.isLoading.value, + modifier = Modifier.fillMaxWidth() + .height(48.dp) + ) { + Text(text = "Update Username") + } + Spacer(modifier = Modifier.height(20.dp)) + } + } +} \ No newline at end of file diff --git a/feature/report/src/main/java/com/loki/report/ReportScreen.kt b/feature/report/src/main/java/com/loki/report/ReportScreen.kt index d85d383..6958e71 100644 --- a/feature/report/src/main/java/com/loki/report/ReportScreen.kt +++ b/feature/report/src/main/java/com/loki/report/ReportScreen.kt @@ -65,8 +65,11 @@ fun ReportScreen( ) { val uiState by viewModel.state + val localUser by viewModel.localUser.collectAsStateWithLifecycle() + val localProfile by viewModel.localProfile.collectAsStateWithLifecycle() val comments by viewModel.commentState.collectAsStateWithLifecycle() val context = LocalContext.current + var isReportMoreClicked by rememberSaveable { mutableStateOf(false) } var isCommentMoreClicked by rememberSaveable { mutableStateOf(false) } var isEditReportClicked by remember { mutableStateOf(false) } @@ -309,7 +312,7 @@ fun ReportScreen( isCommentMoreClicked = true viewModel.onCommentIdChange(matchedComment.comment) }, - isUserMatched = viewModel.localUser.value.userId == matchedComment.comment.userId, + isUserMatched = localUser.userId == matchedComment.comment.userId, modifier = Modifier.padding( vertical = 4.dp, horizontal = 16.dp @@ -338,8 +341,8 @@ fun ReportScreen( ) { ProfileCircleBox( - initials = viewModel.localUser.value.name.toInitials(), - backgroundColor = Color(viewModel.localProfile.value.profileBackground), + initials = localProfile.userNameInitials, + backgroundColor = Color(localProfile.profileBackground), initialsSize = 15, modifier = Modifier.size(30.dp) ) 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 3080aff..440575e 100644 --- a/feature/report/src/main/java/com/loki/report/ReportViewModel.kt +++ b/feature/report/src/main/java/com/loki/report/ReportViewModel.kt @@ -42,8 +42,6 @@ class ReportViewModel @Inject constructor( val editableComment = mutableStateOf(Comment()) init { - getLocalProfile() - getUser() savedStateHandle.get(REPORT_ID)?.let { reportId -> getReport(reportId) getComments(reportId) From d71a05a5d03dc699577e419efdd3fb7222fe7655 Mon Sep 17 00:00:00 2001 From: lokified Date: Thu, 31 Aug 2023 17:49:31 +0300 Subject: [PATCH 2/5] add animation in navigation and functionality to update profile photo --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 3 + core/navigation/build.gradle.kts | 1 + .../com/loki/navigation/BottomNavigation.kt | 2 +- .../java/com/loki/navigation/Navigation.kt | 95 ++++++++- .../main/java/com/loki/navigation/Screen.kt | 1 + .../com/loki/navigation/graph/AccountGraph.kt | 2 + core/ui/build.gradle.kts | 1 + .../com/loki/ui/components/CommentItem.kt | 3 +- .../loki/ui/components/ProfileCircleBox.kt | 40 +++- .../java/com/loki/ui/components/ReportItem.kt | 3 +- .../loki/ui/permission/PermissionAction.kt | 7 + .../loki/ui/permission/PermissionDialog.kt | 84 ++++++++ .../com/loki/ui/utils/TextFieldColorUtil.kt | 1 + .../com/loki/ui/viewmodel/ReetViewModel.kt | 3 +- .../loki/local/datastore/DataStoreStorage.kt | 1 + .../local/datastore/DataStoreStorageImpl.kt | 7 +- .../local/datastore/model/LocalProfile.kt | 3 +- .../com/loki/remote/auth/AuthRepository.kt | 2 - .../loki/remote/auth/AuthRepositoryImpl.kt | 19 -- .../java/com/loki/remote/model/Profile.kt | 1 + .../remote/profiles/ProfilesRepository.kt | 2 + .../remote/profiles/ProfilesRepositoryImpl.kt | 35 ++- .../java/com/loki/di/AuthRepositoryModule.kt | 5 +- .../java/com/loki/auth/login/LoginState.kt | 3 +- .../com/loki/auth/login/LoginViewModel.kt | 9 +- feature/camera/.gitignore | 1 + feature/camera/build.gradle.kts | 65 ++++++ feature/camera/consumer-rules.pro | 0 feature/camera/proguard-rules.pro | 21 ++ .../loki/camera/ExampleInstrumentedTest.kt | 24 +++ feature/camera/src/main/AndroidManifest.xml | 4 + .../java/com/loki/camera/CameraPreview.kt | 200 ++++++++++++++++++ .../main/java/com/loki/camera/CameraScreen.kt | 200 ++++++++++++++++++ .../java/com/loki/camera/util/CameraUtils.kt | 53 +++++ .../com/loki/camera/util/StatusBarUtil.kt | 39 ++++ .../java/com/loki/camera/ExampleUnitTest.kt | 17 ++ .../loki/home/report_list/ReportListScreen.kt | 3 +- feature/new_report/build.gradle.kts | 1 + .../com/loki/new_report/NewReportScreen.kt | 36 ++-- feature/profile/build.gradle.kts | 1 + .../java/com/loki/profile/ProfileViewModel.kt | 34 +-- .../com/loki/profile/profile/ProfileScreen.kt | 129 +++++++---- .../main/java/com/loki/report/ReportScreen.kt | 6 +- gradle/libs.versions.toml | 22 ++ settings.gradle.kts | 1 + 46 files changed, 1073 insertions(+), 118 deletions(-) create mode 100644 core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt create mode 100644 core/ui/src/main/java/com/loki/ui/permission/PermissionDialog.kt create mode 100644 feature/camera/.gitignore create mode 100644 feature/camera/build.gradle.kts create mode 100644 feature/camera/consumer-rules.pro create mode 100644 feature/camera/proguard-rules.pro create mode 100644 feature/camera/src/androidTest/java/com/loki/camera/ExampleInstrumentedTest.kt create mode 100644 feature/camera/src/main/AndroidManifest.xml create mode 100644 feature/camera/src/main/java/com/loki/camera/CameraPreview.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/CameraScreen.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt create mode 100644 feature/camera/src/test/java/com/loki/camera/ExampleUnitTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 25e0b5d..eb2da62 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -64,6 +64,7 @@ dependencies { implementation(project(":feature:profile")) implementation(project(":feature:report")) implementation(project(":feature:settings")) + implementation(project(":feature:camera")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cbd4259..2d3f7aa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,9 +2,12 @@ + + + Unit, viewModel: } fun NavGraphBuilder.newReportScreen(onNavigateTo: (Screen) -> Unit, viewModel: NavigationViewModel) { - composable(route = Screen.NewReportScreen.route) { + composable( + route = Screen.NewReportScreen.route, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween(600) + ) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(200) + ) + } + ) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(false) } @@ -169,7 +186,19 @@ fun NavGraphBuilder.reportScreen(onNavigateBack: () -> Unit, viewModel: Navigati navArgument(REPORT_ID) { type = NavType.StringType } - ) + ), + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(600) + ) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(200) + ) + } ) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(false) @@ -213,13 +242,28 @@ fun NavGraphBuilder.profileScreen(onNavigateTo: (Screen) -> Unit, onNavigateToLo viewModel = profileViewModel, navigateToSettings = { onNavigateTo(Screen.SettingsScreen) }, navigateToChangeUsername = { onNavigateTo(Screen.UsernameChangeScreen) }, - navigateToLogin = { onNavigateToLogin(Screen.LoginScreen) } + navigateToLogin = { onNavigateToLogin(Screen.LoginScreen) }, + navigateToCamera = { onNavigateTo(Screen.CameraScreen) } ) } } fun NavGraphBuilder.usernameChangeScreen(onNavigateBack: () -> Unit, viewModel: NavigationViewModel) { - composable(route = Screen.UsernameChangeScreen.route) { + composable( + route = Screen.UsernameChangeScreen.route, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(600) + ) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(200) + ) + } + ) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(false) } @@ -232,7 +276,21 @@ fun NavGraphBuilder.usernameChangeScreen(onNavigateBack: () -> Unit, viewModel: } fun NavGraphBuilder.settingsScreen(onNavigateBack: () -> Unit, viewModel: NavigationViewModel) { - composable(route = Screen.SettingsScreen.route) { + composable( + route = Screen.SettingsScreen.route, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(600) + ) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(200) + ) + } + ) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(false) } @@ -242,4 +300,31 @@ fun NavGraphBuilder.settingsScreen(onNavigateBack: () -> Unit, viewModel: Naviga viewModel = settingsViewModel ) } +} + +fun NavGraphBuilder.cameraScreen(onNavigateBack: () -> Unit,viewModel: NavigationViewModel) { + composable( + route = Screen.CameraScreen.route, + enterTransition = { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(600) + ) + }, + exitTransition = { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(200) + ) + } + ) { + LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { + viewModel.setBottomBarVisible(false) + } + val profileViewModel = hiltViewModel() + CameraScreen( + navigateBack = onNavigateBack, + profileViewModel = profileViewModel + ) + } } \ 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 1c5c5da..b23e239 100644 --- a/core/navigation/src/main/java/com/loki/navigation/Screen.kt +++ b/core/navigation/src/main/java/com/loki/navigation/Screen.kt @@ -35,5 +35,6 @@ sealed class Screen( object ProfileScreen: Screen("profile_screen", title = "Profile", icon = Icons.Filled.AccountCircle) object UsernameChangeScreen: Screen("change_username_screen") object SettingsScreen: Screen("settings_screen") + object CameraScreen: Screen("camera_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 6da8d2e..293b2d5 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 @@ -6,6 +6,7 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.loki.navigation.NavigationViewModel import com.loki.navigation.Screen +import com.loki.navigation.cameraScreen import com.loki.navigation.ext.navigateTo import com.loki.navigation.profileScreen import com.loki.navigation.settingsScreen @@ -27,5 +28,6 @@ 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) } } \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index c339886..76ad2de 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -57,4 +57,5 @@ dependencies { implementation(libs.bundles.unitTests) implementation(platform(libs.firebase.bom)) implementation(libs.bundles.firebase) + implementation(libs.bundles.cameraX) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/components/CommentItem.kt b/core/ui/src/main/java/com/loki/ui/components/CommentItem.kt index c855d4f..f2b6247 100644 --- a/core/ui/src/main/java/com/loki/ui/components/CommentItem.kt +++ b/core/ui/src/main/java/com/loki/ui/components/CommentItem.kt @@ -52,7 +52,8 @@ fun CommentItem( initials = profile.name.toInitials(), backgroundColor = Color(profile.profileBackgroundColor!!), initialsSize = 15, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + imageUri = profile.profileImage ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/core/ui/src/main/java/com/loki/ui/components/ProfileCircleBox.kt b/core/ui/src/main/java/com/loki/ui/components/ProfileCircleBox.kt index 9c60b7e..69874eb 100644 --- a/core/ui/src/main/java/com/loki/ui/components/ProfileCircleBox.kt +++ b/core/ui/src/main/java/com/loki/ui/components/ProfileCircleBox.kt @@ -2,6 +2,7 @@ package com.loki.ui.components import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text @@ -10,24 +11,53 @@ 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.text.toUpperCase +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage import java.util.Locale @Composable fun ProfileCircleBox( modifier: Modifier = Modifier, initials: String, + imageUri: String, backgroundColor: Color, - initialsSize: Int + initialsSize: Int, + size: Dp = 40.dp ) { + Box( - modifier = modifier.size(40.dp) - .background(color = backgroundColor, shape = CircleShape), + modifier = modifier + .size(size) + .clip(CircleShape), contentAlignment = Alignment.Center ) { - Text(text = initials.uppercase(Locale.ENGLISH), fontSize = initialsSize.sp, color = Color.White) + + if (imageUri.isNotBlank()) { + AsyncImage( + model = imageUri, + contentDescription = "profile image", + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + .clip(CircleShape) + ) + } + else { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(color = backgroundColor), + contentAlignment = Alignment.Center + ) { + Text( + text = initials.uppercase(Locale.ENGLISH), + fontSize = initialsSize.sp, + color = Color.White + ) + } + } } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/components/ReportItem.kt b/core/ui/src/main/java/com/loki/ui/components/ReportItem.kt index 6688cc7..788ac3a 100644 --- a/core/ui/src/main/java/com/loki/ui/components/ReportItem.kt +++ b/core/ui/src/main/java/com/loki/ui/components/ReportItem.kt @@ -55,7 +55,8 @@ fun ReportItem( initials = profile.name.toInitials(), backgroundColor = Color(profile.profileBackgroundColor!!), initialsSize = 15, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + imageUri = profile.profileImage ) Spacer(modifier = Modifier.width(8.dp)) diff --git a/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt b/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt new file mode 100644 index 0000000..d423775 --- /dev/null +++ b/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt @@ -0,0 +1,7 @@ +package com.loki.ui.permission + +sealed class PermissionAction { + object PermissionGranted: PermissionAction() + object PermissionDenied: PermissionAction() + object PermissionAlreadyGranted: PermissionAction() +} \ 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 new file mode 100644 index 0000000..7b5950c --- /dev/null +++ b/core/ui/src/main/java/com/loki/ui/permission/PermissionDialog.kt @@ -0,0 +1,84 @@ +package com.loki.ui.permission + +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +@Composable +fun PermissionDialog( + context: Context, + permission: String, + permissionRationale: String, + snackbarHostState: SnackbarHostState, + permissionAction: (PermissionAction) -> Unit +) { + + val isPermissionGranted = checkIfPermissionGranted(context, permission) + + if (isPermissionGranted) { + permissionAction(PermissionAction.PermissionAlreadyGranted) + return + } + + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> + if (isGranted) { + permissionAction(PermissionAction.PermissionGranted) + } + else { + permissionAction(PermissionAction.PermissionDenied) + } + } + + val showPermissionRationale = shouldShowPermissionRationale(context, permission) + + if (showPermissionRationale) { + LaunchedEffect(key1 = showPermissionRationale ) { + val snackbarResult = snackbarHostState.showSnackbar( + message = permissionRationale, + actionLabel = "Grant Access", + duration = SnackbarDuration.Long + + ) + + when(snackbarResult) { + SnackbarResult.Dismissed -> permissionAction(PermissionAction.PermissionDenied) + SnackbarResult.ActionPerformed -> permissionLauncher.launch(permission) + } + } + } + else { + SideEffect { + permissionLauncher.launch(permission) + } + } +} + +fun checkIfPermissionGranted(context: Context, permission: String): Boolean { + return (ContextCompat.checkSelfPermission(context, permission) + == PackageManager.PERMISSION_GRANTED) +} + +fun shouldShowPermissionRationale(context: Context, permission: String): Boolean { + + val activity = context as Activity? + if (activity == null) + Log.d("Maps Util", "Activity is null") + + return ActivityCompat.shouldShowRequestPermissionRationale( + activity!!, + permission + ) +} \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt b/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt index 90046c7..d267a3e 100644 --- a/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt +++ b/core/ui/src/main/java/com/loki/ui/utils/TextFieldColorUtil.kt @@ -18,5 +18,6 @@ object TextFieldColorUtil { focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, focusedLabelColor = defaultColorField(isDarkTheme), + disabledContainerColor = Color.Transparent ) } \ 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 1cd0271..8fd210f 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 @@ -78,7 +78,8 @@ open class ReetViewModel( id = it.id, userName = it.userName, userNameInitials = it.userNameInitials, - profileBackground = it.profileBackground + profileBackground = it.profileBackground, + profileImage = it.profileImage ) } } diff --git a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt index 55b4f7f..08c5a58 100644 --- a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt +++ b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorage.kt @@ -32,6 +32,7 @@ interface DataStoreStorage { val PROFILE_ID_KEY = stringPreferencesKey("profile_id_key") val USER_USERNAME_KEY = stringPreferencesKey("user_username_key") val USER_USERNAME_INITIALS_KEY = stringPreferencesKey("user_username_initials_key") + val USER_IMAGE_KEY = stringPreferencesKey("user_image_key") val USER_BACKGROUND_KEY = longPreferencesKey("user_background_key") } diff --git a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt index ca6b978..7136994 100644 --- a/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt +++ b/data/local/src/main/java/com/loki/local/datastore/DataStoreStorageImpl.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import com.loki.local.datastore.DataStoreStorage.ProfilePreference.PROFILE_ID_KEY import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_BACKGROUND_KEY +import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_IMAGE_KEY import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_USERNAME_INITIALS_KEY import com.loki.local.datastore.DataStoreStorage.ProfilePreference.USER_USERNAME_KEY import com.loki.local.datastore.DataStoreStorage.ThemePreference.IS_DARK_THEME_KEY @@ -49,7 +50,8 @@ class DataStoreStorageImpl @Inject constructor( preference[PROFILE_ID_KEY] = localProfile.id preference[USER_USERNAME_KEY] = localProfile.userName preference[USER_USERNAME_INITIALS_KEY] = localProfile.userNameInitials - preference[USER_BACKGROUND_KEY] = localProfile.profileBackground!! + preference[USER_BACKGROUND_KEY] = localProfile.profileBackground + preference[USER_IMAGE_KEY] = localProfile.profileImage } } @@ -60,8 +62,9 @@ class DataStoreStorageImpl @Inject constructor( val username = preferences[USER_USERNAME_KEY] ?: "" val usernameInitials = preferences[USER_USERNAME_INITIALS_KEY] ?: "" val background = preferences[USER_BACKGROUND_KEY] ?: 0xFFF1736A + val profileImage = preferences[USER_IMAGE_KEY] ?: "" - LocalProfile(id, username, usernameInitials, background) + LocalProfile(id, username, usernameInitials, background, profileImage) } } diff --git a/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt b/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt index 159ad01..4ae1955 100644 --- a/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt +++ b/data/local/src/main/java/com/loki/local/datastore/model/LocalProfile.kt @@ -4,5 +4,6 @@ data class LocalProfile( val id: String = "", val userName: String = "", val userNameInitials: String = "", - val profileBackground: Long = 0xFFF1736A + val profileBackground: Long = 0xFFF1736A, + val profileImage: String = "" ) diff --git a/data/remote/src/main/java/com/loki/remote/auth/AuthRepository.kt b/data/remote/src/main/java/com/loki/remote/auth/AuthRepository.kt index 8b813e5..2d5ef89 100644 --- a/data/remote/src/main/java/com/loki/remote/auth/AuthRepository.kt +++ b/data/remote/src/main/java/com/loki/remote/auth/AuthRepository.kt @@ -1,6 +1,5 @@ package com.loki.remote.auth -import com.loki.remote.model.Profile import com.loki.remote.model.User import kotlinx.coroutines.flow.Flow @@ -13,7 +12,6 @@ interface AuthRepository { suspend fun authenticate(email: String, password: String): User suspend fun createAccount(names: String, email: String, password: String): String? - suspend fun updateUser(name: String?, profilePhoto: String?) suspend fun sendRecoveryEmail(email: String) suspend fun deleteAccount() suspend fun signOut() diff --git a/data/remote/src/main/java/com/loki/remote/auth/AuthRepositoryImpl.kt b/data/remote/src/main/java/com/loki/remote/auth/AuthRepositoryImpl.kt index 1f5692b..0219fc0 100644 --- a/data/remote/src/main/java/com/loki/remote/auth/AuthRepositoryImpl.kt +++ b/data/remote/src/main/java/com/loki/remote/auth/AuthRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.loki.remote.auth -import androidx.core.net.toUri import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.UserProfileChangeRequest import com.google.firebase.perf.ktx.trace @@ -63,24 +62,6 @@ class AuthRepositoryImpl @Inject constructor( } } - override suspend fun updateUser(name: String?, profilePhoto: String?) { - trace(UPDATE_USER_TRACE) { - val request = UserProfileChangeRequest.Builder() - if (name != null) { - request.apply { - displayName = name - } - } - - if (profilePhoto != null) { - request.apply { - photoUri = profilePhoto.toUri() - } - } - auth.currentUser?.updateProfile(request.build()) - } - } - override suspend fun sendRecoveryEmail(email: String) { trace(PASSWORD_CHANGE_TRACE) { auth.sendPasswordResetEmail(email).await() diff --git a/data/remote/src/main/java/com/loki/remote/model/Profile.kt b/data/remote/src/main/java/com/loki/remote/model/Profile.kt index 51acc65..c0ec424 100644 --- a/data/remote/src/main/java/com/loki/remote/model/Profile.kt +++ b/data/remote/src/main/java/com/loki/remote/model/Profile.kt @@ -8,5 +8,6 @@ data class Profile( val name: String = "", val userName: String = "", val profileBackgroundColor: Long? = null, + val profileImage: String = "", val userId: String = "" ) diff --git a/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepository.kt b/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepository.kt index c9b63e7..933659e 100644 --- a/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepository.kt +++ b/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepository.kt @@ -12,4 +12,6 @@ interface ProfilesRepository { suspend fun setUpProfile(profile: Profile) suspend fun updateUsername(profile: Profile) + + suspend fun updateProfileImage(profile: Profile) } \ No newline at end of file diff --git a/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt b/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt index 4322614..3f423cd 100644 --- a/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt +++ b/data/remote/src/main/java/com/loki/remote/profiles/ProfilesRepositoryImpl.kt @@ -1,17 +1,20 @@ package com.loki.remote.profiles +import android.net.Uri import android.util.Log import androidx.compose.runtime.mutableStateOf import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.ktx.dataObjects import com.google.firebase.perf.ktx.trace +import com.google.firebase.storage.FirebaseStorage import com.loki.remote.model.Profile import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.tasks.await import javax.inject.Inject class ProfilesRepositoryImpl @Inject constructor( - private val storage: FirebaseFirestore + private val storage: FirebaseFirestore, + private val store: FirebaseStorage ): ProfilesRepository { override suspend fun getProfiles(): Flow> { @@ -53,14 +56,44 @@ class ProfilesRepositoryImpl @Inject constructor( } } + override suspend fun updateProfileImage(profile: Profile) { + trace(UPDATE_PROFILE_IMAGE_TRACE) { + + val imageName = "${profile.userName}-${profile.userId}" + val url = addProfileImageToFirebaseStorage( + Uri.parse(profile.profileImage), + imageName + ) + + val profileWithImageUrl = profile.copy( + profileImage = url.toString() + ) + + storage.collection(USER_PROFILE_COLLECTIONS) + .document(profile.id) + .set(profileWithImageUrl) + .await() + } + } + + private suspend fun addProfileImageToFirebaseStorage(imageUri: Uri, imageName: String): Uri{ + + return store.reference.child(PROFILE_IMAGE_REFERENCE) + .child(imageName) + .putFile(imageUri).await() + .storage.downloadUrl.await() + } + companion object { //collections const val USER_PROFILE_COLLECTIONS = "profile_collections" const val USER_FIELD_ID = "userId" + const val PROFILE_IMAGE_REFERENCE = "profile_images" //traces const val PROFILE_USER_TRACE = "add_profile_trace" const val UPDATE_PROFILE_USERNAME_TRACE = "update_profile_username_trace" + const val UPDATE_PROFILE_IMAGE_TRACE = "update_profile_image_trace" } } \ No newline at end of file diff --git a/di/src/main/java/com/loki/di/AuthRepositoryModule.kt b/di/src/main/java/com/loki/di/AuthRepositoryModule.kt index c89b18e..7e419ca 100644 --- a/di/src/main/java/com/loki/di/AuthRepositoryModule.kt +++ b/di/src/main/java/com/loki/di/AuthRepositoryModule.kt @@ -2,6 +2,7 @@ package com.loki.di import com.google.firebase.auth.FirebaseAuth import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.storage.FirebaseStorage import com.loki.remote.auth.AuthRepository import com.loki.remote.auth.AuthRepositoryImpl import com.loki.remote.profiles.ProfilesRepository @@ -26,7 +27,7 @@ object AuthRepositoryModule { @Provides @Singleton - fun provideProfileRepository(storage: FirebaseFirestore): ProfilesRepository { - return ProfilesRepositoryImpl(storage) + fun provideProfileRepository(storage: FirebaseFirestore, store: FirebaseStorage): ProfilesRepository { + return ProfilesRepositoryImpl(storage, store) } } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/loki/auth/login/LoginState.kt b/feature/auth/src/main/java/com/loki/auth/login/LoginState.kt index ca8708a..9c144af 100644 --- a/feature/auth/src/main/java/com/loki/auth/login/LoginState.kt +++ b/feature/auth/src/main/java/com/loki/auth/login/LoginState.kt @@ -15,5 +15,6 @@ data class LoginState( data class LocalProfileState( val id: String = "", val username: String = "", - val profileBackground: Long? = null + val profileBackground: Long? = null, + val profileImage: String = "" ) diff --git a/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt b/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt index 08df30d..1843f8d 100644 --- a/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt +++ b/feature/auth/src/main/java/com/loki/auth/login/LoginViewModel.kt @@ -101,7 +101,6 @@ class LoginViewModel @Inject constructor( //checks if user has remote profile val isProfile = getRemoteProfile(user.id) - if (isProfile) { // saves logged in user updateUser( @@ -118,10 +117,11 @@ class LoginViewModel @Inject constructor( id = localProfileState.value.id, userName = localProfileState.value.username, userNameInitials = names.value.toInitials(), - profileBackground = localProfileState.value.profileBackground!! + profileBackground = localProfileState.value.profileBackground!!, + profileImage = localProfileState.value.profileImage ) ) - delay(1000L) + delay(2000L) resetField() navigateToHome() } @@ -204,7 +204,8 @@ class LoginViewModel @Inject constructor( localProfileState.value = LocalProfileState( id = remoteProfile.id, username = remoteProfile.userName, - profileBackground = remoteProfile.profileBackgroundColor + profileBackground = remoteProfile.profileBackgroundColor, + profileImage = remoteProfile.profileImage ) } diff --git a/feature/camera/.gitignore b/feature/camera/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/feature/camera/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts new file mode 100644 index 0000000..0df9ab2 --- /dev/null +++ b/feature/camera/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.library") + id("org.jetbrains.kotlin.android") + id("kotlin-kapt") +} + +android { + namespace = "com.loki.camera" + compileSdk = 34 + + defaultConfig { + minSdk = 23 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.7" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + + implementation(project(":data:local")) + implementation(project(":data:remote")) + implementation(project(":core:ui")) + implementation(project(":feature:profile")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + debugImplementation(libs.bundles.compose.debug) + androidTestImplementation(platform(libs.androidx.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.bundles.hilt) + kapt(libs.bundles.hilt.kapt) + implementation(platform(libs.firebase.bom)) + implementation(libs.bundles.firebase) + implementation(libs.coil.kt.compose) + implementation(libs.bundles.cameraX) + + testImplementation(libs.bundles.test.common) + androidTestImplementation(libs.bundles.android.test) + testImplementation(libs.bundles.unitTests) +} \ No newline at end of file diff --git a/feature/camera/consumer-rules.pro b/feature/camera/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/feature/camera/proguard-rules.pro b/feature/camera/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/feature/camera/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/camera/src/androidTest/java/com/loki/camera/ExampleInstrumentedTest.kt b/feature/camera/src/androidTest/java/com/loki/camera/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..0b6f512 --- /dev/null +++ b/feature/camera/src/androidTest/java/com/loki/camera/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.loki.camera + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.loki.camera.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/camera/src/main/AndroidManifest.xml b/feature/camera/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/feature/camera/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ 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 new file mode 100644 index 0000000..eee0328 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/CameraPreview.kt @@ -0,0 +1,200 @@ +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 new file mode 100644 index 0000000..7611a86 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/CameraScreen.kt @@ -0,0 +1,200 @@ +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/util/CameraUtils.kt b/feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt new file mode 100644 index 0000000..e344fa5 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/util/CameraUtils.kt @@ -0,0 +1,53 @@ +package com.loki.camera.util + +import android.content.Context +import android.util.Log +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.util.concurrent.Executor +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +suspend fun ImageCapture.takePicture(executor: Executor): File { + val photoFile = withContext(Dispatchers.IO) { + kotlin.runCatching { + File.createTempFile("image", "jpg") + }.getOrElse { ex -> + Log.e("Take Picture", "Failed to create temporary file", ex) + File("/dev/null") + } + } + + return suspendCoroutine { continuation -> + val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile) + .build() + takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { + continuation.resume(photoFile) + } + + override fun onError(exception: ImageCaptureException) { + Log.e("Take picture", "Image capture failed", exception) + continuation.resumeWithException(exception) + } + }) + } +} + +suspend fun Context.getCameraProvider(): ProcessCameraProvider = suspendCoroutine { continuation -> + ProcessCameraProvider.getInstance(this).also { future -> + future.addListener( + { continuation.resume(future.get()) }, + executor + ) + } +} + +val Context.executor: Executor + get() = ContextCompat.getMainExecutor(this) \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt b/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt new file mode 100644 index 0000000..4832322 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt @@ -0,0 +1,39 @@ +package com.loki.camera.util + +import android.app.Activity +import android.view.View +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat + +object StatusBarUtil { + + @Composable + fun statusBarColor() = MaterialTheme.colorScheme.background.toArgb() + + @Composable + fun defaultNavColor(darkTheme: Boolean) = + if (darkTheme) MaterialTheme.colorScheme.primary.toArgb() + else MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp).toArgb() + + @Composable + fun DefaultStatusColors( + view: View, + isDark: Boolean, + statusBarColor: Int = statusBarColor(), + navigationBarColor: Int = defaultNavColor(isDark), + ) { + + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = statusBarColor + window.navigationBarColor = navigationBarColor + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = isDark + WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = isDark + } + } +} \ No newline at end of file diff --git a/feature/camera/src/test/java/com/loki/camera/ExampleUnitTest.kt b/feature/camera/src/test/java/com/loki/camera/ExampleUnitTest.kt new file mode 100644 index 0000000..9baec0a --- /dev/null +++ b/feature/camera/src/test/java/com/loki/camera/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.loki.camera + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt b/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt index 55f7376..1e0644a 100644 --- a/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt +++ b/feature/home/src/main/java/com/loki/home/report_list/ReportListScreen.kt @@ -54,7 +54,8 @@ fun ReportListScreen( initials = localProfile.userNameInitials, backgroundColor = Color(localProfile.profileBackground), initialsSize = 20, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(40.dp), + imageUri = localProfile.profileImage ) Spacer(modifier = Modifier.width(8.dp)) Text(text = "Home", fontSize = 22.sp, fontWeight = FontWeight.Bold) diff --git a/feature/new_report/build.gradle.kts b/feature/new_report/build.gradle.kts index 7f5f210..e5f3376 100644 --- a/feature/new_report/build.gradle.kts +++ b/feature/new_report/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) implementation(libs.bundles.lifecycle) + implementation(libs.coil.kt.compose) implementation(platform(libs.firebase.bom)) implementation(libs.bundles.firebase) diff --git a/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt b/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt index 85e3c03..93971db 100644 --- a/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt +++ b/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt @@ -1,12 +1,10 @@ package com.loki.new_report -import android.provider.MediaStore import android.widget.Toast 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.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.Image import androidx.compose.material3.Button @@ -35,16 +34,17 @@ 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.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.rememberAsyncImagePainter import com.loki.ui.components.AppTopBar import com.loki.ui.components.ProfileCircleBox import kotlinx.coroutines.job @@ -74,10 +74,10 @@ fun NewReportScreen( val galleryLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickVisualMedia() ) { uri -> + isGalleryClicked = false uri?.let { viewModel.onChangeImageUri(uri) - isGalleryClicked = false } } @@ -125,22 +125,12 @@ fun NewReportScreen( initialsSize = 20, modifier = Modifier .size(80.dp) - .padding(16.dp) + .padding(16.dp), + imageUri = localProfile.profileImage ) Column { - uiState.imageUri?.let { - Image( - bitmap = MediaStore.Images.Media.getBitmap(context.contentResolver, it).asImageBitmap(), - contentDescription = null, - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 16.dp) - .size(150.dp), - contentScale = ContentScale.Fit - ) - } - TextField( value = uiState.reportContent, onValueChange = viewModel::onChangeReportContent, @@ -181,6 +171,20 @@ fun NewReportScreen( ) } } + + + uiState.imageUri?.let { + Image( + painter = rememberAsyncImagePainter(model = it), + contentDescription = null, + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + .height(300.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + } } } diff --git a/feature/profile/build.gradle.kts b/feature/profile/build.gradle.kts index a19b345..82128fd 100644 --- a/feature/profile/build.gradle.kts +++ b/feature/profile/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { debugImplementation(libs.bundles.compose.debug) androidTestImplementation(platform(libs.androidx.compose.bom)) implementation(libs.bundles.compose) + implementation(libs.bundles.lifecycle) implementation(libs.bundles.hilt) kapt(libs.bundles.hilt.kapt) implementation(platform(libs.firebase.bom)) diff --git a/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt b/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt index d7e23aa..a68e07e 100644 --- a/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt +++ b/feature/profile/src/main/java/com/loki/profile/ProfileViewModel.kt @@ -1,5 +1,7 @@ package com.loki.profile +import android.net.Uri +import android.util.Log import androidx.compose.runtime.mutableStateOf import com.loki.local.datastore.DataStoreStorage import com.loki.local.datastore.model.LocalProfile @@ -47,13 +49,22 @@ class ProfileViewModel @Inject constructor( } } - fun updateProfilePicture() { + fun updateProfilePicture(imageUri: Uri, onSuccess: () -> Unit) { launchCatching { - auth.updateUser( - name = null, - profilePhoto = null + profilesRepository.updateProfileImage( + editableProfile.value.copy( + profileImage = imageUri.toString() + ) ) + + onSuccess() } + + updateProfile( + localProfile.value.copy( + profileImage = imageUri.toString() + ) + ) } fun updateUsername(onSuccess: () -> Unit) { @@ -63,17 +74,14 @@ class ProfileViewModel @Inject constructor( userName = username ) ) - - updateProfile( - LocalProfile( - id = editableProfile.value.id, - userName = editableProfile.value.userName, - userNameInitials = editableProfile.value.name.toInitials(), - profileBackground = editableProfile.value.profileBackgroundColor!! - ) - ) onSuccess() } + + updateProfile( + localProfile.value.copy( + userName = username + ) + ) } fun logOut(navigateToLogin: () -> Unit) { 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 735c34a..159ef9e 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,9 +1,12 @@ package com.loki.profile.profile +import android.Manifest import android.content.Intent +import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult 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 @@ -22,19 +25,27 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Call +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Logout import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Icon +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.collectAsState 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale @@ -43,23 +54,31 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter 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( viewModel: ProfileViewModel, navigateToSettings: () -> Unit, navigateToChangeUsername: () -> Unit, - navigateToLogin: () -> Unit + navigateToLogin: () -> Unit, + navigateToCamera: () -> Unit ) { - val localProfile by viewModel.localProfile.collectAsState() - val localUser by viewModel.localUser.collectAsState() + 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(), @@ -76,6 +95,43 @@ 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( @@ -98,7 +154,10 @@ fun ProfileScreen( ProfileBox( backgroundColor = Color(localProfile.profileBackground), initials = localProfile.userNameInitials, - onEditClick = {} + imageUri = localProfile.profileImage, + onEditClick = { + isPermissionDialogClicked = true + } ) } @@ -169,54 +228,44 @@ fun ProfileScreen( fun ProfileBox( modifier: Modifier = Modifier, initials: String? = null, - imageUrl: String? = null, + imageUri: String, backgroundColor: Color, onEditClick: () -> Unit ) { Box( modifier = modifier - .size(100.dp) + .size(120.dp) .background( - color = backgroundColor, + color = MaterialTheme.colorScheme.secondaryContainer, shape = CircleShape ), contentAlignment = Alignment.Center ) { - if (initials != null) { - ProfileCircleBox( - initials = initials, - backgroundColor = backgroundColor, - initialsSize = 30, - modifier = Modifier.fillMaxSize() - ) - } - if (imageUrl != null) { - AsyncImage( - model = imageUrl, - contentDescription = "profile image", - modifier = Modifier - .fillMaxSize() - .background(backgroundColor), - contentScale = ContentScale.Crop - ) - } - Box( - modifier = Modifier - .fillMaxSize() - .background( - color = Color.Black.copy(.5f), - shape = CircleShape - ) - .clickable { onEditClick() }, - contentAlignment = Alignment.Center + ProfileCircleBox( + initials = initials!!, + imageUri = imageUri, + backgroundColor = backgroundColor, + initialsSize = 30, + size = 120.dp, + modifier = Modifier.fillMaxSize() + ) + + IconButton( + onClick = onEditClick, + modifier = Modifier.align(Alignment.BottomEnd), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer + ) ) { - Text( - text = "Change Profile", - fontSize = 12.sp, - color = Color.White, - textAlign = TextAlign.Center + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = "Edit", + modifier = Modifier + .size(24.dp) + .padding(4.dp), + tint = MaterialTheme.colorScheme.onBackground ) } } diff --git a/feature/report/src/main/java/com/loki/report/ReportScreen.kt b/feature/report/src/main/java/com/loki/report/ReportScreen.kt index 6958e71..9f3de82 100644 --- a/feature/report/src/main/java/com/loki/report/ReportScreen.kt +++ b/feature/report/src/main/java/com/loki/report/ReportScreen.kt @@ -197,7 +197,8 @@ fun ReportScreen( initials = profile.name.toInitials(), backgroundColor = Color(profile.profileBackgroundColor!!), initialsSize = 20, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(40.dp), + imageUri = profile.profileImage ) Spacer(modifier = Modifier.width(8.dp)) @@ -344,7 +345,8 @@ fun ReportScreen( initials = localProfile.userNameInitials, backgroundColor = Color(localProfile.profileBackground), initialsSize = 15, - modifier = Modifier.size(30.dp) + modifier = Modifier.size(30.dp), + imageUri = localProfile.profileImage ) Spacer(modifier = Modifier.width(4.dp)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95aab2c..f71ad18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,10 @@ archCoreTest = "2.2.0" mockk = "1.10.5" mockito = "3.2.0" mockitoInline = "3.11.2" +camerax = "1.3.0-rc01" +junit = "1.1.5" +appcompat = "1.6.1" +material = "1.8.0" [libraries] @@ -130,6 +134,16 @@ androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = androidx-compose-ui-test = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +# camerax +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-video = { group = "androidx.camera", name = "camera-video", version.ref = "camerax" } +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" } +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" } + [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } @@ -220,4 +234,12 @@ unitTests = [ "kotlinx-coroutines-test", "androidx-arch-core", "kotlinx-coroutines-test" +] + +cameraX = [ + "androidx-camera-lifecycle", + "androidx-camera-video", + "androidx-camera-view", + "androidx-camera-camera2", + "androidx-camera-extensions" ] \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 7dc99de..7d0d26d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -30,3 +30,4 @@ include(":feature:profile") include(":data:remote") include(":data:local") include(":di") +include(":feature:camera") From 5b3dd4054904356b502e60ae30a0049278b7ba9b Mon Sep 17 00:00:00 2001 From: lokified Date: Fri, 1 Sep 2023 00:24:55 +0300 Subject: [PATCH 3/5] fix call and email intents --- .github/workflows/build_release.yml | 4 +-- app/src/main/AndroidManifest.xml | 4 +-- .../com/loki/ui/viewmodel/ReetViewModel.kt | 1 + .../java/com/loki/profile/ProfileViewModel.kt | 27 ++++++++++--------- .../com/loki/profile/profile/ProfileScreen.kt | 18 +++++-------- .../main/java/com/loki/report/ReportScreen.kt | 4 +-- 6 files changed, 27 insertions(+), 31 deletions(-) diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index 7b18040..c453547 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -56,8 +56,8 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tag_name: v2.0.0 - release_name: ${{ github.event.repository.name }} v2.0.0 + tag_name: v1.2.0 + release_name: ${{ github.event.repository.name }} v1.2.0 - name: Upload Release APK id: upload_release_asset diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d3f7aa..0b9c0f3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,9 +5,9 @@ - - + + Unit) { @@ -74,14 +75,14 @@ class ProfileViewModel @Inject constructor( userName = username ) ) + updateProfile( + localProfile.value.copy( + userName = username + ) + ) onSuccess() + message.value = "Username Updated" } - - updateProfile( - localProfile.value.copy( - userName = username - ) - ) } fun logOut(navigateToLogin: () -> Unit) { 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 159ef9e..479b9d2 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 @@ -6,7 +6,6 @@ import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult 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 @@ -45,18 +44,13 @@ 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.vector.ImageVector -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import coil.compose.AsyncImage -import coil.compose.rememberAsyncImagePainter import com.loki.profile.ProfileViewModel import com.loki.ui.components.AppTopBar import com.loki.ui.components.ExtendedRowItem @@ -188,8 +182,8 @@ fun ProfileScreen( iconBackground = Color(0xFF95CF88), content = "Make request", onRowClick = { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "text/plain" + val intent = Intent(Intent.ACTION_SENDTO) + intent.data = Uri.parse("mailto:") intent.putExtra(Intent.EXTRA_EMAIL, arrayOf("sheldonokware@gmail.com")) openIntent.launch(intent) } @@ -200,9 +194,9 @@ fun ProfileScreen( iconBackground = Color(0xFFA8719F), content = "Call Support", onRowClick = { -// val intent = Intent(Intent.ACTION_CALL) -// intent.data = Uri.parse("tel:254725992494") -// openIntent.launch(intent) + val intent = Intent(Intent.ACTION_DIAL) + intent.data = Uri.parse("tel:254725992494") + openIntent.launch(intent) } ) @@ -216,7 +210,7 @@ fun ProfileScreen( ) Text( - text = "v1.1.0", + text = "v1.2.0", fontSize = 12.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) diff --git a/feature/report/src/main/java/com/loki/report/ReportScreen.kt b/feature/report/src/main/java/com/loki/report/ReportScreen.kt index 9f3de82..ae3d76d 100644 --- a/feature/report/src/main/java/com/loki/report/ReportScreen.kt +++ b/feature/report/src/main/java/com/loki/report/ReportScreen.kt @@ -239,7 +239,7 @@ fun ReportScreen( shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.onSecondary ) - .height(200.dp) + .height(400.dp) ) { AsyncImage( model = it, @@ -247,7 +247,7 @@ fun ReportScreen( modifier = Modifier .fillMaxSize() .clip(RoundedCornerShape(12.dp)), - contentScale = ContentScale.Crop, + contentScale = ContentScale.FillBounds ) } } From 79ab3829a41f3b4d3993911e89fa047c50df9341 Mon Sep 17 00:00:00 2001 From: lokified Date: Mon, 4 Sep 2023 10:28:43 +0300 Subject: [PATCH 4/5] add video capture --- app/src/main/AndroidManifest.xml | 2 + .../java/com/loki/navigation/Navigation.kt | 41 +- .../main/java/com/loki/navigation/Screen.kt | 8 +- .../com/loki/navigation/graph/AccountGraph.kt | 4 +- .../loki/ui/components/NotificationBubble.kt | 83 ++++ .../loki/ui/permission/PermissionDialog.kt | 85 ++-- .../main/java/com/loki/ui/utils/Constants.kt | 1 + .../main/java/com/loki/ui/utils/DateUtil.kt | 5 + .../com/loki/ui/viewmodel/ReetViewModel.kt | 4 - di/build.gradle.kts | 1 + .../java/com/loki/di/VideoPlayerModule.kt | 22 + feature/camera/build.gradle.kts | 3 + feature/camera/src/main/AndroidManifest.xml | 1 + .../java/com/loki/camera/CameraPreview.kt | 200 -------- .../main/java/com/loki/camera/CameraScreen.kt | 200 -------- .../camera/camera_screen/CameraPreview.kt | 428 ++++++++++++++++++ .../loki/camera/camera_screen/CameraScreen.kt | 379 ++++++++++++++++ .../java/com/loki/camera/util/CameraUtils.kt | 9 + .../camera/video_screen/VideoPlayerScreen.kt | 66 +++ .../video_screen/VideoPlayerViewModel.kt | 35 ++ .../java/com/loki/home/home/HomeScreen.kt | 7 +- .../com/loki/profile/profile/ProfileScreen.kt | 52 +-- .../java/com/loki/report/ReportViewModel.kt | 1 + gradle/libs.versions.toml | 15 + 24 files changed, 1156 insertions(+), 496 deletions(-) create mode 100644 core/ui/src/main/java/com/loki/ui/components/NotificationBubble.kt create mode 100644 di/src/main/java/com/loki/di/VideoPlayerModule.kt delete mode 100644 feature/camera/src/main/java/com/loki/camera/CameraPreview.kt delete mode 100644 feature/camera/src/main/java/com/loki/camera/CameraScreen.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/camera_screen/CameraPreview.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/camera_screen/CameraScreen.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerScreen.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/video_screen/VideoPlayerViewModel.kt 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 From dcbc1e2726ca9e2e31408879fefba28380b77a90 Mon Sep 17 00:00:00 2001 From: lokified Date: Tue, 19 Sep 2023 12:58:07 +0300 Subject: [PATCH 5/5] change the camera screen, its permissions and component visibility --- .github/workflows/build_release.yml | 13 +- .../java/com/loki/navigation/Navigation.kt | 96 ++++- .../main/java/com/loki/navigation/Screen.kt | 7 +- .../loki/navigation/ext/NavControllerExt.kt | 7 +- .../com/loki/navigation/graph/AccountGraph.kt | 17 +- .../com/loki/navigation/graph/ReportGraph.kt | 18 +- .../java/com/loki/ui/components/Loading.kt | 5 +- .../loki/ui/permission/PermissionAction.kt | 1 - .../loki/ui/permission/PermissionDialog.kt | 83 ++--- .../main/java/com/loki/ui/utils/Constants.kt | 4 + .../java/com/loki/auth/login/LoginScreen.kt | 4 +- .../camera/camera_screen/CameraPreview.kt | 329 ++++++++++-------- .../loki/camera/camera_screen/CameraScreen.kt | 179 ++++++---- .../camera/camera_screen/CameraViewModel.kt | 21 ++ .../loki/camera/camera_screen/VideoOutput.kt | 7 + .../com/loki/camera/util/StatusBarUtil.kt | 4 +- .../camera/src/main/res/values/strings.xml | 5 + .../com/loki/new_report/NewReportScreen.kt | 99 ++++-- .../com/loki/new_report/NewReportViewModel.kt | 2 +- .../com/loki/profile/profile/ProfileScreen.kt | 2 +- .../main/java/com/loki/report/ReportScreen.kt | 7 +- gradle/libs.versions.toml | 12 +- settings.gradle.kts | 2 +- 23 files changed, 591 insertions(+), 333 deletions(-) create mode 100644 feature/camera/src/main/java/com/loki/camera/camera_screen/CameraViewModel.kt create mode 100644 feature/camera/src/main/java/com/loki/camera/camera_screen/VideoOutput.kt create mode 100644 feature/camera/src/main/res/values/strings.xml diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index c453547..7a63055 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -6,6 +6,9 @@ on: - 'master' tags: - 'v*' + pull_request: + types: + - closed jobs: apk: @@ -34,7 +37,7 @@ jobs: run: bash ./gradlew assembleDebug --stacktrace - name: Upload APK - uses: actions/upload-artifact@v1 + uses: actions/upload-artifact@v2 with: name: apk path: app/build/outputs/apk/debug/app-debug.apk @@ -46,7 +49,7 @@ jobs: steps: - name: Download APK from build - uses: actions/download-artifact@v1 + uses: actions/download-artifact@v2 with: name: apk @@ -56,12 +59,12 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - tag_name: v1.2.0 - release_name: ${{ github.event.repository.name }} v1.2.0 + tag_name: v0.3.0-beta + release_name: ${{ github.event.repository.name }} v0.3.0-beta - name: Upload Release APK id: upload_release_asset - uses: actions/upload-release-asset@v1.0.1 + uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/core/navigation/src/main/java/com/loki/navigation/Navigation.kt b/core/navigation/src/main/java/com/loki/navigation/Navigation.kt index 8248885..ca5c581 100644 --- a/core/navigation/src/main/java/com/loki/navigation/Navigation.kt +++ b/core/navigation/src/main/java/com/loki/navigation/Navigation.kt @@ -39,7 +39,10 @@ import com.loki.report.ReportScreen import com.loki.report.ReportViewModel import com.loki.settings.SettingsScreen import com.loki.settings.SettingsViewModel +import com.loki.ui.utils.Constants.CAMERA_SCREEN_TYPE import com.loki.ui.utils.Constants.REPORT_ID +import com.loki.ui.utils.Constants.SCREEN_TYPE_PROFILE_CAMERA +import com.loki.ui.utils.Constants.SCREEN_TYPE_REPORT_CAMERA import com.loki.ui.utils.Constants.VIDEO_URI import kotlinx.coroutines.delay @@ -153,30 +156,60 @@ fun NavGraphBuilder.reportListScreen(onNavigateTo: (Screen) -> Unit, viewModel: } } -fun NavGraphBuilder.newReportScreen(onNavigateTo: (Screen) -> Unit, viewModel: NavigationViewModel) { +fun NavGraphBuilder.newReportScreen( + onNavigateTo: (Screen) -> Unit, + viewModel: NavigationViewModel, + newReportViewModel: NewReportViewModel +) { composable( route = Screen.NewReportScreen.route, enterTransition = { - slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Up, - animationSpec = tween(600) - ) + when(initialState.destination.route) { + Screen.CameraScreen.route -> { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Right, + animationSpec = tween(600) + ) + } + + Screen.ReportListScreen.route -> { + slideIntoContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Up, + animationSpec = tween(600) + ) + } + else -> null + } }, exitTransition = { - slideOutOfContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Down, - animationSpec = tween(200) - ) + when(targetState.destination.route) { + Screen.ReportListScreen.route -> { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Down, + animationSpec = tween(200) + ) + } + + Screen.CameraScreen.route -> { + slideOutOfContainer( + towards = AnimatedContentTransitionScope.SlideDirection.Left, + animationSpec = tween(200) + ) + } + else -> null + } } ) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(false) } - val newReportViewModel = hiltViewModel() NewReportScreen( viewModel = newReportViewModel, navigateToHome = { onNavigateTo(Screen.ReportListScreen) + }, + navigateToCamera = { + onNavigateTo(Screen.CameraScreen.navWith(SCREEN_TYPE_REPORT_CAMERA)) } ) } @@ -234,7 +267,11 @@ fun NavGraphBuilder.accountNavGraph(viewModel: NavigationViewModel, onNavigateTo } } -fun NavGraphBuilder.profileScreen(onNavigateTo: (Screen) -> Unit, onNavigateToLogin: (Screen) -> Unit, viewModel: NavigationViewModel) { +fun NavGraphBuilder.profileScreen( + onNavigateTo: (Screen) -> Unit, + onNavigateToLogin: (Screen) -> Unit, + viewModel: NavigationViewModel +) { composable(route = Screen.ProfileScreen.route) { LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(true) @@ -246,7 +283,7 @@ fun NavGraphBuilder.profileScreen(onNavigateTo: (Screen) -> Unit, onNavigateToLo navigateToSettings = { onNavigateTo(Screen.SettingsScreen) }, navigateToChangeUsername = { onNavigateTo(Screen.UsernameChangeScreen) }, navigateToLogin = { onNavigateToLogin(Screen.LoginScreen) }, - navigateToCamera = { onNavigateTo(Screen.CameraScreen) } + navigateToCamera = { onNavigateTo(Screen.CameraScreen.navWith(SCREEN_TYPE_PROFILE_CAMERA)) } ) } } @@ -305,9 +342,19 @@ fun NavGraphBuilder.settingsScreen(onNavigateBack: () -> Unit, viewModel: Naviga } } -fun NavGraphBuilder.cameraScreen(onNavigateBack: () -> Unit, onNavigateTo: (Screen) -> Unit, viewModel: NavigationViewModel) { +fun NavGraphBuilder.cameraScreen( + onNavigateBack: () -> Unit, + onNavigateTo: (Screen) -> Unit, + viewModel: NavigationViewModel, + newReportViewModel: NewReportViewModel +) { composable( - route = Screen.CameraScreen.route, + route = Screen.CameraScreen.withCameraScreenType(), + arguments = listOf( + navArgument(CAMERA_SCREEN_TYPE) { + type = NavType.StringType + } + ), enterTransition = { slideIntoContainer( towards = AnimatedContentTransitionScope.SlideDirection.Left, @@ -324,12 +371,31 @@ fun NavGraphBuilder.cameraScreen(onNavigateBack: () -> Unit, onNavigateTo: (Scre LaunchedEffect(key1 = viewModel.isBottomBarVisible.value) { viewModel.setBottomBarVisible(false) } + val profileViewModel = hiltViewModel() + val screenType = it.arguments?.getString(CAMERA_SCREEN_TYPE)!! + CameraScreen( navigateBack = onNavigateBack, + screenType = screenType, profileViewModel = profileViewModel, navigateToVideoPlayer = { videoUri -> onNavigateTo(Screen.VideoPlayerScreen.navWith(videoUri)) + }, + onSaveImage = { uri, screenSource -> + + when(screenSource) { + SCREEN_TYPE_PROFILE_CAMERA -> { + profileViewModel.updateProfilePicture( + imageUri = uri, + onSuccess = onNavigateBack + ) + } + SCREEN_TYPE_REPORT_CAMERA -> { + newReportViewModel.onChangeImageUri(uri) + onNavigateBack() + } + } } ) } @@ -345,7 +411,7 @@ fun NavGraphBuilder.videoPlayerScreen(viewModel: NavigationViewModel) { ), enterTransition = { slideIntoContainer( - towards = AnimatedContentTransitionScope.SlideDirection.Down, + towards = AnimatedContentTransitionScope.SlideDirection.Up, animationSpec = tween(500) ) }, 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 27ea7ba..9283a3a 100644 --- a/core/navigation/src/main/java/com/loki/navigation/Screen.kt +++ b/core/navigation/src/main/java/com/loki/navigation/Screen.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.filled.AccountCircle 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.CAMERA_SCREEN_TYPE import com.loki.ui.utils.Constants.REPORT_ID import com.loki.ui.utils.Constants.VIDEO_URI @@ -25,6 +26,10 @@ sealed class Screen( return "${ReportScreen.route}/{$REPORT_ID}" } + fun withCameraScreenType(): String { + return "${CameraScreen.route}/{$CAMERA_SCREEN_TYPE}" + } + fun withVideoUri(): String { return "${VideoPlayerScreen.route}/{$VIDEO_URI}" } @@ -33,7 +38,7 @@ sealed class 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, restoreState = false) + object ReportListScreen: Screen(route = "report_list_screen", title = "Home", icon = Icons.Filled.Home) 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) diff --git a/core/navigation/src/main/java/com/loki/navigation/ext/NavControllerExt.kt b/core/navigation/src/main/java/com/loki/navigation/ext/NavControllerExt.kt index 77a0f0a..2477f4f 100644 --- a/core/navigation/src/main/java/com/loki/navigation/ext/NavControllerExt.kt +++ b/core/navigation/src/main/java/com/loki/navigation/ext/NavControllerExt.kt @@ -1,7 +1,6 @@ package com.loki.navigation.ext import androidx.navigation.NavController -import androidx.navigation.NavGraph.Companion.findStartDestination import com.loki.navigation.Screen fun NavController.navigateTo( @@ -15,8 +14,10 @@ fun NavController.navigateTo( } ?: screen.route navigate(route) { - popUpTo(graph.findStartDestination().id) { - saveState = true + if (screen.icon != null) { + popUpTo(Screen.ReportListScreen.route) { + saveState = true + } } launchSingleTop = true restoreState = screen.restoreState 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 b87c0b0..e5bfeb4 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 @@ -2,6 +2,7 @@ package com.loki.navigation.graph import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.loki.navigation.NavigationViewModel @@ -12,6 +13,7 @@ import com.loki.navigation.profileScreen import com.loki.navigation.settingsScreen import com.loki.navigation.usernameChangeScreen import com.loki.navigation.videoPlayerScreen +import com.loki.new_report.NewReportViewModel @Composable fun AccountNavGraph( @@ -20,16 +22,25 @@ fun AccountNavGraph( viewModel: NavigationViewModel, onNavigateToLogin: (Screen) -> Unit ) { + val newReportViewModel = hiltViewModel() NavHost( navController = navController, startDestination = Screen.ProfileScreen.route, modifier = modifier ) { - profileScreen(onNavigateTo = navController::navigateTo, viewModel = viewModel, onNavigateToLogin = onNavigateToLogin) + profileScreen( + onNavigateTo = navController::navigateTo, + viewModel = viewModel, + onNavigateToLogin = onNavigateToLogin + ) usernameChangeScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) settingsScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) - cameraScreen(onNavigateBack = navController::navigateUp, onNavigateTo = navController::navigateTo, viewModel = viewModel) - videoPlayerScreen(viewModel = viewModel) + cameraScreen( + onNavigateBack = navController::navigateUp, + onNavigateTo = navController::navigateTo, + viewModel = viewModel, + newReportViewModel = newReportViewModel + ) } } \ No newline at end of file diff --git a/core/navigation/src/main/java/com/loki/navigation/graph/ReportGraph.kt b/core/navigation/src/main/java/com/loki/navigation/graph/ReportGraph.kt index 07fa577..ceab9f1 100644 --- a/core/navigation/src/main/java/com/loki/navigation/graph/ReportGraph.kt +++ b/core/navigation/src/main/java/com/loki/navigation/graph/ReportGraph.kt @@ -2,14 +2,17 @@ package com.loki.navigation.graph import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.loki.navigation.NavigationViewModel import com.loki.navigation.Screen +import com.loki.navigation.cameraScreen import com.loki.navigation.ext.navigateTo import com.loki.navigation.newReportScreen import com.loki.navigation.reportListScreen import com.loki.navigation.reportScreen +import com.loki.new_report.NewReportViewModel @Composable fun ReportNavGraph( @@ -17,6 +20,7 @@ fun ReportNavGraph( navController: NavHostController, viewModel: NavigationViewModel ) { + val newReportViewModel = hiltViewModel() NavHost( navController = navController, @@ -25,7 +29,17 @@ fun ReportNavGraph( ) { reportListScreen(onNavigateTo = navController::navigateTo, viewModel = viewModel) - reportScreen(onNavigateBack = { navController.popBackStack() }, viewModel = viewModel) - newReportScreen(onNavigateTo = navController::navigateTo, viewModel = viewModel) + reportScreen(onNavigateBack = navController::navigateUp, viewModel = viewModel) + newReportScreen( + onNavigateTo = navController::navigateTo, + viewModel = viewModel, + newReportViewModel = newReportViewModel + ) + cameraScreen( + onNavigateBack = navController::navigateUp, + onNavigateTo = navController::navigateTo, + viewModel = viewModel, + newReportViewModel = newReportViewModel + ) } } \ No newline at end of file diff --git a/core/ui/src/main/java/com/loki/ui/components/Loading.kt b/core/ui/src/main/java/com/loki/ui/components/Loading.kt index 191ef59..d899c9b 100644 --- a/core/ui/src/main/java/com/loki/ui/components/Loading.kt +++ b/core/ui/src/main/java/com/loki/ui/components/Loading.kt @@ -11,14 +11,15 @@ import androidx.compose.ui.Modifier @Composable fun Loading( - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + alignment: Alignment = Alignment.Center ) { Box( modifier = modifier.fillMaxSize() .background( MaterialTheme.colorScheme.background.copy(.5f) ), - contentAlignment = Alignment.Center + contentAlignment = alignment ) { CircularProgressIndicator() } diff --git a/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt b/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt index d423775..8402468 100644 --- a/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt +++ b/core/ui/src/main/java/com/loki/ui/permission/PermissionAction.kt @@ -3,5 +3,4 @@ package com.loki.ui.permission sealed class PermissionAction { object PermissionGranted: PermissionAction() object PermissionDenied: PermissionAction() - object PermissionAlreadyGranted: PermissionAction() } \ 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 088bfb4..02c4218 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 @@ -19,68 +19,71 @@ import androidx.core.content.ContextCompat fun PermissionDialog( context: Context, permissions: List, + permissionToRequest: List? = null, permissionRationale: Map, snackbarHostState: SnackbarHostState, permissionAction: (Map) -> Unit ) { val grantedPermissions = mutableMapOf() + val permissionToRequestSet = permissionToRequest?.toSet() permissions.forEach { permission -> - val isPermissionGranted = checkIfPermissionGranted(context, permission) - if (isPermissionGranted) { - grantedPermissions[permission] = PermissionAction.PermissionAlreadyGranted - } - else { + if (permissionToRequestSet == null || permissionToRequestSet.contains(permission)) { + val isPermissionGranted = checkIfPermissionGranted(context, permission) - val permissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { isGranted -> + if (isPermissionGranted) { + grantedPermissions[permission] = PermissionAction.PermissionGranted + } else { - val action = if (isGranted) { - PermissionAction.PermissionGranted - } else { - PermissionAction.PermissionDenied - } + val permissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> - grantedPermissions[permission] = action + val action = if (isGranted) { + PermissionAction.PermissionGranted + } else { + PermissionAction.PermissionDenied + } - //checks if we all collected responses for all permission - if (grantedPermissions.size == permissions.size) { - permissionAction(grantedPermissions) - } - } + grantedPermissions[permission] = action - val showPermissionRationale = shouldShowPermissionRationale(context, permission) + //checks if we all collected responses for all permission + if (grantedPermissions.size == permissions.size) { + permissionAction(grantedPermissions) + } + } - if (showPermissionRationale) { - LaunchedEffect(key1 = showPermissionRationale ) { + val showPermissionRationale = shouldShowPermissionRationale(context, permission) - val rationale = permissionRationale[permission] - val snackbarResult = snackbarHostState.showSnackbar( - message = rationale ?: "Permission Required", - actionLabel = "Grant Access", - duration = SnackbarDuration.Long + 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 + when (snackbarResult) { + SnackbarResult.Dismissed -> { + grantedPermissions[permission] = PermissionAction.PermissionDenied - // Check if we have collected responses for all permissions - if (grantedPermissions.size == permissions.size) { - permissionAction(grantedPermissions) + // Check if we have collected responses for all permissions + if (grantedPermissions.size == permissions.size) { + permissionAction(grantedPermissions) + } } + + SnackbarResult.ActionPerformed -> permissionLauncher.launch(permission) } - 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 1d4910d..865bdc6 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 @@ -4,4 +4,8 @@ object Constants { const val REPORT_ID = "reportId" const val VIDEO_URI = "videoUri" + const val CAMERA_SCREEN_TYPE = "camera_screen_type" + const val SCREEN_TYPE_BOTH = "type_both" + const val SCREEN_TYPE_PROFILE_CAMERA = "type_profile_camera" + const val SCREEN_TYPE_REPORT_CAMERA = "type_report_camera" } \ No newline at end of file diff --git a/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt b/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt index 43da000..2980cf3 100644 --- a/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt +++ b/feature/auth/src/main/java/com/loki/auth/login/LoginScreen.kt @@ -54,7 +54,7 @@ fun LoginScreen( val uiState by viewModel.state val isDarkTheme by viewModel.isDarkTheme - val keyboardController = LocalSoftwareKeyboardController.current + //val keyboardController = LocalSoftwareKeyboardController.current val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -158,7 +158,7 @@ fun LoginScreen( Button( onClick = { - keyboardController?.hide() + //keyboardController?.hide() viewModel.login( navigateToHome = { Toast.makeText( 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 index 8c5dc78..41a0d55 100644 --- 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 @@ -18,6 +18,7 @@ import androidx.camera.video.Recorder import androidx.camera.video.Recording import androidx.camera.video.VideoCapture import androidx.camera.video.VideoRecordEvent +import androidx.camera.video.VideoRecordEvent.Finalize import androidx.camera.view.PreviewView import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -56,17 +57,16 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri import androidx.core.util.Consumer +import androidx.lifecycle.LifecycleOwner 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( @@ -102,11 +102,11 @@ fun CameraPreview( @Composable fun CameraCapture( modifier: Modifier = Modifier, - isCameraView: Boolean, - isPermissionGranted: Boolean, + screenPreview: ScreenPreview, onImageUri: (imageUri: Uri) -> Unit, + onVideoOutput: (VideoOutput) -> Unit, + onErrorMessage: (String) -> Unit, onGalleryClick: () -> Unit, - onPermissionRequired: (isPermissionRequired: Boolean) -> Unit, bottomSwitchContent: @Composable () -> Unit ) { @@ -117,6 +117,8 @@ fun CameraCapture( val coroutineScope = rememberCoroutineScope() var previewUseCase by remember { mutableStateOf(Preview.Builder().build()) } + + // camera capture val imageCaptureUseCase by remember { mutableStateOf( ImageCapture.Builder().setCaptureMode(CAPTURE_MODE_MAXIMIZE_QUALITY) @@ -124,6 +126,24 @@ fun CameraCapture( ) } + // video capture + val qualitySelector = QualitySelector.fromOrderedList( + listOf(Quality.UHD, Quality.FHD, Quality.HD, Quality.SD), + FallbackStrategy.lowerQualityOrHigherThan(Quality.SD) + ) + val record = Recorder.Builder() + .setExecutor(context.executor) + .setQualitySelector(qualitySelector) + .setTargetVideoEncodingBitRate(5 * 1024 * 1024) + .build() + val videoCaptureUseCase = VideoCapture.withOutput(record) + + // video controls + var recording = remember { null } + var recordingStarted by remember { mutableStateOf(false) } + var audioEnabled by remember { mutableStateOf(true) } + + // camera controls var isFrontCamera by remember { mutableStateOf(false) } val cameraSelector = if (isFrontCamera) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA @@ -138,180 +158,127 @@ fun CameraCapture( } ) + when(screenPreview) { + ScreenPreview.CAMERA -> { + ImageCapture( + context = context, + lifecycleOwner = lifecycleOwner, + previewUseCase = previewUseCase, + cameraSelector = cameraSelector, + imageCaptureUseCase = imageCaptureUseCase + ) + } + + ScreenPreview.VIDEO -> { + VideoRecordCapture( + context = context, + lifecycleOwner = lifecycleOwner, + previewUseCase = previewUseCase, + cameraSelector = cameraSelector, + videoCaptureUseCase = videoCaptureUseCase + ) + } + } + Column( modifier = Modifier .fillMaxWidth() .align(Alignment.BottomCenter) ) { + CaptureControls( modifier = Modifier .fillMaxWidth() .padding(bottom = 32.dp), - isCamera = isCameraView, + screenPreview = screenPreview, + isRecording = recordingStarted, onLeftControlClick = onGalleryClick, onRotateCamera = { isFrontCamera = !isFrontCamera }, onCapture = { - if (isCameraView && isPermissionGranted) { - coroutineScope.launch(Dispatchers.IO) { - imageCaptureUseCase - .takePicture(context.executor) - .let { - onImageUri(it.toUri()) - } + when(screenPreview) { + ScreenPreview.CAMERA -> { + coroutineScope.launch { + imageCapture( + imageCaptureUseCase, + context, + onImageUri + ) + } + } + + ScreenPreview.VIDEO -> { + if (!recordingStarted) { + recordingStarted = true + recording = startRecordingVideo( + context = context, + videoCapture = videoCaptureUseCase, + audioEnabled = audioEnabled, + consumer = { event -> + onVideoRecorded( + recording = recording, + event = event, + onVideoUri = onVideoOutput, + onErrorMessage = onErrorMessage + ) + } + ) + } + else { + recordingStarted = false + recording?.stop() + recording = null + } } - } - 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 +fun ImageCapture( + context: Context, + lifecycleOwner: LifecycleOwner, + previewUseCase: UseCase, + cameraSelector: CameraSelector, + imageCaptureUseCase: ImageCapture ) { - 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) + LaunchedEffect(key1 = cameraSelector, key2 = previewUseCase) { - 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 cameraProvider = context.getCameraProvider() - 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) - } - } - } + try { + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + imageCaptureUseCase ) - bottomSwitchContent() + } catch (e: Exception) { + Log.e("Camera Capture", "Failed to bind camera use case", e) } } +} + +@Composable +fun VideoRecordCapture( + context: Context, + lifecycleOwner: LifecycleOwner, + previewUseCase: UseCase, + cameraSelector: CameraSelector, + videoCaptureUseCase: VideoCapture, +) { + + LaunchedEffect(key1 = cameraSelector, key2 = previewUseCase) { - LaunchedEffect(key1 = recordingStarted, key2 = cameraSelector, key3 = recording) { val cameraProvider = context.getCameraProvider() try { @@ -328,16 +295,75 @@ fun VideoCapture( } } +fun onVideoRecorded( + recording: Recording?, + event: VideoRecordEvent, + onVideoUri: (VideoOutput) -> Unit, + onErrorMessage: (String) -> Unit +) { + + when (event) { + + is Finalize -> { + recording?.stop() + + val uri = event.outputResults.outputUri + + when(event.error) { + Finalize.ERROR_INSUFFICIENT_STORAGE -> { + onErrorMessage("insufficient storage") + } + Finalize.ERROR_NO_VALID_DATA -> { + onErrorMessage("No data recorded") + } + } + + if (uri != Uri.EMPTY) { + + recording?.close() + + val uriEncoded = URLEncoder.encode( + uri.toString(), + StandardCharsets.UTF_8.toString() + ) + onVideoUri( + VideoOutput( + uri = uriEncoded.toString(), + ) + ) + } + } + } +} + +suspend fun imageCapture( + imageCaptureUseCase: ImageCapture, + context: Context, + onImageUri: (Uri) -> Unit +) { + imageCaptureUseCase + .takePicture(context.executor) + .let { + onImageUri(it.toUri()) + } +} + @SuppressLint("MissingPermission") fun startRecordingVideo( context: Context, videoCapture: VideoCapture, - outputDirectory: File, - executor: Executor, audioEnabled: Boolean, consumer: Consumer ): Recording { + val mediaDir = context.externalCacheDirs.firstOrNull()?.let { + File(it, "Reet").apply { mkdirs() } + } + + val outputDirectory = + if (mediaDir != null && mediaDir.exists()) mediaDir else + context.filesDir + val videoFile = File( outputDirectory, DateUtil.getFileName() + ".mp4" @@ -345,20 +371,19 @@ fun startRecordingVideo( val outputOptions = FileOutputOptions.Builder(videoFile).build() - return videoCapture.output .prepareRecording(context, outputOptions) .apply { if (audioEnabled) withAudioEnabled() } - .start(executor, consumer) + .start(context.executor, consumer) } @Composable fun CaptureControls( modifier: Modifier = Modifier, - isCamera: Boolean, + screenPreview: ScreenPreview, isRecording: Boolean = false, audioEnabled: Boolean = true, onLeftControlClick: () -> Unit, @@ -367,10 +392,10 @@ fun CaptureControls( ) { val recordingBackground = if (isRecording) Color.Red else Color.Red.copy(.4f) - val captureBackground = if (isCamera) Color.White.copy(.8f) else recordingBackground + val captureBackground = if (screenPreview == ScreenPreview.CAMERA) 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 + val leftControl = if (screenPreview == ScreenPreview.CAMERA) Icons.Filled.Image else recordingLeftControlIcon Box( modifier = modifier 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 index 3fc65f8..713ef7d 100644 --- 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 @@ -1,7 +1,9 @@ package com.loki.camera.camera_screen import android.Manifest +import android.content.Intent import android.net.Uri +import android.provider.Settings import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -12,6 +14,7 @@ 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.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -24,9 +27,11 @@ 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.OutlinedButton import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue @@ -42,51 +47,67 @@ 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.LocalLifecycleOwner import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import coil.compose.rememberAsyncImagePainter +import com.loki.camera.R 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.permission.checkIfPermissionGranted import com.loki.ui.theme.md_theme_dark_background import com.loki.ui.theme.md_theme_dark_secondaryContainer +import com.loki.ui.utils.Constants.SCREEN_TYPE_BOTH +import com.loki.ui.utils.Constants.SCREEN_TYPE_PROFILE_CAMERA +import com.loki.ui.utils.Constants.SCREEN_TYPE_REPORT_CAMERA private val EMPTY_IMAGE_URI: Uri = Uri.parse("file://dev/null") @Composable fun CameraScreen( profileViewModel: ProfileViewModel, + screenType: String, navigateToVideoPlayer: (uri: String) -> Unit, + onSaveImage: (imageUri: Uri, screenSource: String) -> Unit, navigateBack: () -> Unit ) { - val appTheme by profileViewModel.isDarkTheme + val isDarkTheme by profileViewModel.isDarkTheme val context = LocalContext.current val view = LocalView.current + val lifecycleOwner = LocalLifecycleOwner.current + val lifecycle = lifecycleOwner.lifecycle val snackbarHostState = remember { SnackbarHostState() } var showPermissionDialog by remember { mutableStateOf(false) } var isPermissionGranted by remember { mutableStateOf(false) } + // preview + var screenPreview by remember { mutableStateOf(ScreenPreview.CAMERA) } + // 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 isDark by remember { mutableStateOf(isDarkTheme) } var navBarColor by remember { mutableStateOf(md_theme_dark_background.toArgb()) } - val defaultNavColor = StatusBarUtil.defaultNavColor(darkTheme = appTheme) - val defaultStatusBarColor = StatusBarUtil.statusBarColor() + val defaultNavColor = StatusBarUtil.defaultNavColor(darkTheme = isDarkTheme) + val defaultStatusBarColor = StatusBarUtil.defaultStatusBarColor() - if (!appTheme) { + if (!isDarkTheme) { StatusBarUtil.DefaultStatusColors( view = view, isDark = isDark, @@ -102,16 +123,46 @@ fun CameraScreen( } } + // permissions for camera and audio recorder 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" ) + val permissionToRequest = if (screenType == SCREEN_TYPE_PROFILE_CAMERA || screenType == SCREEN_TYPE_REPORT_CAMERA) + listOf(Manifest.permission.CAMERA) else permissions + + LaunchedEffect(key1 = showPermissionDialog, key2 = isPermissionGranted) { + showPermissionDialog = true + + val isGranted = checkIfPermissionGranted(context, Manifest.permission.CAMERA) + isPermissionGranted = isGranted + } + + // onresume, check if permission was allowed + DisposableEffect(key1 = lifecycle) { + val lifecycleObserver = LifecycleEventObserver { _, event -> + when(event) { + Lifecycle.Event.ON_RESUME -> { + val isGranted = checkIfPermissionGranted(context, Manifest.permission.CAMERA) + isPermissionGranted = isGranted + } + else -> {} + } + } + + lifecycle.addObserver(lifecycleObserver) + + onDispose { + lifecycle.removeObserver(lifecycleObserver) + } + } if (showPermissionDialog) { PermissionDialog( context = context, permissions = permissions, + permissionToRequest = permissionToRequest, permissionRationale = permissionRationales, snackbarHostState = snackbarHostState, permissionAction = { permissionsAction -> @@ -120,24 +171,10 @@ fun CameraScreen( 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 } } } @@ -180,7 +217,7 @@ fun CameraScreen( ).show() } } - + if (imageUri != EMPTY_IMAGE_URI) { Box( @@ -223,9 +260,10 @@ fun CameraScreen( containerColor = md_theme_dark_secondaryContainer ), onClick = { - profileViewModel.updateProfilePicture(imageUri) { - navigateBack() - } + statusBarColor = defaultStatusBarColor + navBarColor = defaultNavColor + isDark = !isDark + onSaveImage(imageUri, screenType) } ) { Image( @@ -245,7 +283,35 @@ fun CameraScreen( } else { - if (isCameraView) { + if (!isPermissionGranted) { + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center) + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(R.string.allow_permissions), + textAlign = TextAlign.Center + ) + OutlinedButton( + onClick = { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + val uri = Uri.fromParts("package", context.packageName, null) + intent.data = uri + context.startActivity(intent) + } + ) { + Text( + text = stringResource(R.string.open_settings), + color = MaterialTheme.colorScheme.onBackground + ) + } + } + } + } else { Box( modifier = Modifier @@ -254,46 +320,34 @@ fun CameraScreen( ) { CameraCapture( modifier = Modifier.fillMaxSize(), - isCameraView = isCameraView, - isPermissionGranted = isPermissionGranted, + screenPreview = screenPreview, onImageUri = { uri -> imageUri = uri }, - onPermissionRequired = { - showPermissionDialog = it + onVideoOutput = { output -> + output.uri?.let(navigateToVideoPlayer) }, onGalleryClick = { isGalleryClicked = true + }, + onErrorMessage = { + Toast.makeText( + context, + it, + Toast.LENGTH_LONG + ).show() } ) { - 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 - } - ) + if (screenType == SCREEN_TYPE_BOTH) { + BottomContent( + onViewSelected = { + screenPreview = it + } + ) + } } - ) + } } } } @@ -301,7 +355,7 @@ fun CameraScreen( @Composable fun BottomContent( modifier: Modifier = Modifier, - onViewSelected: (Preview) -> Unit + onViewSelected: (ScreenPreview) -> Unit ) { Box( modifier = modifier @@ -310,6 +364,7 @@ fun BottomContent( contentAlignment = Alignment.Center ) { + val titles = listOf("Camera", "Video") var selectedTitle by remember { mutableStateOf(titles[0]) } @@ -318,19 +373,17 @@ fun BottomContent( horizontalArrangement = Arrangement.Center ) { - titles.forEachIndexed { index, title -> - - val selected = titles[index] == title + titles.forEachIndexed { _, title -> PreviewSelectables( title = title, - selected = selected, + selected = selectedTitle == title, onClick = { selectedTitle = title if (title == "Camera") { - onViewSelected(Preview.CAMERA) + onViewSelected(ScreenPreview.CAMERA) } else { - onViewSelected(Preview.VIDEO) + onViewSelected(ScreenPreview.VIDEO) } } ) @@ -339,7 +392,7 @@ fun BottomContent( } } -enum class Preview { +enum class ScreenPreview { CAMERA, VIDEO } diff --git a/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraViewModel.kt b/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraViewModel.kt new file mode 100644 index 0000000..5a3fea6 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/camera_screen/CameraViewModel.kt @@ -0,0 +1,21 @@ +package com.loki.camera.camera_screen + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.loki.ui.utils.Constants.CAMERA_SCREEN_TYPE +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class CameraViewModel @Inject constructor( + savedStateHandle: SavedStateHandle +): ViewModel() { + + private var screenType = mutableStateOf("") + init { + savedStateHandle.get(CAMERA_SCREEN_TYPE)?.let { type -> + screenType.value = type + } + } +} \ No newline at end of file diff --git a/feature/camera/src/main/java/com/loki/camera/camera_screen/VideoOutput.kt b/feature/camera/src/main/java/com/loki/camera/camera_screen/VideoOutput.kt new file mode 100644 index 0000000..294cee6 --- /dev/null +++ b/feature/camera/src/main/java/com/loki/camera/camera_screen/VideoOutput.kt @@ -0,0 +1,7 @@ +package com.loki.camera.camera_screen + +data class VideoOutput( + val uri: String? = null, + val duration: Long? = null, + val fileSize: Long? = null +) diff --git a/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt b/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt index 4832322..66324ab 100644 --- a/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt +++ b/feature/camera/src/main/java/com/loki/camera/util/StatusBarUtil.kt @@ -13,7 +13,7 @@ import androidx.core.view.WindowCompat object StatusBarUtil { @Composable - fun statusBarColor() = MaterialTheme.colorScheme.background.toArgb() + fun defaultStatusBarColor() = MaterialTheme.colorScheme.background.toArgb() @Composable fun defaultNavColor(darkTheme: Boolean) = @@ -24,7 +24,7 @@ object StatusBarUtil { fun DefaultStatusColors( view: View, isDark: Boolean, - statusBarColor: Int = statusBarColor(), + statusBarColor: Int = defaultStatusBarColor(), navigationBarColor: Int = defaultNavColor(isDark), ) { diff --git a/feature/camera/src/main/res/values/strings.xml b/feature/camera/src/main/res/values/strings.xml new file mode 100644 index 0000000..6406e98 --- /dev/null +++ b/feature/camera/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Permissions, and turn \"Camera\" on]]> + Open Settings + \ No newline at end of file diff --git a/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt b/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt index 93971db..1d81407 100644 --- a/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt +++ b/feature/new_report/src/main/java/com/loki/new_report/NewReportScreen.kt @@ -1,11 +1,14 @@ package com.loki.new_report +import android.net.Uri import android.widget.Toast 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.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize @@ -15,9 +18,9 @@ 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.Image +import androidx.compose.material.icons.filled.CameraAlt +import androidx.compose.material.icons.sharp.Cancel import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,16 +49,18 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.rememberAsyncImagePainter import com.loki.ui.components.AppTopBar +import com.loki.ui.components.Loading import com.loki.ui.components.ProfileCircleBox import kotlinx.coroutines.job -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NewReportScreen( viewModel: NewReportViewModel, - navigateToHome: () -> Unit + navigateToHome: () -> Unit, + navigateToCamera: () -> Unit ) { + val isDarkTheme by viewModel.isDarkTheme val uiState by viewModel.state val localProfile by viewModel.localProfile.collectAsStateWithLifecycle() @@ -99,6 +104,12 @@ fun NewReportScreen( } } + if (viewModel.isLoading.value) { + Loading( + alignment = Alignment.TopCenter + ) + } + Column(modifier = Modifier.fillMaxSize()) { AppTopBar( @@ -111,7 +122,8 @@ fun NewReportScreen( onClick = { viewModel.addReport(navigateToHome) }, - enabled = !viewModel.isLoading.value && uiState.reportContent.isNotBlank() + enabled = !viewModel.isLoading.value && + (uiState.reportContent.isNotBlank() || uiState.imageUri != null) ) { Text(text = "Add") } @@ -131,6 +143,9 @@ fun NewReportScreen( Column { + val containerColor = if (isDarkTheme) MaterialTheme.colorScheme.primary.copy(.2f) + else MaterialTheme.colorScheme.primary.copy(.05f) + TextField( value = uiState.reportContent, onValueChange = viewModel::onChangeReportContent, @@ -143,13 +158,14 @@ fun NewReportScreen( .height(150.dp) .focusRequester(focusRequester), enabled = !viewModel.isLoading.value, - colors = TextFieldDefaults.textFieldColors( - containerColor = if (viewModel.isDarkTheme.value) MaterialTheme.colorScheme.primary.copy(.2f) - else MaterialTheme.colorScheme.primary.copy(.05f), + colors = TextFieldDefaults.colors( + focusedContainerColor = containerColor, + unfocusedContainerColor = containerColor, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, - cursorColor = if (viewModel.isDarkTheme.value) MaterialTheme.colorScheme.onBackground - else MaterialTheme.colorScheme.primary + cursorColor = if (isDarkTheme) MaterialTheme.colorScheme.onBackground + else MaterialTheme.colorScheme.primary, + disabledContainerColor = Color.Transparent ), ) @@ -158,34 +174,61 @@ fun NewReportScreen( horizontalArrangement = Arrangement.Center, modifier = Modifier.padding(16.dp) ) { - IconButton( - onClick = { - isGalleryClicked = true - } + onClick = navigateToCamera ) { - - Icon( - imageVector = Icons.Filled.Image, - contentDescription = "Gallery_icon" - ) + Icon(imageVector = Icons.Filled.CameraAlt, contentDescription = "Camera_icon") } } uiState.imageUri?.let { - Image( - painter = rememberAsyncImagePainter(model = it), - contentDescription = null, - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 16.dp) - .fillMaxWidth() - .height(300.dp) - .clip(RoundedCornerShape(8.dp)), - contentScale = ContentScale.Crop + ImageContainer( + imageUri = it, + onDeleteClick = { + viewModel.onChangeImageUri(null) + } ) } } + } +} +@Composable +fun ImageContainer( + imageUri: Uri, + onDeleteClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + .height(300.dp) + .clip(RoundedCornerShape(8.dp)) + ) { + Image( + painter = rememberAsyncImagePainter(model = imageUri), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(.5f)) + ) { + IconButton( + onClick = onDeleteClick, + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + imageVector = Icons.Sharp.Cancel, + contentDescription = "cancel icon" + ) + } + } } } \ No newline at end of file diff --git a/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt b/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt index 559ab30..87fb7f7 100644 --- a/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt +++ b/feature/new_report/src/main/java/com/loki/new_report/NewReportViewModel.kt @@ -30,7 +30,7 @@ class NewReportViewModel @Inject constructor( state.value = state.value.copy(reportContent = newValue) } - fun onChangeImageUri(newValue: Uri) { + fun onChangeImageUri(newValue: Uri?) { state.value = state.value.copy(imageUri = newValue) } 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 ac7f6b0..a749057 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 @@ -160,7 +160,7 @@ fun ProfileScreen( ) Text( - text = "v1.2.0", + text = "v0.3.0-beta", fontSize = 12.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) diff --git a/feature/report/src/main/java/com/loki/report/ReportScreen.kt b/feature/report/src/main/java/com/loki/report/ReportScreen.kt index ae3d76d..f6137e1 100644 --- a/feature/report/src/main/java/com/loki/report/ReportScreen.kt +++ b/feature/report/src/main/java/com/loki/report/ReportScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.MoreHoriz import androidx.compose.material3.Button import androidx.compose.material3.Divider -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -57,7 +56,6 @@ import com.loki.ui.utils.DateUtil.formatDate import com.loki.ui.utils.DateUtil.formatTime import com.loki.ui.utils.ext.toInitials -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ReportScreen( viewModel: ReportViewModel, @@ -364,8 +362,9 @@ fun ReportScreen( .width((deviceWidth - (deviceWidth / 2.4)).dp) .clip(RoundedCornerShape(12.dp)), enabled = !viewModel.isLoading.value, - colors = TextFieldDefaults.textFieldColors( - containerColor = MaterialTheme.colorScheme.primary.copy(.02f), + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.primary.copy(.02f), + unfocusedContainerColor = MaterialTheme.colorScheme.primary.copy(.02f), focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, disabledIndicatorColor = Color.Transparent, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 551a2fd..d961a0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,14 +2,14 @@ androidGradlePlugin = "8.0.2" androidxActivity = "1.7.2" -androidxComposeBom = "2023.06.01" +androidxComposeBom = "2023.09.00" androidxComposeCompiler = "1.4.4" androidxCore = "1.10.1" androidxComposeMaterial3 = "1.1.1" androidxCoreSplashscreen = "1.0.1" androidxEspresso = "3.5.1" -androidxLifecycle = "2.6.1" -androidxNavigation = "2.7.1" +androidxLifecycle = "2.6.2" +androidxNavigation = "2.7.2" androidxTestCore = "1.5.0" androidxTestExt = "1.1.5" androidxTestRules = "1.5.0" @@ -17,7 +17,7 @@ androidxTestRunner = "1.5.2" androidxDataStore = "1.0.0" junit4 = "4.13.2" kotlin = "1.8.21" -kotlinxCoroutines = "1.6.4" +kotlinxCoroutines = "1.7.1" retrofit = "2.9.0" okHttp3 = "4.10.0" room = "2.5.2" @@ -33,7 +33,7 @@ firebaseBom = "32.2.2" googleServices = "4.3.15" firebasePerf = "1.4.2" firebaseCrash = "2.9.6" -kotlinxPlayService = "1.6.4" +kotlinxPlayService = "1.7.1" ktlint = "11.0.0" spotless = "6.19.0" archCoreTest = "2.2.0" @@ -43,7 +43,6 @@ mockitoInline = "3.11.2" 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" @@ -152,7 +151,6 @@ permissions = { group = "com.google.accompanist", name = "accompanist-permission 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" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 7d0d26d..f11f113 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,3 @@ -include(":feature:settings") pluginManagement { repositories { @@ -31,3 +30,4 @@ include(":data:remote") include(":data:local") include(":di") include(":feature:camera") +include(":feature:settings")