diff --git a/app/src/main/java/com/canopas/catchme/ui/MainActivity.kt b/app/src/main/java/com/canopas/catchme/ui/MainActivity.kt index 36992a9e..d2ceaac7 100644 --- a/app/src/main/java/com/canopas/catchme/ui/MainActivity.kt +++ b/app/src/main/java/com/canopas/catchme/ui/MainActivity.kt @@ -21,7 +21,6 @@ import com.canopas.catchme.ui.flow.auth.verification.PhoneVerificationScreen import com.canopas.catchme.ui.flow.home.home.HomeScreen import com.canopas.catchme.ui.flow.intro.IntroScreen import com.canopas.catchme.ui.flow.onboard.OnboardScreen -import com.canopas.catchme.ui.flow.permission.EnablePermissionsScreen import com.canopas.catchme.ui.navigation.AppDestinations import com.canopas.catchme.ui.navigation.AppNavigator import com.canopas.catchme.ui.navigation.KEY_RESULT @@ -93,9 +92,6 @@ fun MainApp() { slideComposable(AppDestinations.OtpVerificationNavigation.path) { PhoneVerificationScreen() } - slideComposable(AppDestinations.enablePermissions.path) { - EnablePermissionsScreen() - } slideComposable(AppDestinations.home.path) { HomeScreen() diff --git a/app/src/main/java/com/canopas/catchme/ui/component/AppAlertDialog.kt b/app/src/main/java/com/canopas/catchme/ui/component/AppAlertDialog.kt new file mode 100644 index 00000000..d44d8b68 --- /dev/null +++ b/app/src/main/java/com/canopas/catchme/ui/component/AppAlertDialog.kt @@ -0,0 +1,61 @@ +package com.canopas.catchme.ui.component + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.canopas.catchme.ui.theme.AppTheme + +@Composable +fun AppAlertDialog( + title: String?, + subTitle: String, + confirmBtnText: String?, + dismissBtnText: String?, + onConfirmClick: (() -> Unit)? = null, + onDismissClick: (() -> Unit)? = null, + isConfirmDestructive: Boolean = false +) { + AlertDialog( + onDismissRequest = {}, + containerColor = AppTheme.colorScheme.containerNormalOnSurface, + title = { + if (title != null) { + Text( + text = title, + style = AppTheme.appTypography.header3 + ) + } + }, + text = { + Text( + text = subTitle, + style = AppTheme.appTypography.body1 + ) + }, + confirmButton = { + if (confirmBtnText != null && onConfirmClick != null) { + TextButton( + onClick = (onConfirmClick) + ) { + Text( + text = confirmBtnText, + style = AppTheme.appTypography.subTitle2, + color = if (isConfirmDestructive) AppTheme.colorScheme.alertColor else AppTheme.colorScheme.primary + ) + } + } + }, + dismissButton = { + if (dismissBtnText != null && onDismissClick != null) { + TextButton(onClick = (onDismissClick)) { + Text( + text = dismissBtnText, + style = AppTheme.appTypography.subTitle2, + color = if (isConfirmDestructive) AppTheme.colorScheme.textSecondary else AppTheme.colorScheme.primary + ) + } + } + } + ) +} diff --git a/app/src/main/java/com/canopas/catchme/ui/component/BackgroudPermissionCheck.kt b/app/src/main/java/com/canopas/catchme/ui/component/BackgroudPermissionCheck.kt index 9288ac75..71268026 100644 --- a/app/src/main/java/com/canopas/catchme/ui/component/BackgroudPermissionCheck.kt +++ b/app/src/main/java/com/canopas/catchme/ui/component/BackgroudPermissionCheck.kt @@ -80,7 +80,7 @@ fun ShowBackgroundLocationRequestDialog( ) { Column( Modifier - .background(AppTheme.colorScheme.surface) + .background(AppTheme.colorScheme.containerNormalOnSurface) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { diff --git a/app/src/main/java/com/canopas/catchme/ui/component/CreateSpace.kt b/app/src/main/java/com/canopas/catchme/ui/component/CreateSpace.kt index aeeccea2..d8289913 100644 --- a/app/src/main/java/com/canopas/catchme/ui/component/CreateSpace.kt +++ b/app/src/main/java/com/canopas/catchme/ui/component/CreateSpace.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow 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 @@ -43,7 +44,9 @@ fun CreateSpace( val keyboardController = LocalSoftwareKeyboardController.current val scrollState = rememberScrollState() Column( - modifier = modifier.verticalScroll(scrollState) + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) ) { Spacer(modifier = Modifier.height(20.dp)) Text( @@ -85,7 +88,7 @@ fun CreateSpace( onSpaceNameChanged(it) keyboardController?.hide() } - Spacer(modifier = Modifier.height(40.dp)) + Spacer(modifier = Modifier.weight(1f)) PrimaryButton( label = stringResource(R.string.common_btn_next), @@ -97,6 +100,7 @@ fun CreateSpace( enabled = spaceName.trim().isNotEmpty(), showLoader = showLoader ) + Spacer(modifier = Modifier.height(40.dp)) } } @@ -146,7 +150,7 @@ private fun PickNameTextField(title: String, value: String, onValueChanged: (Str modifier = Modifier .fillMaxWidth() .padding(vertical = 14.dp), - textStyle = AppTheme.appTypography.header4, + textStyle = AppTheme.appTypography.header4.copy(AppTheme.colorScheme.textPrimary), onValueChange = { value -> onValueChanged(value) }, diff --git a/app/src/main/java/com/canopas/catchme/ui/component/PrimaryButton.kt b/app/src/main/java/com/canopas/catchme/ui/component/PrimaryButton.kt index d81a823f..77e19693 100644 --- a/app/src/main/java/com/canopas/catchme/ui/component/PrimaryButton.kt +++ b/app/src/main/java/com/canopas/catchme/ui/component/PrimaryButton.kt @@ -24,7 +24,9 @@ fun PrimaryButton( label: String, onClick: () -> Unit, enabled: Boolean = true, - showLoader: Boolean = false + showLoader: Boolean = false, + containerColor: Color = AppTheme.colorScheme.primary, + contentColor: Color = AppTheme.colorScheme.onPrimary ) { Button( onClick = onClick, @@ -32,19 +34,20 @@ fun PrimaryButton( .fillMaxWidth(fraction = 0.9f), shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors( - containerColor = AppTheme.colorScheme.primary + containerColor = containerColor ), enabled = enabled ) { if (showLoader) { - AppProgressIndicator(color = AppTheme.colorScheme.onPrimary) + AppProgressIndicator(color = contentColor) Spacer(modifier = Modifier.width(4.dp)) } Text( text = label, - style = AppTheme.appTypography.subTitle2.copy(color = AppTheme.colorScheme.onPrimary), - textAlign = TextAlign.Center + style = AppTheme.appTypography.subTitle2.copy(color = contentColor), + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 6.dp) ) } } @@ -54,7 +57,9 @@ fun PrimaryTextButton( modifier: Modifier = Modifier, label: String, onClick: () -> Unit, - enabled: Boolean = true + enabled: Boolean = true, + showLoader: Boolean = false, + contentColor: Color = AppTheme.colorScheme.primary ) { TextButton( onClick = onClick, @@ -63,10 +68,14 @@ fun PrimaryTextButton( shape = RoundedCornerShape(50), colors = ButtonDefaults.buttonColors( containerColor = AppTheme.colorScheme.surface, - contentColor = AppTheme.colorScheme.primary + contentColor = contentColor ), enabled = enabled ) { + if (showLoader) { + AppProgressIndicator(color = contentColor) + Spacer(modifier = Modifier.width(4.dp)) + } Text( text = label, style = AppTheme.appTypography.subTitle2, diff --git a/app/src/main/java/com/canopas/catchme/ui/component/UserProfile.kt b/app/src/main/java/com/canopas/catchme/ui/component/UserProfile.kt index 9d8f4e75..b732dd10 100644 --- a/app/src/main/java/com/canopas/catchme/ui/component/UserProfile.kt +++ b/app/src/main/java/com/canopas/catchme/ui/component/UserProfile.kt @@ -34,7 +34,7 @@ fun UserProfile( shape = RoundedCornerShape(16.dp) ) .background( - AppTheme.colorScheme.primary.copy(alpha = 0.7f), + AppTheme.colorScheme.primary, shape = RoundedCornerShape(16.dp) ), contentAlignment = Alignment.Center diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModel.kt b/app/src/main/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModel.kt index 2a6e537e..ff400d5d 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModel.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModel.kt @@ -65,7 +65,7 @@ class SignInMethodViewModel @Inject constructor( } else { userPreferences.setOnboardShown(true) navigator.navigateTo( - AppDestinations.enablePermissions.path, + AppDestinations.home.path, popUpToRoute = AppDestinations.signIn.path, inclusive = true ) diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreen.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreen.kt index 5e6867bf..84dc401a 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreen.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreen.kt @@ -45,7 +45,6 @@ import androidx.navigation.compose.rememberNavController import com.canopas.catchme.R import com.canopas.catchme.data.utils.isBackgroundLocationPermissionGranted import com.canopas.catchme.ui.component.AppProgressIndicator -import com.canopas.catchme.ui.component.CheckBackgroundLocationPermission import com.canopas.catchme.ui.flow.home.activity.ActivityScreen import com.canopas.catchme.ui.flow.home.home.component.SpaceSelectionMenu import com.canopas.catchme.ui.flow.home.home.component.SpaceSelectionPopup @@ -54,6 +53,8 @@ import com.canopas.catchme.ui.flow.home.places.PlacesScreen import com.canopas.catchme.ui.flow.home.space.create.CreateSpaceHomeScreen import com.canopas.catchme.ui.flow.home.space.create.SpaceInvite import com.canopas.catchme.ui.flow.home.space.join.JoinSpaceScreen +import com.canopas.catchme.ui.flow.permission.EnablePermissionsScreen +import com.canopas.catchme.ui.flow.settings.SettingsScreen import com.canopas.catchme.ui.navigation.AppDestinations import com.canopas.catchme.ui.navigation.AppNavigator import com.canopas.catchme.ui.navigation.slideComposable @@ -67,15 +68,11 @@ fun HomeScreen() { val context = LocalContext.current LaunchedEffect(Unit) { - if (!context.isBackgroundLocationPermissionGranted) { - viewModel.shouldAskForBackgroundLocationPermission(true) - } else { + if (context.isBackgroundLocationPermissionGranted) { viewModel.startTracking() } } - PermissionChecker() - AppNavigator(navController = navController, viewModel.navActions) val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -108,8 +105,8 @@ fun HomeScreen() { HomeTopBar() } } - }, - bottomBar = { + } + /* bottomBar = { AnimatedVisibility( visible = !hideBottomBar, enter = slideInVertically(tween(100)) { it }, @@ -117,7 +114,7 @@ fun HomeScreen() { ) { HomeBottomBar(navController) } - } + }*/ ) } @@ -151,6 +148,7 @@ fun HomeTopBar() { modifier = Modifier, visible = !state.showSpaceSelectionPopup ) { + viewModel.navigateToSettings() } SpaceSelectionMenu(modifier = Modifier.weight(1f)) @@ -208,20 +206,6 @@ private fun MapControl( } } -@Composable -private fun PermissionChecker() { - val viewModel = hiltViewModel() - val state by viewModel.state.collectAsState() - - if (state.shouldAskForBackgroundLocationPermission) { - CheckBackgroundLocationPermission(onDismiss = { - viewModel.shouldAskForBackgroundLocationPermission(false) - }, onGranted = { - viewModel.startTracking() - }) - } -} - @OptIn(ExperimentalAnimationApi::class) @Composable fun HomeScreenContent(navController: NavHostController) { @@ -252,6 +236,14 @@ fun HomeScreenContent(navController: NavHostController) { slideComposable(AppDestinations.SpaceInvitation.path) { SpaceInvite() } + + slideComposable(AppDestinations.enablePermissions.path) { + EnablePermissionsScreen() + } + + slideComposable(AppDestinations.settings.path) { + SettingsScreen() + } } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModel.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModel.kt index fef9e574..eba3d8a7 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModel.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModel.kt @@ -4,11 +4,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.canopas.catchme.data.models.space.SpaceInfo import com.canopas.catchme.data.repository.SpaceRepository +import com.canopas.catchme.data.service.auth.AuthService import com.canopas.catchme.data.service.location.LocationManager import com.canopas.catchme.data.storage.UserPreferences import com.canopas.catchme.data.utils.AppDispatcher import com.canopas.catchme.ui.navigation.AppDestinations import com.canopas.catchme.ui.navigation.HomeNavigator +import com.canopas.catchme.ui.navigation.MainNavigator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,9 +22,11 @@ import javax.inject.Inject @HiltViewModel class HomeScreenViewModel @Inject constructor( private val navigator: HomeNavigator, + private val mainNavigator: MainNavigator, private val locationManager: LocationManager, private val spaceRepository: SpaceRepository, private val userPreferences: UserPreferences, + private val authService: AuthService, private val appDispatcher: AppDispatcher ) : ViewModel() { @@ -32,19 +36,29 @@ class HomeScreenViewModel @Inject constructor( val state: StateFlow = _state init { + updateUser() getAllSpaces() } - fun onTabChange(index: Int) { - _state.value = _state.value.copy(currentTab = index) + private fun updateUser() = viewModelScope.launch(appDispatcher.IO) { + val user = authService.getUser() + if (user == null) { + authService.signOut() + mainNavigator.navigateTo( + AppDestinations.signIn.path, + AppDestinations.home.path, + true + ) + } else { + authService.saveUser(user) + } } - fun shouldAskForBackgroundLocationPermission(ask: Boolean) { - _state.value = _state.value.copy(shouldAskForBackgroundLocationPermission = ask) + fun onTabChange(index: Int) { + _state.value = _state.value.copy(currentTab = index) } fun startTracking() { - shouldAskForBackgroundLocationPermission(false) locationManager.startService() } @@ -147,11 +161,14 @@ class HomeScreenViewModel @Inject constructor( _state.emit(_state.value.copy(error = e.message)) } } + + fun navigateToSettings() { + navigator.navigateTo(AppDestinations.settings.path) + } } data class HomeScreenState( val currentTab: Int = 0, - val shouldAskForBackgroundLocationPermission: Boolean = false, val spaces: List = emptyList(), val selectedSpaceId: String = "", val selectedSpace: SpaceInfo? = null, diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/home/component/SpaceSelectionMenu.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/home/component/SpaceSelectionMenu.kt index 17462ddd..bc611a27 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/home/component/SpaceSelectionMenu.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/home/component/SpaceSelectionMenu.kt @@ -167,7 +167,7 @@ private fun SpaceItem( isSelected: Boolean, onSpaceSelected: (String) -> Unit = {} ) { - val admin = space.members.first { it.user.id == space.space.admin_id }.user + val admin = space.members.firstOrNull { it.user.id == space.space.admin_id }?.user val members = space.members val containerShape = RoundedCornerShape(10.dp) Row( @@ -203,7 +203,7 @@ private fun SpaceItem( } else { R.string.home_space_selection_space_item_subtitle_member }, - admin.fullName, + admin?.fullName ?: stringResource(id = R.string.common_unknown), members.size ), style = AppTheme.appTypography.label1.copy(color = AppTheme.colorScheme.textSecondary) diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapScreen.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapScreen.kt index 3f04e26b..badde361 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapScreen.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapScreen.kt @@ -1,19 +1,38 @@ package com.canopas.catchme.ui.flow.home.map +import android.Manifest import androidx.annotation.DrawableRes import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.SheetValue import androidx.compose.material3.SmallFloatingActionButton +import androidx.compose.material3.Text import androidx.compose.material3.rememberBottomSheetScaffoldState import androidx.compose.material3.rememberStandardBottomSheetState import androidx.compose.runtime.Composable @@ -26,17 +45,30 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.catchme.R +import com.canopas.catchme.data.utils.hasAllPermission +import com.canopas.catchme.data.utils.hasFineLocationPermission +import com.canopas.catchme.data.utils.hasNotificationPermission +import com.canopas.catchme.ui.flow.home.map.component.AddMemberBtn import com.canopas.catchme.ui.flow.home.map.component.MapMarker +import com.canopas.catchme.ui.flow.home.map.component.MapUserItem import com.canopas.catchme.ui.flow.home.map.member.MemberDetailBottomSheetContent import com.canopas.catchme.ui.theme.AppTheme +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.MapStyleOptions import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.MapProperties @@ -47,7 +79,7 @@ import kotlinx.coroutines.launch private const val DEFAULT_CAMERA_ZOOM = 15f private const val DEFAULT_CAMERA_ZOOM_FOR_SELECTED_USER = 17f -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class) @Composable fun MapScreen() { val viewModel = hiltViewModel() @@ -55,6 +87,16 @@ fun MapScreen() { val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp + val permissionState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + LaunchedEffect(permissionState) { + snapshotFlow { permissionState.status == PermissionStatus.Granted && state.currentCameraPosition == null } + .collect { + if (it) { + viewModel.startLocationTracking() + } + } + } + val bottomSheetState = rememberStandardBottomSheetState( initialValue = SheetValue.Hidden, skipHiddenState = false @@ -133,11 +175,12 @@ fun MapScreenContent(modifier: Modifier) { Column( modifier = Modifier + .fillMaxWidth() .align(Alignment.BottomEnd) - .padding(bottom = 10.dp), - horizontalAlignment = Alignment.End + ) { RelocateBtn( + modifier = Modifier.align(Alignment.End), icon = R.drawable.ic_relocate, show = relocate ) { @@ -150,18 +193,93 @@ fun MapScreenContent(modifier: Modifier) { ) } } -// LazyRow( -// modifier = Modifier.fillMaxWidth(), -// contentPadding = PaddingValues(horizontal = 10.dp), -// horizontalArrangement = Arrangement.spacedBy(12.dp) -// ) { -// items(state.members) { -// MapUserItem(it) { -// viewModel.showMemberDetail(it) -// } -// } -// } + + if (state.members.isNotEmpty()) { + LazyRow( + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 10.dp) + .fillMaxWidth() + .shadow(10.dp, shape = RoundedCornerShape(6.dp)) + .background(AppTheme.colorScheme.surface, shape = RoundedCornerShape(6.dp)) + .align(Alignment.CenterHorizontally), + contentPadding = PaddingValues(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + item { + AddMemberBtn(state.loadingInviteCode) { viewModel.addMember() } + } + items(state.members) { + MapUserItem(it) { + viewModel.showMemberDetail(it) + } + } + } + } + + AnimatedVisibility( + visible = !LocalContext.current.hasAllPermission, + enter = slideInVertically(tween(100)) { it }, + exit = slideOutVertically(tween(100)) { it } + ) { + PermissionFooter() { + viewModel.navigateToPermissionScreen() + } + } + } + } +} + +@Composable +fun PermissionFooter(onClick: () -> Unit) { + val context = LocalContext.current + val hasLocationPermission = context.hasFineLocationPermission + val hasNotificationPermission = context.hasNotificationPermission + + val title = if (!hasLocationPermission || !hasNotificationPermission) { + stringResource(id = R.string.home_permission_footer_title) + } else { + stringResource(id = R.string.home_permission_footer_missing_location_permission_title) + } + + val subTitle = + if (!hasLocationPermission) { + stringResource(id = R.string.home_permission_footer_subtitle) + } else { + stringResource(id = R.string.home_permission_footer_missing_location_permission_subtitle) + } + + Row( + modifier = Modifier + .height(72.dp) + .clickable { onClick() } + .background(color = AppTheme.colorScheme.secondary) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.Center + ) { + Text( + text = title, + style = AppTheme.appTypography.label1.copy( + color = AppTheme.colorScheme.textInversePrimary, + fontWeight = FontWeight.W600 + ) + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subTitle, + style = AppTheme.appTypography.body3.copy(color = AppTheme.colorScheme.textInversePrimary) + ) } + Icon( + Icons.Default.KeyboardArrowRight, + contentDescription = "", + tint = AppTheme.colorScheme.textInversePrimary + ) } } @@ -171,10 +289,19 @@ private fun MapView( ) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() + val isDarkMode = isSystemInDarkTheme() + val context = LocalContext.current + val mapProperties = MapProperties( + mapStyleOptions = if (isDarkMode) { + MapStyleOptions.loadRawResourceStyle(context, R.raw.map_theme_night) + } else { + null + } + ) GoogleMap( cameraPositionState = cameraPositionState, - properties = MapProperties(), + properties = mapProperties, uiSettings = MapUiSettings( zoomControlsEnabled = false, tiltGesturesEnabled = false, @@ -206,11 +333,10 @@ private fun RelocateBtn( visible = show, enter = fadeIn(), exit = fadeOut(), - modifier = Modifier + modifier = modifier .padding(bottom = 10.dp, end = 10.dp) ) { SmallFloatingActionButton( - modifier = modifier, onClick = { onClick() }, containerColor = AppTheme.colorScheme.surface, contentColor = AppTheme.colorScheme.primary diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapViewModel.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapViewModel.kt index 88efa3e6..f116d0ab 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapViewModel.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/map/MapViewModel.kt @@ -8,6 +8,8 @@ import com.canopas.catchme.data.repository.SpaceRepository import com.canopas.catchme.data.service.location.LocationManager import com.canopas.catchme.data.storage.UserPreferences import com.canopas.catchme.data.utils.AppDispatcher +import com.canopas.catchme.ui.navigation.AppDestinations +import com.canopas.catchme.ui.navigation.HomeNavigator import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow @@ -15,6 +17,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -22,7 +25,8 @@ class MapViewModel @Inject constructor( private val spaceRepository: SpaceRepository, private val userPreferences: UserPreferences, private val locationManager: LocationManager, - private val appDispatcher: AppDispatcher + private val appDispatcher: AppDispatcher, + private val navigator: HomeNavigator ) : ViewModel() { private val _state = MutableStateFlow(MapScreenState()) @@ -69,6 +73,35 @@ class MapViewModel @Inject constructor( fun dismissMemberDetail() { _state.value = _state.value.copy(showUserDetails = false, selectedUser = null) } + + fun addMember() = viewModelScope.launch(appDispatcher.IO) { + try { + _state.emit(_state.value.copy(loadingInviteCode = true)) + val space = spaceRepository.getCurrentSpace() ?: return@launch + val inviteCode = spaceRepository.getInviteCode(space.id) ?: return@launch + _state.emit(_state.value.copy(loadingInviteCode = false)) + navigator.navigateTo( + AppDestinations.SpaceInvitation.spaceInvitation(inviteCode, space.name).path, + AppDestinations.createSpace.path, + inclusive = true + ) + } catch (e: Exception) { + Timber.e(e, "Failed to get invite code") + _state.emit(_state.value.copy(error = e.message, loadingInviteCode = false)) + } + } + + fun navigateToPermissionScreen() { + navigator.navigateTo(AppDestinations.enablePermissions.path) + } + + fun startLocationTracking() { + viewModelScope.launch(appDispatcher.IO) { + val currentLocation = locationManager.getLastLocation() + _state.emit(_state.value.copy(currentCameraPosition = currentLocation)) + locationManager.startService() + } + } } data class MapScreenState( @@ -76,5 +109,7 @@ data class MapScreenState( val currentUserId: String? = "", val currentCameraPosition: Location? = null, val selectedUser: UserInfo? = null, - val showUserDetails: Boolean = false + val showUserDetails: Boolean = false, + val loadingInviteCode: Boolean = false, + val error: String? = null ) diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/map/component/MapUserItem.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/map/component/MapUserItem.kt index 68d6524e..a1b3d610 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/map/component/MapUserItem.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/map/component/MapUserItem.kt @@ -1,170 +1,93 @@ package com.canopas.catchme.ui.flow.home.map.component -import android.content.Context +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.Spacer 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.Info -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.canopas.catchme.R import com.canopas.catchme.data.models.user.UserInfo +import com.canopas.catchme.ui.component.AppProgressIndicator import com.canopas.catchme.ui.component.UserProfile import com.canopas.catchme.ui.theme.AppTheme -import com.canopas.catchme.ui.theme.InterFontFamily -import com.canopas.catchme.utils.getAddress -import com.google.android.gms.maps.model.LatLng -import java.text.SimpleDateFormat -import java.util.Date -import java.util.concurrent.TimeUnit -import kotlin.math.abs @Composable fun MapUserItem( userInfo: UserInfo, onClick: () -> Unit ) { - val context = LocalContext.current val user = userInfo.user - val location = remember(userInfo) { - val latLng = - LatLng(userInfo.location?.latitude ?: 0.0, userInfo.location?.longitude ?: 0.0) - latLng.getAddress(context) ?: context.getString(R.string.map_user_item_location_unknown) - } - val lastUpdated = remember(userInfo) { - getTimeAgoString(context, userInfo.location?.created_at ?: 0L) + Column( + modifier = Modifier + .clickable { + onClick() + } + .padding(14.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + UserProfile( + Modifier + .size(50.dp), + user = user + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = user.first_name ?: "", + style = AppTheme.appTypography.label1.copy(color = AppTheme.colorScheme.textPrimary) + ) } +} - Card( +@Composable +fun AddMemberBtn( + showLoader: Boolean, + onClick: () -> Unit +) { + Column( modifier = Modifier - .height(100.dp) - .aspectRatio(2.8f) .clickable { onClick() - }, - colors = CardDefaults.cardColors( - containerColor = AppTheme.colorScheme.surface - ), - shape = RoundedCornerShape(10.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 10.dp) - + } + .padding(10.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Row(modifier = Modifier.padding(10.dp)) { - UserProfile( - Modifier - .size(50.dp), - user = user - ) - - Column( - modifier = Modifier - .padding(start = 8.dp) - .weight(1f) - ) { - Text( - text = buildAnnotatedString { - withStyle( - style = SpanStyle( - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - fontFamily = InterFontFamily - ) - ) { - append(user.fullName) - } - withStyle( - style = SpanStyle( - fontWeight = FontWeight.W400, - fontSize = 10.sp, - fontFamily = InterFontFamily - ) - ) { - if (userInfo.isLocationEnable) append(" • $lastUpdated") - } - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis + Box( + modifier = Modifier + .size(50.dp) + .border( + 1.dp, + AppTheme.colorScheme.primary.copy(alpha = 0.7f), + shape = RoundedCornerShape(16.dp) + ), + contentAlignment = Alignment.Center + ) { + if (showLoader) { + AppProgressIndicator(strokeWidth = 2.dp) + } else { + Icon( + painter = painterResource(id = R.drawable.ic_add_member), + modifier = Modifier.padding(14.dp), + contentDescription = "", + tint = AppTheme.colorScheme.primary.copy(alpha = 0.7f) ) - - if (!userInfo.isLocationEnable) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Outlined.Info, - contentDescription = "", - tint = Color.Red, - modifier = Modifier - .size(14.dp) - - ) - Text( - text = stringResource(id = R.string.map_user_item_location_off), - style = AppTheme.appTypography.label1.copy( - color = Color.Red, - fontWeight = FontWeight.Normal - ), - modifier = Modifier - .padding(start = 4.dp) - ) - } - } else { - Text( - text = location, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - style = AppTheme.appTypography.label2.copy(color = AppTheme.colorScheme.textSecondary) - ) - } } } - } -} - -private fun getTimeAgoString(context: Context, timestamp: Long): String { - val now = System.currentTimeMillis() - val duration = abs(timestamp - now) - val days = TimeUnit.MILLISECONDS.toDays(duration) - val hours = TimeUnit.MILLISECONDS.toHours(duration) - val minutes = TimeUnit.MILLISECONDS.toMinutes(duration) - - return when { - minutes < 1 -> context.getString(R.string.map_user_item_location_updated_now) - hours < 1 -> context.getString( - R.string.map_user_item_location_updated_minutes_ago, - minutes.toString() - ) - - days < 1 -> context.getString( - R.string.map_user_item_location_updated_hours_ago, - hours.toString() + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.common_btn_add), + style = AppTheme.appTypography.label1.copy(color = AppTheme.colorScheme.textPrimary) ) - - else -> { - val output = SimpleDateFormat("dd MMM") - val since = output.format(Date(timestamp)) - context.getString(R.string.map_user_item_location_updated_since_days, since) - } } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/home/space/join/JoinSpaceScreen.kt b/app/src/main/java/com/canopas/catchme/ui/flow/home/space/join/JoinSpaceScreen.kt index ba415bee..d56a4fcf 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/home/space/join/JoinSpaceScreen.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/home/space/join/JoinSpaceScreen.kt @@ -119,7 +119,7 @@ private fun JoinSpaceContent(modifier: Modifier) { } @Composable -private fun JoinedSpacePopup(space: ApiSpace, onDismiss: () -> Unit) { +fun JoinedSpacePopup(space: ApiSpace, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = { onDismiss() }, title = { diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardScreen.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardScreen.kt index b56c9d36..7a573cb5 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardScreen.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.getValue import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.catchme.ui.flow.onboard.components.CreateSpaceOnboard import com.canopas.catchme.ui.flow.onboard.components.JoinOrCreateSpaceOnboard -import com.canopas.catchme.ui.flow.onboard.components.JoinSpaceOnboard import com.canopas.catchme.ui.flow.onboard.components.PickNameOnboard import com.canopas.catchme.ui.flow.onboard.components.ShareSpaceCodeOnboard import com.canopas.catchme.ui.flow.onboard.components.SpaceInfoOnboard @@ -47,7 +46,9 @@ fun OnboardScreen() { AnimatedVisibility( visible = state.currentStep == OnboardItems.CreateSpace, enter = slideInHorizontally(initialOffsetX = { it }), - exit = slideOutHorizontally(targetOffsetX = { -it }) + exit = slideOutHorizontally(targetOffsetX = { + if (state.prevStep == OnboardItems.CreateSpace) it else -it + }) ) { CreateSpaceOnboard() } @@ -59,12 +60,4 @@ fun OnboardScreen() { ) { ShareSpaceCodeOnboard() } - - AnimatedVisibility( - visible = state.currentStep == OnboardItems.JoinSpace, - enter = slideInHorizontally(initialOffsetX = { it }), - exit = slideOutHorizontally(targetOffsetX = { -it }) - ) { - JoinSpaceOnboard() - } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModel.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModel.kt index d049c957..e9af24f5 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModel.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModel.kt @@ -2,6 +2,7 @@ package com.canopas.catchme.ui.flow.onboard import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.canopas.catchme.data.models.space.ApiSpace import com.canopas.catchme.data.repository.SpaceRepository import com.canopas.catchme.data.service.auth.AuthService import com.canopas.catchme.data.service.space.SpaceInvitationService @@ -63,6 +64,7 @@ class OnboardViewModel @Inject constructor( _state.emit( _state.value.copy( updatingUserName = false, + prevStep = _state.value.currentStep, currentStep = OnboardItems.SpaceIntro ) ) @@ -70,12 +72,16 @@ class OnboardViewModel @Inject constructor( fun navigateToJoinOrCreateSpace() { _state.value = _state.value.copy( + prevStep = _state.value.currentStep, currentStep = OnboardItems.JoinOrCreateSpace ) } fun navigateToCreateSpace() { - _state.value = _state.value.copy(currentStep = OnboardItems.CreateSpace) + _state.value = _state.value.copy( + prevStep = _state.value.currentStep, + currentStep = OnboardItems.CreateSpace + ) } fun createSpace(spaceName: String) = viewModelScope.launch(appDispatcher.IO) { @@ -91,6 +97,7 @@ class OnboardViewModel @Inject constructor( _state.value.copy( creatingSpace = false, spaceInviteCode = invitationCode, + prevStep = _state.value.currentStep, currentStep = OnboardItems.ShareSpaceCodeOnboard ) ) @@ -100,10 +107,10 @@ class OnboardViewModel @Inject constructor( } } - fun navigateToPermission() = viewModelScope.launch { + fun navigateToHome() = viewModelScope.launch { userPreferences.setOnboardShown(true) navigator.navigateTo( - AppDestinations.enablePermissions.path, + AppDestinations.home.path, popUpToRoute = AppDestinations.onboard.path, inclusive = true ) @@ -127,13 +134,17 @@ class OnboardViewModel @Inject constructor( val space = spaceRepository.getSpace(invitation.space_id) + if (space != null) { + spaceRepository.joinSpace(invitation.space_id) + } + _state.emit( _state.value.copy( + joinedSpace = space, spaceName = space?.name, spaceId = invitation.space_id, verifyingInviteCode = false, - errorInvalidInviteCode = false, - currentStep = OnboardItems.JoinSpace + errorInvalidInviteCode = false ) ) } catch (e: Exception) { @@ -147,26 +158,6 @@ class OnboardViewModel @Inject constructor( } } - fun joinSpace() = viewModelScope.launch(appDispatcher.IO) { - _state.emit(_state.value.copy(joiningSpace = true)) - val spaceId = _state.value.spaceId - try { - if (spaceId != null) { - spaceRepository.joinSpace(spaceId) - } - _state.emit(_state.value.copy(joiningSpace = false)) - navigateToPermission() - } catch (e: Exception) { - Timber.e(e, "Unable to join space") - _state.emit( - _state.value.copy( - joiningSpace = false, - error = e.localizedMessage - ) - ) - } - } - fun onInviteCodeChanged(inviteCode: String) { _state.value = _state.value.copy( spaceInviteCode = inviteCode, @@ -184,19 +175,27 @@ class OnboardViewModel @Inject constructor( errorInvalidInviteCode = false ) } + + fun popTo(page: OnboardItems) { + _state.value = _state.value.copy( + prevStep = _state.value.currentStep, + currentStep = page + ) + } } data class OnboardScreenState( + val prevStep: OnboardItems = OnboardItems.PickName, val currentStep: OnboardItems = OnboardItems.PickName, val firstName: String = "", val lastName: String = "", val updatingUserName: Boolean = false, val spaceName: String? = "", val spaceId: String? = null, + val joinedSpace: ApiSpace? = null, val spaceInviteCode: String? = "", val creatingSpace: Boolean = false, val verifyingInviteCode: Boolean = false, - val joiningSpace: Boolean = false, val errorInvalidInviteCode: Boolean = false, val error: String? = null ) @@ -207,5 +206,4 @@ sealed class OnboardItems { data object JoinOrCreateSpace : OnboardItems() data object CreateSpace : OnboardItems() data object ShareSpaceCodeOnboard : OnboardItems() - data object JoinSpace : OnboardItems() } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/CreateSpaceOnboard.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/CreateSpaceOnboard.kt index ffca9240..27c7a7bc 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/CreateSpaceOnboard.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/CreateSpaceOnboard.kt @@ -1,8 +1,15 @@ package com.canopas.catchme.ui.flow.onboard.components -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.fillMaxSize +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -11,13 +18,14 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.catchme.R import com.canopas.catchme.ui.component.CreateSpace +import com.canopas.catchme.ui.flow.onboard.OnboardItems import com.canopas.catchme.ui.flow.onboard.OnboardViewModel import com.canopas.catchme.ui.theme.AppTheme +@OptIn(ExperimentalMaterial3Api::class) @Composable fun CreateSpaceOnboard() { val viewModel = hiltViewModel() @@ -33,15 +41,34 @@ fun CreateSpaceOnboard() { } var spaceName by remember { mutableStateOf(initialName) } - CreateSpace( - modifier = Modifier - .fillMaxSize() - .background(AppTheme.colorScheme.surface) - .padding(top = 80.dp), - spaceName = spaceName, - showLoader = state.creatingSpace, - onSpaceNameChanged = { spaceName = it } + BackHandler { + viewModel.popTo(OnboardItems.JoinOrCreateSpace) + } + Scaffold( + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = AppTheme.colorScheme.surface), + title = { + }, + navigationIcon = { + IconButton(onClick = { viewModel.popTo(OnboardItems.JoinOrCreateSpace) }) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "" + ) + } + } + ) + } ) { - viewModel.createSpace(spaceName) + CreateSpace( + modifier = Modifier + .padding(it), + spaceName = spaceName, + showLoader = state.creatingSpace, + onSpaceNameChanged = { spaceName = it } + ) { + viewModel.createSpace(spaceName) + } } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinOrCreateSpaceOnboard.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinOrCreateSpaceOnboard.kt index aa721135..b891fd42 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinOrCreateSpaceOnboard.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinOrCreateSpaceOnboard.kt @@ -29,6 +29,7 @@ import com.canopas.catchme.R import com.canopas.catchme.ui.component.AppBanner import com.canopas.catchme.ui.component.OtpInputField import com.canopas.catchme.ui.component.PrimaryButton +import com.canopas.catchme.ui.flow.home.space.join.JoinedSpacePopup import com.canopas.catchme.ui.flow.onboard.OnboardViewModel import com.canopas.catchme.ui.theme.AppTheme @@ -72,6 +73,10 @@ fun JoinOrCreateSpaceOnboard() { viewModel.resetErrorState() } } + + if (state.joinedSpace != null) { + JoinedSpacePopup(state.joinedSpace!!) { viewModel.navigateToHome() } + } } @Composable diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinSpaceOnboard.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinSpaceOnboard.kt deleted file mode 100644 index 99574b4d..00000000 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/JoinSpaceOnboard.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.canopas.catchme.ui.flow.onboard.components - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -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.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import coil.compose.rememberAsyncImagePainter -import coil.request.ImageRequest -import com.canopas.catchme.R -import com.canopas.catchme.data.models.user.ApiUser -import com.canopas.catchme.ui.component.PrimaryButton -import com.canopas.catchme.ui.component.PrimaryTextButton -import com.canopas.catchme.ui.flow.onboard.OnboardViewModel -import com.canopas.catchme.ui.theme.AppTheme - -@Composable -fun JoinSpaceOnboard() { - val viewModel = hiltViewModel() - val state by viewModel.state.collectAsState() - Column( - Modifier - .fillMaxSize() - .background(AppTheme.colorScheme.surface) - .padding(horizontal = 16.dp) - .padding(top = 40.dp, bottom = 20.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.onboard_join_space_title), - style = AppTheme.appTypography.header1, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - ) - Text( - text = stringResource( - R.string.onboard_join_space_joining_space_label, - state.spaceName ?: "" - ), - style = AppTheme.appTypography.header4.copy(fontWeight = FontWeight.W500), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.onboard_join_space_subtitle), - style = AppTheme.appTypography.header4.copy(fontWeight = FontWeight.W500), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - ) - Spacer(modifier = Modifier.height(40.dp)) - - SpaceMemberComponent(tempList) - Spacer(modifier = Modifier.weight(1f)) - PrimaryButton( - label = stringResource(R.string.common_btn_join), - onClick = { viewModel.joinSpace() }, - showLoader = state.joiningSpace - ) - Spacer(modifier = Modifier.height(10.dp)) - PrimaryTextButton( - label = stringResource(R.string.common_btn_skip), - onClick = { viewModel.navigateToPermission() } - ) - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -fun SpaceMemberComponent(users: List) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 6.dp), - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .wrapContentWidth() - ) { - users.take(4).forEachIndexed { index, user -> - val setProfile = user.profile_image ?: R.drawable.ic_user_profile_placeholder - Image( - painter = rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data( - setProfile - ).build() - ), - modifier = Modifier - .padding(start = 45.dp * index) - .size(60.dp) - .border(3.dp, AppTheme.colorScheme.textPrimary, CircleShape) - .clip(CircleShape) - .background(AppTheme.colorScheme.containerHigh) - .padding(if (user.profile_image == null) 32.dp else 0.dp), - contentScale = ContentScale.Crop, - contentDescription = "ProfileImage" - ) - } - if (users.size > 4) { - Icon( - painter = painterResource(id = R.drawable.ic_arrow_down), - modifier = Modifier - .padding(start = 180.dp) - .size(60.dp) - .border(3.dp, AppTheme.colorScheme.textPrimary, CircleShape) - .clip(CircleShape) - .background(AppTheme.colorScheme.surface.copy(alpha = 1f)) - .padding(14.dp) - .rotate(-90f), - tint = AppTheme.colorScheme.textPrimary, - contentDescription = "ProfileImage" - ) - } - } - } -} - -val tempList = listOf( - ApiUser(first_name = "Tom", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tom", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tom hjkk ", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tom r ", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tom sss ", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tommmy", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tomer", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tomyi", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tomlok", profile_image = "https://placebear.com/g/200/200"), - ApiUser(first_name = "Tomaar", profile_image = "https://placebear.com/g/200/200") -) diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/PickNameOnboard.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/PickNameOnboard.kt index 52c03c15..f25659a6 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/PickNameOnboard.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/PickNameOnboard.kt @@ -46,7 +46,7 @@ fun PickNameOnboard() { .fillMaxSize() .background(AppTheme.colorScheme.surface) .verticalScroll(scrollState) - .padding(top = 80.dp) + .padding(top = 80.dp, bottom = 40.dp) ) { Spacer(modifier = Modifier.height(30.dp)) TitleContent() @@ -66,7 +66,7 @@ fun PickNameOnboard() { ) { viewModel.onLastNameChange(it) } - Spacer(modifier = Modifier.height(60.dp)) + Spacer(modifier = Modifier.weight(1f)) PrimaryButton( label = stringResource(R.string.common_btn_next), modifier = Modifier.align(Alignment.CenterHorizontally), @@ -74,7 +74,7 @@ fun PickNameOnboard() { keyboard?.hide() viewModel.navigateToSpaceInfo() }, - enabled = state.firstName.trim().isNotEmpty() && state.lastName.trim().isNotEmpty(), + enabled = state.firstName.trim().isNotEmpty(), showLoader = state.updatingUserName ) } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/ShareSpaceCodeOnboard.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/ShareSpaceCodeOnboard.kt index 7bd962af..beef4268 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/ShareSpaceCodeOnboard.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/ShareSpaceCodeOnboard.kt @@ -78,7 +78,7 @@ fun ShareSpaceCodeOnboard() { Spacer(modifier = Modifier.height(10.dp)) PrimaryTextButton( label = stringResource(R.string.common_btn_skip), - onClick = { viewModel.navigateToPermission() } + onClick = { viewModel.navigateToHome() } ) } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/SpaceInfoOnboard.kt b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/SpaceInfoOnboard.kt index a0211d37..dccd67ea 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/SpaceInfoOnboard.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/onboard/components/SpaceInfoOnboard.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.catchme.R import com.canopas.catchme.ui.component.PrimaryButton +import com.canopas.catchme.ui.component.PrimaryTextButton import com.canopas.catchme.ui.flow.onboard.OnboardViewModel import com.canopas.catchme.ui.theme.AppTheme @@ -41,7 +42,7 @@ fun SpaceInfoOnboard() { TitleContent(state.firstName) Spacer(modifier = Modifier.weight(0.5f)) Image( - painter = painterResource(id = R.drawable.ic_onboard_space_intro), + painter = painterResource(id = R.drawable.ic_empty_location_history), contentDescription = null, modifier = Modifier .fillMaxWidth() @@ -54,14 +55,20 @@ fun SpaceInfoOnboard() { PrimaryButton(label = stringResource(R.string.common_btn_continue), onClick = { viewModel.navigateToJoinOrCreateSpace() }) - Spacer(modifier = Modifier.height(20.dp)) + + Spacer(modifier = Modifier.height(8.dp)) + + PrimaryTextButton( + label = stringResource(R.string.common_btn_skip), + onClick = { viewModel.navigateToHome() } + ) } } @Composable private fun TitleContent(firstName: String) { Text( - text = stringResource(R.string.onboard_space_info_title, firstName), + text = stringResource(R.string.onboard_space_info_title), style = AppTheme.appTypography.header1, textAlign = TextAlign.Center, modifier = Modifier diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionViewModel.kt b/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionViewModel.kt index 5dc2ccc8..00c90fd0 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionViewModel.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionViewModel.kt @@ -1,21 +1,16 @@ package com.canopas.catchme.ui.flow.permission import androidx.lifecycle.ViewModel -import com.canopas.catchme.ui.navigation.AppDestinations -import com.canopas.catchme.ui.navigation.MainNavigator +import com.canopas.catchme.ui.navigation.HomeNavigator import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @HiltViewModel class EnablePermissionViewModel @Inject constructor( - private val appNavigator: MainNavigator + private val appNavigator: HomeNavigator ) : ViewModel() { - fun navigationToHome() { - appNavigator.navigateTo( - AppDestinations.home.path, - popUpToRoute = AppDestinations.enablePermissions.path, - inclusive = true - ) + fun popBack() { + appNavigator.navigateBack() } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionsScreen.kt b/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionsScreen.kt index 12f1dc4a..d4f00535 100644 --- a/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionsScreen.kt +++ b/app/src/main/java/com/canopas/catchme/ui/flow/permission/EnablePermissionsScreen.kt @@ -1,6 +1,7 @@ package com.canopas.catchme.ui.flow.permission import android.Manifest +import android.app.Activity import android.os.Build import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -14,13 +15,21 @@ 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.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,6 +38,7 @@ 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.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -36,21 +46,69 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.catchme.R -import com.canopas.catchme.ui.component.PrimaryButton +import com.canopas.catchme.data.utils.openAppSettings import com.canopas.catchme.ui.theme.AppTheme import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale -@OptIn(ExperimentalPermissionsApi::class) @Composable fun EnablePermissionsScreen() { + Scaffold(topBar = { EnablePermissionsAppBar() }) { + EnablePermissionsContent(Modifier.padding(it)) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EnablePermissionsAppBar() { + val viewModel = hiltViewModel() + + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = AppTheme.colorScheme.surface + ), + title = { + Text( + text = stringResource(id = R.string.enable_permission_title), + style = AppTheme.appTypography.header3 + ) + }, + navigationIcon = { + IconButton( + onClick = { + viewModel.popBack() + }, + modifier = Modifier + ) { + Icon( + Icons.Filled.ArrowBack, + contentDescription = null, + tint = AppTheme.colorScheme.textSecondary + ) + } + } + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun EnablePermissionsContent(modifier: Modifier) { val viewModel = hiltViewModel() + val context = LocalContext.current val locationPermissionStates = rememberMultiplePermissionsState( listOf(Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION) ) + val bgLocationPermissionStates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) { granted -> + } + } else { + null + } + val notificationPermissionStates = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) } else { @@ -61,29 +119,22 @@ fun EnablePermissionsScreen() { mutableStateOf(false) } + val scrollState = rememberScrollState() Column( - Modifier + modifier .fillMaxSize() + .verticalScroll(scrollState) .background(AppTheme.colorScheme.surface) - .padding(vertical = 40.dp), + .padding(vertical = 10.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.height(40.dp)) - Text( - text = stringResource(R.string.enable_permission_title), - style = AppTheme.appTypography.header1, - - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 28.dp) - ) Text( text = stringResource(R.string.enable_permission_subtitle), style = AppTheme.appTypography.label1.copy(color = AppTheme.colorScheme.textSecondary), modifier = Modifier .fillMaxWidth() - .padding(horizontal = 28.dp) + .padding(horizontal = 16.dp) .padding(top = 2.dp) ) @@ -100,13 +151,30 @@ fun EnablePermissionsScreen() { } ) + PermissionContent( + title = stringResource(R.string.enable_permission_background_location_access_title), + description = stringResource(R.string.enable_permission_background_location_access_desc), + isGranted = bgLocationPermissionStates?.status == PermissionStatus.Granted, + onClick = { + if (bgLocationPermissionStates?.status?.shouldShowRationale == true) { + (context as Activity).openAppSettings() + } else { + bgLocationPermissionStates?.launchPermissionRequest() + } + } + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { PermissionContent( title = stringResource(R.string.enable_permission_notification_access_title), description = stringResource(R.string.enable_permission_notification_access_desc), isGranted = notificationPermissionStates?.status == PermissionStatus.Granted, onClick = { - if (notificationPermissionStates?.status != PermissionStatus.Granted) { + if (notificationPermissionStates?.status is PermissionStatus.Denied || + notificationPermissionStates?.status?.shouldShowRationale == true + ) { + showNotificationRational = true + } else if (notificationPermissionStates?.status != PermissionStatus.Granted) { notificationPermissionStates?.launchPermissionRequest() } } @@ -114,19 +182,6 @@ fun EnablePermissionsScreen() { } Spacer(modifier = Modifier.weight(1f)) - PrimaryButton( - label = stringResource(id = R.string.common_btn_continue), - onClick = { - if (notificationPermissionStates?.status != null && notificationPermissionStates.status != PermissionStatus.Granted) { - showNotificationRational = true - } else { - viewModel.navigationToHome() - } - }, - enabled = locationPermissionStates.allPermissionsGranted - ) - - Spacer(modifier = Modifier.height(20.dp)) Text( text = stringResource(R.string.enable_permission_footer), style = AppTheme.appTypography.label3, @@ -141,11 +196,17 @@ fun EnablePermissionsScreen() { NotificationPermissionRationaleDialog( onSkip = { showNotificationRational = false - viewModel.navigationToHome() + viewModel.popBack() }, onContinue = { showNotificationRational = false - notificationPermissionStates?.launchPermissionRequest() + if (notificationPermissionStates?.status is PermissionStatus.Denied && + !notificationPermissionStates.status.shouldShowRationale + ) { + (context as Activity).openAppSettings() + } else { + notificationPermissionStates?.launchPermissionRequest() + } } ) } @@ -178,7 +239,7 @@ private fun PermissionContent( isGranted: Boolean = false, onClick: () -> Unit = {} ) { - Row(modifier = Modifier.padding(horizontal = 28.dp, vertical = 10.dp)) { + Row(modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { Box( modifier = Modifier .clip(CircleShape) @@ -199,21 +260,20 @@ private fun PermissionContent( } } - Column { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 16.dp) + ) { Text( text = title.uppercase(), - style = AppTheme.appTypography.body1.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 28.dp) + style = AppTheme.appTypography.body1.copy(fontWeight = FontWeight.SemiBold) + ) Spacer(modifier = Modifier.height(2.dp)) Text( text = description, - style = AppTheme.appTypography.body3.copy(color = AppTheme.colorScheme.textSecondary), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 28.dp) + style = AppTheme.appTypography.body2.copy(color = AppTheme.colorScheme.textSecondary) ) } } diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/settings/SettingsScreen.kt b/app/src/main/java/com/canopas/catchme/ui/flow/settings/SettingsScreen.kt new file mode 100644 index 00000000..a2ecdd34 --- /dev/null +++ b/app/src/main/java/com/canopas/catchme/ui/flow/settings/SettingsScreen.kt @@ -0,0 +1,235 @@ +package com.canopas.catchme.ui.flow.settings + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.BoxWithConstraints +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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import com.canopas.catchme.R +import com.canopas.catchme.data.models.user.ApiUser +import com.canopas.catchme.ui.component.AppAlertDialog +import com.canopas.catchme.ui.component.PrimaryButton +import com.canopas.catchme.ui.component.PrimaryTextButton +import com.canopas.catchme.ui.theme.AppTheme + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen() { + val viewModel = hiltViewModel() + + Scaffold( + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = AppTheme.colorScheme.surface), + title = { + Text( + text = stringResource(id = R.string.settings_title), + style = AppTheme.appTypography.header3 + ) + }, + navigationIcon = { + IconButton(onClick = { viewModel.popBackStack() }) { + Icon( + Icons.Default.ArrowBack, + contentDescription = "" + ) + } + } + ) + } + ) { + SettingsContent(modifier = Modifier.padding(it)) + } +} + +@Composable +private fun SettingsContent(modifier: Modifier) { + val scrollState = rememberScrollState() + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + Column( + modifier = modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(bottom = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = stringResource(id = R.string.setting_profile), + style = AppTheme.appTypography.header3, + color = AppTheme.colorScheme.textPrimary, + textAlign = TextAlign.Start, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, top = 12.dp) + ) + state.user?.let { ProfileView(user = it) } + + Spacer(modifier = Modifier.weight(1f)) + PrimaryButton( + label = stringResource(id = R.string.setting_btn_sign_out), + onClick = { + viewModel.showSignOutConfirmation(true) + }, + contentColor = AppTheme.colorScheme.alertColor, + containerColor = AppTheme.colorScheme.containerHigh, + showLoader = state.signingOut + ) + Spacer(modifier = Modifier.height(8.dp)) + PrimaryTextButton( + label = stringResource(id = R.string.settings_btn_delete_account), + onClick = { + viewModel.showDeleteAccountConfirmation(true) + }, + contentColor = AppTheme.colorScheme.alertColor, + showLoader = state.deletingAccount + ) + + if (state.openSignOutDialog) { + ShowSignOutDialog(viewModel) + } + + if (state.openDeleteAccountDialog) { + ShowDeleteAccountDialog(viewModel) + } + } +} + +@Composable +fun ShowSignOutDialog(viewModel: SettingsViewModel) { + AppAlertDialog( + title = stringResource(R.string.setting_sign_out_dialogue_title_text), + subTitle = stringResource(R.string.setting_sign_out_dialogue_message_text), + confirmBtnText = stringResource(R.string.setting_sign_out_dialogue_confirm_btn_text), + dismissBtnText = stringResource(R.string.common_btn_cancel), + onConfirmClick = { viewModel.signOutUser() }, + onDismissClick = { viewModel.showSignOutConfirmation(false) }, + isConfirmDestructive = true + ) +} + +@Composable +fun ShowDeleteAccountDialog(viewModel: SettingsViewModel) { + AppAlertDialog( + title = stringResource(R.string.setting_delete_account_dialogue_title_text), + subTitle = stringResource(R.string.setting_delete_account_dialogue_message_text), + confirmBtnText = stringResource(R.string.setting_delete_account_dialogue_confirm_btn_text), + dismissBtnText = stringResource(R.string.common_btn_cancel), + onConfirmClick = { viewModel.deleteAccount() }, + onDismissClick = { viewModel.showDeleteAccountConfirmation(false) }, + isConfirmDestructive = true + ) +} + +@Composable +fun ProfileView(user: ApiUser) { + val userName = user.fullName + val profileImageUrl = user.profile_image ?: "" + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp, horizontal = 16.dp) + ) { + ProfileImageView( + data = profileImageUrl, + modifier = Modifier + .size(66.dp) + .border(1.dp, AppTheme.colorScheme.textDisabled, CircleShape), + char = user.fullName.first().toString() + ) + + Column( + modifier = Modifier + .padding(start = 16.dp) + .weight(1f) + ) { + Text( + text = userName ?: "", + style = AppTheme.appTypography.subTitle2, + color = AppTheme.colorScheme.textPrimary + ) + } + Icon( + Icons.Default.KeyboardArrowRight, + contentDescription = "Consulting Image", + modifier = Modifier.padding(horizontal = 8.dp), + tint = AppTheme.colorScheme.textSecondary + ) + } +} + +@Composable +fun ProfileImageView( + data: String?, + modifier: Modifier = Modifier, + char: String, + backgroundColor: Color = AppTheme.colorScheme.containerHigh, + textColor: Color = AppTheme.colorScheme.textPrimary +) { + if (!data.isNullOrEmpty()) { + Image( + painter = rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(data = data).build() + ), + modifier = modifier.clip(CircleShape), + contentScale = ContentScale.Crop, + contentDescription = "ProfileImage" + ) + } else { + BoxWithConstraints( + modifier = modifier + .clip(CircleShape) + .background(backgroundColor) + .fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Text( + text = char ?: "?", + style = AppTheme.appTypography.subTitle1.copy( + fontSize = (maxWidth.value + maxHeight.value).times(0.18).sp + ), + color = textColor, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/com/canopas/catchme/ui/flow/settings/SettingsViewModel.kt b/app/src/main/java/com/canopas/catchme/ui/flow/settings/SettingsViewModel.kt new file mode 100644 index 00000000..328c58b3 --- /dev/null +++ b/app/src/main/java/com/canopas/catchme/ui/flow/settings/SettingsViewModel.kt @@ -0,0 +1,90 @@ +package com.canopas.catchme.ui.flow.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.canopas.catchme.data.models.user.ApiUser +import com.canopas.catchme.data.repository.SpaceRepository +import com.canopas.catchme.data.service.auth.AuthService +import com.canopas.catchme.data.service.user.ApiUserService +import com.canopas.catchme.data.utils.AppDispatcher +import com.canopas.catchme.ui.navigation.AppDestinations +import com.canopas.catchme.ui.navigation.HomeNavigator +import com.canopas.catchme.ui.navigation.MainNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SettingsViewModel @Inject constructor( + private val navigator: HomeNavigator, + private val appNavigator: MainNavigator, + private val userService: ApiUserService, + private val authService: AuthService, + private val appDispatcher: AppDispatcher, + private val spaceRepository: SpaceRepository +) : ViewModel() { + + private val _state = MutableStateFlow(SettingsScreenState()) + val state = _state.asStateFlow() + + init { + getUser() + } + + private fun getUser() = viewModelScope.launch(appDispatcher.IO) { + val user = authService.currentUser + _state.emit(_state.value.copy(user = user)) + user?.let { + val updatedUser = userService.getUser(it.id) + _state.emit(_state.value.copy(user = updatedUser)) + updatedUser?.let { + authService.saveUser(it) + } + } + } + + fun popBackStack() { + navigator.navigateBack() + } + + fun showSignOutConfirmation(show: Boolean) { + _state.value = _state.value.copy(openSignOutDialog = show) + } + + fun showDeleteAccountConfirmation(show: Boolean) { + _state.value = _state.value.copy(openDeleteAccountDialog = show) + } + + fun signOutUser() = viewModelScope.launch(appDispatcher.IO) { + _state.emit(_state.value.copy(signingOut = true, openSignOutDialog = false)) + authService.signOut() + appNavigator.navigateTo( + AppDestinations.signIn.path, + AppDestinations.home.path, + true + ) + _state.emit(_state.value.copy(signingOut = false)) + } + + fun deleteAccount() = viewModelScope.launch(appDispatcher.IO) { + _state.emit(_state.value.copy(deletingAccount = true, openDeleteAccountDialog = false)) + spaceRepository.deleteUserSpaces() + authService.deleteAccount() + appNavigator.navigateTo( + AppDestinations.signIn.path, + AppDestinations.home.path, + true + ) + _state.emit(_state.value.copy(deletingAccount = false)) + } +} + +data class SettingsScreenState( + val user: ApiUser? = null, + var openSignOutDialog: Boolean = false, + var openDeleteAccountDialog: Boolean = false, + var deletingAccount: Boolean = false, + var signingOut: Boolean = false +) diff --git a/app/src/main/java/com/canopas/catchme/ui/navigation/AppRoute.kt b/app/src/main/java/com/canopas/catchme/ui/navigation/AppRoute.kt index 8d448c70..9cb1dff2 100644 --- a/app/src/main/java/com/canopas/catchme/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/canopas/catchme/ui/navigation/AppRoute.kt @@ -16,6 +16,11 @@ object AppDestinations { override val path: String = "home" } + val settings = object : AppRoute { + override val arguments: List = emptyList() + override val path: String = "settings" + } + val intro = object : AppRoute { override val arguments: List = emptyList() override val path: String = "intro" diff --git a/app/src/main/java/com/canopas/catchme/ui/theme/Color.kt b/app/src/main/java/com/canopas/catchme/ui/theme/Color.kt index fb09dc47..529b65b5 100644 --- a/app/src/main/java/com/canopas/catchme/ui/theme/Color.kt +++ b/app/src/main/java/com/canopas/catchme/ui/theme/Color.kt @@ -39,6 +39,9 @@ private val outlineDarkColor = Color(0x14FFFFFF) private val surfaceLightColor = Color(0xFFFFFFFF) private val surfaceDarkColor = Color(0xFF121212) +private val permissionWarningColor = Color(0xFFf4bb41) +private val awarenessAlertColor = Color(0xFFCA2F27) + internal val themeLightColorScheme = lightColorScheme().copy( primary = primaryColor, onPrimary = textPrimaryLightColor, @@ -133,7 +136,9 @@ data class AppColorScheme( val onPrimary: Color = textPrimaryDarkColor, val onPrimaryVariant: Color = textPrimaryLightColor, val onSecondary: Color = textSecondaryDarkColor, - val onDisabled: Color = textDisabledLightColor + val onDisabled: Color = textDisabledLightColor, + val permissionWarning: Color = permissionWarningColor, + val alertColor: Color = awarenessAlertColor ) { val containerNormalOnSurface: Color get() { diff --git a/app/src/main/res/drawable/ic_empty_location_history.png b/app/src/main/res/drawable/ic_empty_location_history.png index 350a171f..bc5c9cba 100644 Binary files a/app/src/main/res/drawable/ic_empty_location_history.png and b/app/src/main/res/drawable/ic_empty_location_history.png differ diff --git a/app/src/main/res/raw/map_theme_night.json b/app/src/main/res/raw/map_theme_night.json new file mode 100644 index 00000000..049e2138 --- /dev/null +++ b/app/src/main/res/raw/map_theme_night.json @@ -0,0 +1,161 @@ +[ + { + "elementType": "geometry", + "stylers": [ + { + "color": "#242f3e" + } + ] + }, + { + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#746855" + } + ] + }, + { + "elementType": "labels.text.stroke", + "stylers": [ + { + "color": "#242f3e" + } + ] + }, + { + "featureType": "administrative.locality", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#d59563" + } + ] + }, + { + "featureType": "poi", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#d59563" + } + ] + }, + { + "featureType": "poi.park", + "elementType": "geometry", + "stylers": [ + { + "color": "#263c3f" + } + ] + }, + { + "featureType": "poi.park", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#6b9a76" + } + ] + }, + { + "featureType": "road", + "elementType": "geometry", + "stylers": [ + { + "color": "#38414e" + } + ] + }, + { + "featureType": "road", + "elementType": "geometry.stroke", + "stylers": [ + { + "color": "#212a37" + } + ] + }, + { + "featureType": "road", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#9ca5b3" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry", + "stylers": [ + { + "color": "#746855" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "geometry.stroke", + "stylers": [ + { + "color": "#1f2835" + } + ] + }, + { + "featureType": "road.highway", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#f3d19c" + } + ] + }, + { + "featureType": "transit", + "elementType": "geometry", + "stylers": [ + { + "color": "#2f3948" + } + ] + }, + { + "featureType": "transit.station", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#d59563" + } + ] + }, + { + "featureType": "water", + "elementType": "geometry", + "stylers": [ + { + "color": "#17263c" + } + ] + }, + { + "featureType": "water", + "elementType": "labels.text.fill", + "stylers": [ + { + "color": "#515c6d" + } + ] + }, + { + "featureType": "water", + "elementType": "labels.text.stroke", + "stylers": [ + { + "color": "#17263c" + } + ] + } +] \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e1aa0203..740b75a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,9 @@ Join space Okay Congratulations! + Add + Cancel + Unknown Let\'s stay connected! Use invite code %s to join my Space. Download the app: [link]. @@ -33,7 +36,7 @@ What\'s your name? First name Last name - Hi %s, The Map Awaits! Join or create Your Space Now! + Connect, Share, Explore. Join Your Trusted Space for Safe Location Sharing. A Space is your exclusive and private area, just for you and your family. Ready to connect? Input your invite code for Space entry. Get the code from the Space creator to join. @@ -52,15 +55,17 @@ Get set to connect! You\'re about to become a member of the %s Space. Here\'s presenting those eagerly waiting: - Unknown It appears the code is no longer valid or has expired. Please review and enter a valid code. - To start, allow us few permissions + Enable Permissions For an effective user experience on CatchMe, it\'s necessary to enable these permissions. We prioritize the security of your information and want to assure you that we do not share your data with any third-party entities. Location access For seamless sharing of your live location with a trusted contact requires permission for location access. + Background Location access + Granting background location permission allows you to effortlessly stay connected with trusted ones, ensuring seamless coordination and peace of mind in knowing their whereabouts.\n\nGo to settings > CatchMe app info > Location permission > select "Allow all the time" + Notification access Stay connected and receive timely updates by enabling notification permission for check-ins, alerts, and messages from your trusted one. Enable permission @@ -92,6 +97,23 @@ Location turned off Location History No Location History Found! + Stay closed to your loved one + Allow CatchMe to access your location data. + Location sharing is currently not possible + Some permissions are missing - grant access + Settings + Profile + %s settings + General settings + Sign out + Delete account + See you soon! + Sign out + Are you sure you want to sign out? + + Sorry to see you go! + Delete Account + Deleting your account is permanent and you will lose all your CatchMe data. Are you sure you want to delete your account permanently? Family diff --git a/app/src/test/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModelTest.kt b/app/src/test/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModelTest.kt index 1f478aa8..9b8143a4 100644 --- a/app/src/test/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModelTest.kt +++ b/app/src/test/java/com/canopas/catchme/ui/flow/auth/methods/SignInMethodViewModelTest.kt @@ -85,7 +85,7 @@ class SignInMethodViewModelTest { whenever(authService.verifiedGoogleLogin("firebaseToken", account)) .thenReturn(false) viewModel.proceedGoogleSignIn(account) - verify(navigator).navigateTo("enable-permissions", "sign-in", true) + verify(navigator).navigateTo("home", "sign-in", true) } @Test diff --git a/app/src/test/java/com/canopas/catchme/ui/flow/auth/permission/EnablePermissionViewModelTest.kt b/app/src/test/java/com/canopas/catchme/ui/flow/auth/permission/EnablePermissionViewModelTest.kt index 1f4d227b..27b0886a 100644 --- a/app/src/test/java/com/canopas/catchme/ui/flow/auth/permission/EnablePermissionViewModelTest.kt +++ b/app/src/test/java/com/canopas/catchme/ui/flow/auth/permission/EnablePermissionViewModelTest.kt @@ -1,21 +1,21 @@ package com.canopas.catchme.ui.flow.auth.permission import com.canopas.catchme.ui.flow.permission.EnablePermissionViewModel -import com.canopas.catchme.ui.navigation.MainNavigator +import com.canopas.catchme.ui.navigation.HomeNavigator import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.verify class EnablePermissionViewModelTest { - private val appNavigator = mock() + private val appNavigator = mock() private val viewModel = EnablePermissionViewModel(appNavigator) @Test - fun `navigationToHome should navigate to home screen`() { - viewModel.navigationToHome() + fun `popBack should call navigate back`() { + viewModel.popBack() - verify(appNavigator).navigateTo("home", "enable-permissions", true) + verify(appNavigator).navigateBack() } } diff --git a/app/src/test/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModelTest.kt b/app/src/test/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModelTest.kt index 04a844d5..2572c07b 100644 --- a/app/src/test/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModelTest.kt +++ b/app/src/test/java/com/canopas/catchme/ui/flow/home/home/HomeScreenViewModelTest.kt @@ -6,6 +6,7 @@ import com.canopas.catchme.data.models.space.SpaceInfo import com.canopas.catchme.data.models.user.ApiUser import com.canopas.catchme.data.models.user.UserInfo import com.canopas.catchme.data.repository.SpaceRepository +import com.canopas.catchme.data.service.auth.AuthService import com.canopas.catchme.data.service.location.LocationManager import com.canopas.catchme.data.storage.UserPreferences import com.canopas.catchme.data.utils.AppDispatcher @@ -13,6 +14,7 @@ import com.canopas.catchme.ui.flow.home.home.HomeViewModelTestData.space_info1 import com.canopas.catchme.ui.flow.home.home.HomeViewModelTestData.space_info2 import com.canopas.catchme.ui.flow.home.home.HomeViewModelTestData.user1 import com.canopas.catchme.ui.navigation.HomeNavigator +import com.canopas.catchme.ui.navigation.MainNavigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay @@ -46,9 +48,11 @@ class HomeScreenViewModelTest { val coroutineRule = MainCoroutineRule() private val navigator = mock() + private val mainNavigator = mock() private val locationManager = mock() private val spaceRepository = mock() private val userPreferences = mock() + private val authService = mock() private val testDispatcher = AppDispatcher(IO = UnconfinedTestDispatcher()) @@ -57,9 +61,11 @@ class HomeScreenViewModelTest { private fun setUp() { viewModel = HomeScreenViewModel( navigator = navigator, + mainNavigator = mainNavigator, locationManager = locationManager, spaceRepository = spaceRepository, userPreferences = userPreferences, + authService = authService, appDispatcher = testDispatcher ) } @@ -135,14 +141,6 @@ class HomeScreenViewModelTest { assert(viewModel.state.value.currentTab == 1) } - @Test - fun `shouldAskForBackgroundLocationPermission should set shouldAskForBackgroundLocationPermission to true`() = - runTest { - setUp() - viewModel.shouldAskForBackgroundLocationPermission(true) - assert(viewModel.state.value.shouldAskForBackgroundLocationPermission) - } - @Test fun `toggleSpaceSelection should toggle showSpaceSelectionPopup`() = runTest { setUp() @@ -150,13 +148,6 @@ class HomeScreenViewModelTest { assert(viewModel.state.value.showSpaceSelectionPopup) } - @Test - fun `startTracking should set shouldAskForBackgroundLocationPermission to false`() = runTest { - setUp() - viewModel.startTracking() - assert(!viewModel.state.value.shouldAskForBackgroundLocationPermission) - } - @Test fun `startTracking should call locationManager startService`() = runTest { setUp() @@ -288,7 +279,9 @@ class HomeScreenViewModelTest { whenever(spaceRepository.currentSpaceId).thenReturn("space1") whenever(spaceRepository.getAllSpaceInfo()).thenReturn(flowOf(listOf(space_info1))) whenever(userPreferences.currentUser).thenReturn(user1) - whenever(spaceRepository.enableLocation("space1", "user1", false)).thenThrow(RuntimeException("error")) + whenever(spaceRepository.enableLocation("space1", "user1", false)).thenThrow( + RuntimeException("error") + ) setUp() viewModel.toggleLocation() assert(viewModel.state.value.error == "error") diff --git a/app/src/test/java/com/canopas/catchme/ui/flow/home/map/MapViewModelTest.kt b/app/src/test/java/com/canopas/catchme/ui/flow/home/map/MapViewModelTest.kt index da8c9256..bd0c8032 100644 --- a/app/src/test/java/com/canopas/catchme/ui/flow/home/map/MapViewModelTest.kt +++ b/app/src/test/java/com/canopas/catchme/ui/flow/home/map/MapViewModelTest.kt @@ -1,18 +1,25 @@ package com.canopas.catchme.ui.flow.home.map import com.canopas.catchme.MainCoroutineRule +import com.canopas.catchme.data.models.space.ApiSpace import com.canopas.catchme.data.models.user.ApiUser +import com.canopas.catchme.data.models.user.UserInfo import com.canopas.catchme.data.repository.SpaceRepository import com.canopas.catchme.data.service.location.LocationManager import com.canopas.catchme.data.storage.UserPreferences import com.canopas.catchme.data.utils.AppDispatcher +import com.canopas.catchme.ui.navigation.HomeNavigator +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext import org.junit.Rule import org.junit.Test +import org.mockito.kotlin.doSuspendableAnswer import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -26,6 +33,7 @@ class MapViewModelTest { private val spaceRepository = mock() private val userPreferences = mock() private val locationManager = mock() + private val navigator = mock() private val testDispatcher = AppDispatcher(IO = UnconfinedTestDispatcher()) private lateinit var viewModel: MapViewModel @@ -35,7 +43,8 @@ class MapViewModelTest { spaceRepository = spaceRepository, userPreferences = userPreferences, locationManager = locationManager, - appDispatcher = testDispatcher + appDispatcher = testDispatcher, + navigator = navigator ) } @@ -52,4 +61,142 @@ class MapViewModelTest { setUp() verify(spaceRepository).getMemberWithLocation() } + + @Test + fun `showMemberDetail should set selectedUser`() = runTest { + val user = ApiUser(id = "user1") + val info = UserInfo(user) + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + setUp() + viewModel.showMemberDetail(info) + assert(viewModel.state.value.selectedUser == info) + assert(viewModel.state.value.showUserDetails) + } + + @Test + fun `dismissMemberDetail should set selectedUser to null`() = runTest { + val user = ApiUser(id = "user1") + val info = UserInfo(user) + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + setUp() + viewModel.showMemberDetail(info) + viewModel.dismissMemberDetail() + assert(viewModel.state.value.selectedUser == null) + assert(!viewModel.state.value.showUserDetails) + } + + @Test + fun `showMemberDetail should dismissMemberDetail if selectedUser is not null`() = runTest { + val user = ApiUser(id = "user1") + val info = UserInfo(user) + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + setUp() + viewModel.showMemberDetail(info) + assert(viewModel.state.value.selectedUser == info) + assert(viewModel.state.value.showUserDetails) + viewModel.showMemberDetail(info) + assert(viewModel.state.value.selectedUser == null) + assert(!viewModel.state.value.showUserDetails) + } + + @Test + fun `addMember should set loadingInviteCode to true`() = runTest { + val user = ApiUser(id = "user1") + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + whenever(spaceRepository.getCurrentSpace()).doSuspendableAnswer { + withContext(Dispatchers.IO) { + delay(1000) + null + } + } + setUp() + viewModel.addMember() + assert(viewModel.state.value.loadingInviteCode) + } + + @Test + fun `addMember should call spaceRepository getCurrentSpace`() = runTest { + val user = ApiUser(id = "user1") + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + setUp() + viewModel.addMember() + verify(spaceRepository).getCurrentSpace() + } + + @Test + fun `addMember should call getInviteCode`() = runTest { + val user = ApiUser(id = "user1") + val space = ApiSpace(id = "space1") + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + whenever(spaceRepository.getCurrentSpace()).thenReturn(space) + setUp() + viewModel.addMember() + verify(spaceRepository).getInviteCode("space1") + } + + @Test + fun `addMember should navigate to spaceInvitation`() = runTest { + val user = ApiUser(id = "user1") + val space = ApiSpace(id = "space1", name = "space1") + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + whenever(spaceRepository.getCurrentSpace()).thenReturn(space) + whenever(spaceRepository.getInviteCode("space1")).thenReturn("inviteCode") + setUp() + viewModel.addMember() + verify(navigator).navigateTo( + "space-invite/inviteCode/space1", + "create-space", + true + ) + } + + @Test + fun `addMember should set error if getCurrentSpace throws exception`() = runTest { + val user = ApiUser(id = "user1") + val flow = flow { + emit("space1") + } + whenever(userPreferences.currentUser).thenReturn(user) + whenever(userPreferences.currentSpaceState).thenReturn(flow) + whenever(spaceRepository.getMemberWithLocation()).thenReturn(flowOf(emptyList())) + whenever(spaceRepository.getCurrentSpace()).thenThrow(RuntimeException("error")) + setUp() + viewModel.addMember() + assert(viewModel.state.value.error == "error") + } } diff --git a/app/src/test/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModelTest.kt b/app/src/test/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModelTest.kt index 7855bae2..50653174 100644 --- a/app/src/test/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModelTest.kt +++ b/app/src/test/java/com/canopas/catchme/ui/flow/onboard/OnboardViewModelTest.kt @@ -171,14 +171,14 @@ class OnboardViewModelTest { @Test fun `navigateToPermission should set OnboardShown to true`() = runTest { - viewModel.navigateToPermission() + viewModel.navigateToHome() verify(userPreferences).setOnboardShown(true) } @Test - fun `navigateToPermission should navigate to permission screen`() = runTest { - viewModel.navigateToPermission() - verify(navigator).navigateTo("enable-permissions", "onboard", true) + fun `navigateToHome should navigate to home screen`() = runTest { + viewModel.navigateToHome() + verify(navigator).navigateTo("home", "onboard", true) } @Test @@ -276,7 +276,7 @@ class OnboardViewModelTest { } @Test - fun `submitInviteCode should set currentStep to JoinSpace`() = runTest { + fun `submitInviteCode should set joinedSpace`() = runTest { viewModel.onInviteCodeChanged("inviteCode") val invitation = mock() @@ -289,7 +289,7 @@ class OnboardViewModelTest { whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) viewModel.submitInviteCode() - assert(viewModel.state.value.currentStep == OnboardItems.JoinSpace) + assert(viewModel.state.value.joinedSpace == space) } @Test @@ -301,125 +301,6 @@ class OnboardViewModelTest { assert(viewModel.state.value.error == "error") } - @Test - fun `joinSpace should set joiningSpace to true`() = runTest { - val invitation = mock() - - whenever(invitation.space_id).thenReturn("spaceId") - - val space = mock() - whenever(space.name).thenReturn("spaceName") - - whenever(invitationService.getInvitation("inviteCode")).thenReturn(invitation) - whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) - - whenever(spaceRepository.joinSpace("spaceId")).doSuspendableAnswer { - withContext(Dispatchers.IO) { delay(5000) } - return@doSuspendableAnswer null - } - - viewModel.onInviteCodeChanged("inviteCode") - viewModel.submitInviteCode() - viewModel.joinSpace() - assert(viewModel.state.value.joiningSpace) - } - - @Test - fun `joinSpace should call joinSpace`() = runTest { - val invitation = mock() - - whenever(invitation.space_id).thenReturn("spaceId") - - val space = mock() - whenever(space.name).thenReturn("spaceName") - - whenever(invitationService.getInvitation("inviteCode")).thenReturn(invitation) - whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) - - viewModel.onInviteCodeChanged("inviteCode") - viewModel.submitInviteCode() - - viewModel.joinSpace() - verify(spaceRepository).joinSpace("spaceId") - } - - @Test - fun `joinSpace should set joiningSpace to false after space joined`() = runTest { - val invitation = mock() - - whenever(invitation.space_id).thenReturn("spaceId") - - val space = mock() - whenever(space.name).thenReturn("spaceName") - - whenever(invitationService.getInvitation("inviteCode")).thenReturn(invitation) - whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) - - viewModel.onInviteCodeChanged("inviteCode") - viewModel.submitInviteCode() - viewModel.joinSpace() - - assert(!viewModel.state.value.joiningSpace) - } - - @Test - fun `joinSpace should navigate to permission screen after space joined`() = runTest { - val invitation = mock() - - whenever(invitation.space_id).thenReturn("spaceId") - - val space = mock() - whenever(space.name).thenReturn("spaceName") - - whenever(invitationService.getInvitation("inviteCode")).thenReturn(invitation) - whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) - - viewModel.onInviteCodeChanged("inviteCode") - viewModel.submitInviteCode() - viewModel.joinSpace() - - verify(navigator).navigateTo("enable-permissions", "onboard", true) - } - - @Test - fun `joinSpace should set OnboardShown to true after space joined`() = runTest { - val invitation = mock() - - whenever(invitation.space_id).thenReturn("spaceId") - - val space = mock() - whenever(space.name).thenReturn("spaceName") - - whenever(invitationService.getInvitation("inviteCode")).thenReturn(invitation) - whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) - - viewModel.onInviteCodeChanged("inviteCode") - viewModel.submitInviteCode() - viewModel.joinSpace() - - verify(userPreferences).setOnboardShown(true) - } - - @Test - fun `joinSpace should set error if joinSpace throw error`() = runTest { - val invitation = mock() - - whenever(invitation.space_id).thenReturn("spaceId") - - val space = mock() - whenever(space.name).thenReturn("spaceName") - - whenever(invitationService.getInvitation("inviteCode")).thenReturn(invitation) - whenever(spaceRepository.getSpace("spaceId")).thenReturn(space) - whenever(spaceRepository.joinSpace("spaceId")).thenThrow(RuntimeException("error")) - - viewModel.onInviteCodeChanged("inviteCode") - viewModel.submitInviteCode() - viewModel.joinSpace() - - assert(viewModel.state.value.error == "error") - } - @Test fun `resetErrorState should reset error state`() { viewModel.resetErrorState() diff --git a/data/src/main/java/com/canopas/catchme/data/repository/SpaceRepository.kt b/data/src/main/java/com/canopas/catchme/data/repository/SpaceRepository.kt index 4f70fe7a..cc0f066a 100644 --- a/data/src/main/java/com/canopas/catchme/data/repository/SpaceRepository.kt +++ b/data/src/main/java/com/canopas/catchme/data/repository/SpaceRepository.kt @@ -66,7 +66,7 @@ class SpaceRepository @Inject constructor( } } - private suspend fun getCurrentSpace(): ApiSpace? { + suspend fun getCurrentSpace(): ApiSpace? { val spaceId = currentSpaceId if (spaceId.isEmpty()) { @@ -117,4 +117,22 @@ class SpaceRepository @Inject constructor( suspend fun enableLocation(spaceId: String, userId: String, locationEnabled: Boolean) { spaceService.enableLocation(spaceId, userId, locationEnabled) } + + suspend fun deleteUserSpaces() { + val userId = authService.currentUser?.id ?: "" + val allSpace = getUserSpaces(userId).firstOrNull()?.filterNotNull() ?: emptyList() + val ownSpace = allSpace.filter { it.admin_id == userId } + val joinedSpace = allSpace.filter { it.admin_id != userId } + + ownSpace.forEach { space -> + invitationService.deleteInvitations(space.id) + spaceService.deleteSpace(space.id) + } + + joinedSpace.forEach { space -> + spaceService.removeUserFromSpace(space.id, userId) + } + + locationService.deleteLocations(userId) + } } diff --git a/data/src/main/java/com/canopas/catchme/data/service/auth/AuthService.kt b/data/src/main/java/com/canopas/catchme/data/service/auth/AuthService.kt index 0f41be9a..712c5e42 100644 --- a/data/src/main/java/com/canopas/catchme/data/service/auth/AuthService.kt +++ b/data/src/main/java/com/canopas/catchme/data/service/auth/AuthService.kt @@ -122,4 +122,23 @@ class AuthService @Inject constructor( userRef.document(user.id).set(user).await() currentUser = user } + + fun signOut() { + currentUser = null + currentUserSession = null + userPreferences.setOnboardShown(false) + userPreferences.currentSpace = "" + } + + suspend fun deleteAccount() { + val currentUser = currentUser ?: return + userRef.document(currentUser.id).delete().await() + sessionRef.whereEqualTo("user_id", currentUser.id).get().await().documents.forEach { + it.reference.delete().await() + } + signOut() + } + + suspend fun getUser(): ApiUser? = + userRef.document(currentUser?.id ?: "").get().await().toObject(ApiUser::class.java) } diff --git a/data/src/main/java/com/canopas/catchme/data/service/location/ApiLocationService.kt b/data/src/main/java/com/canopas/catchme/data/service/location/ApiLocationService.kt index 8400e955..eac8e6f0 100644 --- a/data/src/main/java/com/canopas/catchme/data/service/location/ApiLocationService.kt +++ b/data/src/main/java/com/canopas/catchme/data/service/location/ApiLocationService.kt @@ -44,4 +44,10 @@ class ApiLocationService @Inject constructor( .whereGreaterThanOrEqualTo("created_at", from) .whereLessThan("created_at", to) .orderBy("created_at", Query.Direction.DESCENDING).limit(8) + + suspend fun deleteLocations(userId: String) { + locationRef.whereEqualTo("user_id", userId).get().await().documents.forEach { + it.reference.delete().await() + } + } } diff --git a/data/src/main/java/com/canopas/catchme/data/service/location/LocationManager.kt b/data/src/main/java/com/canopas/catchme/data/service/location/LocationManager.kt index 28c4c39c..49188c3e 100644 --- a/data/src/main/java/com/canopas/catchme/data/service/location/LocationManager.kt +++ b/data/src/main/java/com/canopas/catchme/data/service/location/LocationManager.kt @@ -7,6 +7,7 @@ import android.location.Location import android.os.Build import com.canopas.catchme.data.receiver.location.ACTION_LOCATION_UPDATE import com.canopas.catchme.data.receiver.location.LocationUpdateReceiver +import com.canopas.catchme.data.utils.hasCoarseLocationPermission import com.canopas.catchme.data.utils.hasFineLocationPermission import com.canopas.catchme.data.utils.isBackgroundLocationPermissionGranted import com.google.android.gms.location.FusedLocationProviderClient @@ -33,7 +34,10 @@ class LocationManager @Inject constructor(@ApplicationContext private val contex request = createRequest() } - suspend fun getLastLocation(): Location? = locationClient.lastLocation.await() + suspend fun getLastLocation(): Location? { + if (!context.hasCoarseLocationPermission) return null + return locationClient.lastLocation.await() + } private val locationUpdatePendingIntent: PendingIntent by lazy { val intent = Intent(context, LocationUpdateReceiver::class.java) diff --git a/data/src/main/java/com/canopas/catchme/data/service/space/ApiSpaceService.kt b/data/src/main/java/com/canopas/catchme/data/service/space/ApiSpaceService.kt index 207dac7b..d4063c46 100644 --- a/data/src/main/java/com/canopas/catchme/data/service/space/ApiSpaceService.kt +++ b/data/src/main/java/com/canopas/catchme/data/service/space/ApiSpaceService.kt @@ -75,4 +75,22 @@ class ApiSpaceService @Inject constructor( suspend fun getMemberBySpaceId(spaceId: String) = spaceMemberRef.whereEqualTo("space_id", spaceId).snapshotFlow(ApiSpaceMember::class.java) + + suspend fun deleteMembers(spaceId: String) { + spaceMemberRef.whereEqualTo("space_id", spaceId).get().await().documents.forEach { doc -> + doc.reference.delete().await() + } + } + + suspend fun deleteSpace(spaceId: String) { + deleteMembers(spaceId) + spaceRef.document(spaceId).delete().await() + } + + suspend fun removeUserFromSpace(spaceId: String, userId: String) { + spaceMemberRef.whereEqualTo("space_id", spaceId) + .whereEqualTo("user_id", userId).get().await().documents.forEach { + it.reference.delete().await() + } + } } diff --git a/data/src/main/java/com/canopas/catchme/data/service/space/SpaceInvitationService.kt b/data/src/main/java/com/canopas/catchme/data/service/space/SpaceInvitationService.kt index b2d5d01e..b8cca690 100644 --- a/data/src/main/java/com/canopas/catchme/data/service/space/SpaceInvitationService.kt +++ b/data/src/main/java/com/canopas/catchme/data/service/space/SpaceInvitationService.kt @@ -54,4 +54,10 @@ class SpaceInvitationService @Inject constructor( val invitation = result.documents.firstOrNull()?.toObject(ApiSpaceInvitation::class.java) return invitation?.takeIf { !it.isExpired } } + + suspend fun deleteInvitations(spaceId: String) { + spaceInvitationRef.whereEqualTo("space_id", spaceId).get().await().documents.forEach { + it.reference.delete().await() + } + } } diff --git a/data/src/main/java/com/canopas/catchme/data/storage/UserPreferences.kt b/data/src/main/java/com/canopas/catchme/data/storage/UserPreferences.kt index d462bbb2..d699479e 100644 --- a/data/src/main/java/com/canopas/catchme/data/storage/UserPreferences.kt +++ b/data/src/main/java/com/canopas/catchme/data/storage/UserPreferences.kt @@ -56,7 +56,7 @@ class UserPreferences @Inject constructor( return preferencesDataStore.data.first()[PreferencesKey.ONBOARD_SHOWN] ?: false } - suspend fun setOnboardShown(value: Boolean) { + fun setOnboardShown(value: Boolean) = runBlocking { preferencesDataStore.edit { preferences -> preferences[PreferencesKey.ONBOARD_SHOWN] = value } diff --git a/data/src/main/java/com/canopas/catchme/data/utils/PermissionExts.kt b/data/src/main/java/com/canopas/catchme/data/utils/PermissionExts.kt index 268bd141..794fffe4 100644 --- a/data/src/main/java/com/canopas/catchme/data/utils/PermissionExts.kt +++ b/data/src/main/java/com/canopas/catchme/data/utils/PermissionExts.kt @@ -34,7 +34,7 @@ val Context.isBackgroundLocationPermissionGranted get() = hasBackgroundLocationP val Context.hasFineLocationPermission get() = checkPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) -private val Context.hasCoarseLocationPermission +val Context.hasCoarseLocationPermission get() = checkPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) private val Context.hasBackgroundLocationPermission @@ -43,3 +43,13 @@ private val Context.hasBackgroundLocationPermission } else { true } + +val Context.hasNotificationPermission + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + checkPermission(this, Manifest.permission.POST_NOTIFICATIONS) + } else { + true + } + +val Context.hasAllPermission get() = isLocationPermissionGranted && hasNotificationPermission