diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7aaae700..204fb571 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -210,5 +210,8 @@ dependencies { // Desugaring coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3") + // Gson + implementation("com.google.code.gson:gson:2.10.1") + implementation(project(":data")) } diff --git a/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt b/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt index 3ff87234..fb6a1c13 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt @@ -1,6 +1,7 @@ package com.canopas.yourspace.ui import android.content.Intent +import android.net.Uri import android.os.Bundle import android.view.Window import androidx.activity.ComponentActivity @@ -22,6 +23,7 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import com.canopas.yourspace.R +import com.canopas.yourspace.data.models.space.SpaceInfo import com.canopas.yourspace.ui.component.AppAlertDialog import com.canopas.yourspace.ui.flow.auth.SignInMethodsScreen import com.canopas.yourspace.ui.flow.geofence.add.addnew.AddNewPlaceScreen @@ -48,13 +50,16 @@ import com.canopas.yourspace.ui.flow.permission.EnablePermissionsScreen import com.canopas.yourspace.ui.flow.settings.SettingsScreen import com.canopas.yourspace.ui.flow.settings.profile.EditProfileScreen import com.canopas.yourspace.ui.flow.settings.space.SpaceProfileScreen +import com.canopas.yourspace.ui.flow.settings.space.edit.ChangeAdminScreen import com.canopas.yourspace.ui.flow.settings.support.SupportScreen import com.canopas.yourspace.ui.navigation.AppDestinations +import com.canopas.yourspace.ui.navigation.AppDestinations.ChangeAdminScreen.KEY_SPACE_NAME import com.canopas.yourspace.ui.navigation.AppNavigator import com.canopas.yourspace.ui.navigation.KEY_RESULT import com.canopas.yourspace.ui.navigation.RESULT_OKAY import com.canopas.yourspace.ui.navigation.slideComposable import com.canopas.yourspace.ui.theme.CatchMeTheme +import com.google.gson.Gson import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint @@ -159,6 +164,15 @@ fun MainApp(viewModel: MainViewModel) { SpaceProfileScreen() } + slideComposable(AppDestinations.ChangeAdminScreen.path) { + val spaceInfo = it.arguments?.getString(KEY_SPACE_NAME)?.let { encodedInfo -> + Uri.decode(encodedInfo) + } ?: "" + + val space = Gson().fromJson(spaceInfo, SpaceInfo::class.java) + ChangeAdminScreen(space = space) + } + slideComposable(AppDestinations.spaceThreads.path) { ThreadsScreen() } diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/SettingsScreen.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/SettingsScreen.kt index 4625fb61..33a59461 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/SettingsScreen.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/SettingsScreen.kt @@ -1,6 +1,7 @@ package com.canopas.yourspace.ui.flow.settings import android.app.Activity +import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.foundation.Image @@ -186,6 +187,18 @@ private fun OtherSettingsContent(viewModel: SettingsViewModel) { } ) + SettingsItem( + label = stringResource(id = R.string.setting_share_app), + icon = R.drawable.ic_setting_share_icon, + onClick = { shareApp(context) } + ) + + SettingsItem( + label = stringResource(id = R.string.setting_rate_app), + icon = R.drawable.ic_setting_rate_icon, + onClick = { rateApp(context) } + ) + SettingsItem( label = stringResource(id = R.string.setting_btn_sign_out), icon = R.drawable.ic_setting_sign_out_icon, @@ -202,6 +215,29 @@ private fun openUrl(context: Activity, url: String) { context.startActivity(intent) } +fun shareApp(context: Context) { + val appUrl = "https://play.google.com/store/apps/details?id=${context.packageName}" + val shareMessage = context.getString(R.string.app_share_message, appUrl) + + val shareIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareMessage) + type = "text/plain" + } + context.startActivity( + Intent.createChooser( + shareIntent, + context.getString(R.string.setting_share_app) + ) + ) +} + +fun rateApp(context: Context) { + val appUrl = "https://play.google.com/store/apps/details?id=${context.packageName}" + val rateIntent = Intent(Intent.ACTION_VIEW, Uri.parse(appUrl)) + context.startActivity(rateIntent) +} + @Composable private fun SpaceSettingsContent( spaces: List, diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileScreen.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileScreen.kt index 38f969c0..7b2a2959 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileScreen.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileScreen.kt @@ -20,6 +20,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -32,6 +35,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.ripple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -56,12 +60,20 @@ import com.canopas.yourspace.ui.component.PrimaryTextButton import com.canopas.yourspace.ui.component.UserProfile import com.canopas.yourspace.ui.flow.settings.profile.UserTextField import com.canopas.yourspace.ui.theme.AppTheme +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext @Composable fun SpaceProfileScreen() { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + viewModel.fetchSpaceDetail() + } + } + Scaffold( topBar = { SpaceProfileToolbar() @@ -105,6 +117,18 @@ fun SpaceProfileScreen() { ) } + if (state.showChangeAdminDialog) { + AppAlertDialog( + title = stringResource(R.string.space_setting_change_admin_title), + subTitle = stringResource(R.string.space_setting_change_admin_description), + confirmBtnText = stringResource(R.string.change_admin_button), + dismissBtnText = stringResource(id = R.string.common_btn_cancel), + isConfirmDestructive = true, + onConfirmClick = { viewModel.onChangeAdminClicked() }, + onDismissClick = { viewModel.showChangeAdminDialog(false) } + ) + } + if (state.showLeaveSpaceConfirmation) { AppAlertDialog( title = stringResource(id = R.string.space_settings_btn_leave_space), @@ -180,6 +204,29 @@ private fun SpaceProfileToolbar() { } ) ) + if (state.isAdmin && state.spaceMemberCount > 1) { + IconButton( + onClick = { viewModel.onAdminMenuExpanded(true) } + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "" + ) + } + + DropdownMenu( + expanded = state.isMenuExpanded, + onDismissRequest = { viewModel.onAdminMenuExpanded(false) } + ) { + DropdownMenuItem( + text = { Text(stringResource(id = R.string.space_setting_change_admin_title)) }, + onClick = { + viewModel.onAdminMenuExpanded(false) + viewModel.navigateToChangeAdminScreen(state.spaceInfo) + } + ) + } + } } ) } @@ -222,6 +269,7 @@ private fun SpaceProfileContent() { enable = true, isAdmin = state.isAdmin, currentUser = state.currentUserId!!, + isAdminUser = state.spaceInfo?.space?.admin_id == it.user.id, onCheckedChange = { viewModel.onLocationEnabledChanged(it) }, @@ -252,6 +300,7 @@ private fun SpaceProfileContent() { enable = false, isAdmin = state.isAdmin, currentUser = state.currentUserId!!, + isAdminUser = state.spaceInfo?.space?.admin_id == it.user.id, onCheckedChange = { }, onMemberRemove = { @@ -276,7 +325,7 @@ private fun SpaceProfileContent() { ) } } - if (state.spaceInfo != null && state.currentUserId == state.spaceInfo?.space?.admin_id) { + if (state.spaceInfo != null && state.spaceMemberCount == 1) { FooterButton( title = stringResource(id = R.string.space_settings_btn_delete_space), onClick = { @@ -287,11 +336,15 @@ private fun SpaceProfileContent() { ) } - if (state.spaceInfo != null && state.currentUserId != state.spaceInfo?.space?.admin_id) { + if (state.spaceInfo != null && state.spaceMemberCount > 1) { FooterButton( title = stringResource(id = R.string.space_settings_btn_leave_space), onClick = { - viewModel.showLeaveSpaceConfirmation(true) + if (state.isAdmin) { + viewModel.showChangeAdminDialog(true) + } else { + viewModel.showLeaveSpaceConfirmation(true) + } }, showLoader = state.leavingSpace, icon = Icons.AutoMirrored.Filled.ExitToApp @@ -348,6 +401,7 @@ private fun UserItem( enable: Boolean, isAdmin: Boolean, currentUser: String, + isAdminUser: Boolean = false, onCheckedChange: (Boolean) -> Unit, onMemberRemove: () -> Unit ) { @@ -360,13 +414,28 @@ private fun UserItem( ) { UserProfile(modifier = Modifier.size(40.dp), user = userInfo.user) Spacer(modifier = Modifier.width(8.dp)) - Text( - text = userInfo.user.fullName, - style = AppTheme.appTypography.subTitle2, - color = AppTheme.colorScheme.textPrimary, - textAlign = TextAlign.Start, + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, modifier = Modifier.weight(1f) - ) + ) { + Text( + text = userInfo.user.fullName, + style = AppTheme.appTypography.subTitle2, + color = AppTheme.colorScheme.textPrimary, + textAlign = TextAlign.Start + ) + + if (isAdminUser) { + Text( + text = stringResource(R.string.space_profile_screen_admin_text), + style = AppTheme.appTypography.subTitle3, + color = AppTheme.colorScheme.textDisabled, + modifier = Modifier.padding(start = 8.dp) + ) + } + } Switch( checked = isChecked, diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModel.kt index 8aa9d2d6..0ff47d4d 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModel.kt @@ -1,5 +1,6 @@ package com.canopas.yourspace.ui.flow.settings.space +import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -10,6 +11,7 @@ import com.canopas.yourspace.data.utils.AppDispatcher import com.canopas.yourspace.domain.utils.ConnectivityObserver import com.canopas.yourspace.ui.navigation.AppDestinations import com.canopas.yourspace.ui.navigation.AppNavigator +import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -37,10 +39,9 @@ class SpaceProfileViewModel @Inject constructor( init { checkInternetConnection() - fetchSpaceDetail() } - private fun fetchSpaceDetail() = viewModelScope.launch(appDispatcher.IO) { + fun fetchSpaceDetail() = viewModelScope.launch(appDispatcher.IO) { _state.emit(_state.value.copy(isLoading = true)) try { val spaceInfo = spaceRepository.getSpaceInfo(spaceID) @@ -54,7 +55,8 @@ class SpaceProfileViewModel @Inject constructor( currentUserId = authService.currentUser?.id, isAdmin = spaceInfo?.space?.admin_id == authService.currentUser?.id, spaceName = spaceInfo?.space?.name, - locationEnabled = locationEnabled + locationEnabled = locationEnabled, + spaceMemberCount = spaceInfo?.members?.size ?: 1 ) ) } catch (e: Exception) { @@ -184,18 +186,27 @@ class SpaceProfileViewModel @Inject constructor( } } - fun checkInternetConnection() { - viewModelScope.launch(appDispatcher.IO) { - connectivityObserver.observe().collectLatest { status -> - _state.emit( - _state.value.copy( - connectivityStatus = status - ) - ) - } + fun onChangeAdminClicked() { + val spaceInfo = _state.value.spaceInfo + if (spaceInfo != null) { + navigateToChangeAdminScreen(spaceInfo) + _state.value = _state.value.copy(showChangeAdminDialog = false) } } + fun onAdminMenuExpanded(value: Boolean) { + _state.value = _state.value.copy(isMenuExpanded = value) + } + + fun showChangeAdminDialog(show: Boolean) { + _state.value = _state.value.copy(showChangeAdminDialog = show) + } + + fun navigateToChangeAdminScreen(spaceInfo: SpaceInfo?) { + val spaceDetail = Uri.encode(Gson().toJson(spaceInfo)) + navigator.navigateTo(AppDestinations.ChangeAdminScreen.getSpaceDetail(spaceDetail).path) + } + fun showRemoveMemberConfirmationWithId(show: Boolean, memberId: String) { _state.value = state.value.copy(showRemoveMemberConfirmation = show, memberToRemove = memberId) } @@ -220,6 +231,18 @@ class SpaceProfileViewModel @Inject constructor( ) } } + + fun checkInternetConnection() { + viewModelScope.launch(appDispatcher.IO) { + connectivityObserver.observe().collectLatest { status -> + _state.emit( + _state.value.copy( + connectivityStatus = status + ) + ) + } + } + } } data class SpaceProfileState( @@ -238,5 +261,8 @@ data class SpaceProfileState( val error: Exception? = null, val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, val showRemoveMemberConfirmation: Boolean = false, - val memberToRemove: String? = null + val memberToRemove: String? = null, + val spaceMemberCount: Int = 1, + val showChangeAdminDialog: Boolean = false, + var isMenuExpanded: Boolean = false ) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/edit/ChangeAdminScreen.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/edit/ChangeAdminScreen.kt new file mode 100644 index 00000000..b24991db --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/edit/ChangeAdminScreen.kt @@ -0,0 +1,187 @@ +package com.canopas.yourspace.ui.flow.settings.space.edit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +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.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.canopas.yourspace.R +import com.canopas.yourspace.data.models.space.SpaceInfo +import com.canopas.yourspace.data.models.user.UserInfo +import com.canopas.yourspace.domain.utils.ConnectivityObserver +import com.canopas.yourspace.ui.component.AppProgressIndicator +import com.canopas.yourspace.ui.component.NoInternetScreen +import com.canopas.yourspace.ui.component.UserProfile +import com.canopas.yourspace.ui.theme.AppTheme + +@Composable +fun ChangeAdminScreen(space: SpaceInfo) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + var selectedUserId by remember { mutableStateOf(space.space.admin_id) } + + LaunchedEffect(Unit) { + if (space.space.id.isNotEmpty()) { + viewModel.fetchSpaceDetail(space.space.id) + selectedUserId = space.space.admin_id + } + } + + Scaffold( + topBar = { ChangeAdminAppBar(selectedUserId) } + ) { + Box( + modifier = Modifier + .padding(it) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (state.connectivityStatus == ConnectivityObserver.Status.Available) { + SpaceMemberListContent( + userInfo = space.members, + onUserSelect = { userId -> selectedUserId = userId }, + selectedUserId = selectedUserId + ) + if (state.isLoading) { + AppProgressIndicator() + } + } else { + NoInternetScreen(viewModel::checkInternetConnection) + } + } + } +} + +@Composable +fun SpaceMemberListContent( + userInfo: List, + onUserSelect: (String) -> Unit, + selectedUserId: String? +) { + val scrollState = rememberScrollState() + Box(modifier = Modifier.fillMaxSize()) { + Column( + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(bottom = 80.dp) + ) { + userInfo.forEach { user -> + UserItem( + userInfo = user, + isSelected = selectedUserId == user.user.id, + onUserSelect = onUserSelect + ) + } + } + } +} + +@Composable +private fun UserItem( + userInfo: UserInfo, + isSelected: Boolean, + onUserSelect: (String) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + UserProfile(modifier = Modifier.size(40.dp), user = userInfo.user) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = userInfo.user.fullName, + style = AppTheme.appTypography.subTitle2, + color = AppTheme.colorScheme.textPrimary, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + + RadioButton( + selected = isSelected, + onClick = { onUserSelect(userInfo.user.id) }, + enabled = true, + colors = RadioButtonDefaults.colors( + selectedColor = AppTheme.colorScheme.primary, + unselectedColor = AppTheme.colorScheme.textDisabled + ), + modifier = Modifier.padding(end = 4.dp) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangeAdminAppBar(selectedUserId: String?) { + val viewModel = hiltViewModel() + + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = AppTheme.colorScheme.surface), + title = {}, + navigationIcon = { + Row( + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { viewModel.popBackStack() }) { + Icon( + painter = painterResource(id = R.drawable.ic_nav_back_arrow_icon), + contentDescription = "", + tint = AppTheme.colorScheme.primary + ) + } + Text( + text = stringResource(id = R.string.common_btn_back), + color = AppTheme.colorScheme.primary, + style = MaterialTheme.typography.titleMedium + ) + } + }, + actions = { + IconButton( + onClick = { selectedUserId?.let { viewModel.changeAdmin(it) } } + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "" + ) + } + } + ) +} diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/edit/ChangeAdminViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/edit/ChangeAdminViewModel.kt new file mode 100644 index 00000000..96555444 --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/settings/space/edit/ChangeAdminViewModel.kt @@ -0,0 +1,102 @@ +package com.canopas.yourspace.ui.flow.settings.space.edit + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.canopas.yourspace.data.models.space.SpaceInfo +import com.canopas.yourspace.data.models.user.UserInfo +import com.canopas.yourspace.data.repository.SpaceRepository +import com.canopas.yourspace.data.service.auth.AuthService +import com.canopas.yourspace.data.utils.AppDispatcher +import com.canopas.yourspace.domain.utils.ConnectivityObserver +import com.canopas.yourspace.ui.navigation.AppNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ChangeAdminViewModel @Inject constructor( + private val appDispatcher: AppDispatcher, + private val connectivityObserver: ConnectivityObserver, + private val appNavigator: AppNavigator, + private val spaceRepository: SpaceRepository, + private val authService: AuthService +) : ViewModel() { + + private val _state = MutableStateFlow(ChangeAdminState()) + val state = _state.asStateFlow() + + init { + checkInternetConnection() + } + + fun fetchSpaceDetail(spaceID: String) = viewModelScope.launch(appDispatcher.IO) { + _state.emit(_state.value.copy(isLoading = true)) + try { + val spaceInfo = spaceRepository.getSpaceInfo(spaceID) + _state.emit( + _state.value.copy( + isLoading = false, + spaceInfo = spaceInfo, + spaceID = spaceID, + currentUserId = authService.currentUser?.id, + isAdmin = spaceInfo?.space?.admin_id == authService.currentUser?.id, + spaceName = spaceInfo?.space?.name, + members = spaceInfo?.members ?: emptyList() + ) + ) + } catch (e: Exception) { + Timber.e(e, "Failed to fetch space detail") + _state.emit(_state.value.copy(error = e, isLoading = false)) + } + } + + fun changeAdmin(newAdminId: String) { + viewModelScope.launch(appDispatcher.IO) { + try { + spaceRepository.changeSpaceAdmin(state.value.spaceID, newAdminId) + _state.emit( + _state.value.copy( + isAdmin = true, + currentUserId = newAdminId + ) + ) + popBackStack() + } catch (e: Exception) { + Timber.e(e, "Failed to change admin") + _state.emit(_state.value.copy(error = e)) + } + } + } + + fun popBackStack() { + appNavigator.navigateBack() + } + + fun checkInternetConnection() { + viewModelScope.launch(appDispatcher.IO) { + connectivityObserver.observe().collectLatest { status -> + _state.emit( + _state.value.copy( + connectivityStatus = status + ) + ) + } + } + } +} + +data class ChangeAdminState( + val isLoading: Boolean = false, + var spaceID: String = "", + val currentUserId: String? = null, + val isAdmin: Boolean = false, + val members: List? = null, + val spaceInfo: SpaceInfo? = null, + val spaceName: String? = null, + val error: Exception? = null, + val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available +) diff --git a/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt b/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt index 5efc4ed6..9de649c2 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt @@ -20,6 +20,24 @@ object AppDestinations { override val path: String = "settings" } + object ChangeAdminScreen { + const val KEY_SPACE_NAME = "space-name" + + private const val PATH = "change-admin" + const val path = "$PATH/{$KEY_SPACE_NAME}" + + fun getSpaceDetail( + spaceInfo: String + ) = object : AppRoute { + + override val arguments = listOf( + navArgument(KEY_SPACE_NAME) { type = NavType.StringType } + ) + + override val path = "$PATH/$spaceInfo" + } + } + val contactSupport = object : AppRoute { override val arguments: List = emptyList() override val path: String = "contact-support" diff --git a/app/src/main/res/drawable/ic_setting_rate_icon.xml b/app/src/main/res/drawable/ic_setting_rate_icon.xml new file mode 100644 index 00000000..0e39bc9f --- /dev/null +++ b/app/src/main/res/drawable/ic_setting_rate_icon.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_setting_share_icon.xml b/app/src/main/res/drawable/ic_setting_share_icon.xml new file mode 100644 index 00000000..74744a1e --- /dev/null +++ b/app/src/main/res/drawable/ic_setting_share_icon.xml @@ -0,0 +1,10 @@ + + + \ 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 132ccd56..7cafd4d6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,7 @@ Reply Save Retry + Back Let\'s stay connected! Use invite code %s to join my Group.\nDownload the app. You\'ve been logged out @@ -173,6 +174,9 @@ Privacy Policy About Us App version: %s + Share app + Rate us + Check out GroupTrack - the best app to stay connected with your loved ones! Download now: %1$s Edit profile Save @@ -293,7 +297,8 @@ Failed to open navigation Invalid coordinates Failed to share location - - Add members to add places - At least one member needs to join your group to be able to add places. + (Admin) + Change Admin + To leave the group, you must assign another member as admin. This action is irreversible unless the new admin changes it. + Change Admin \ No newline at end of file diff --git a/app/src/test/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModelTest.kt b/app/src/test/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModelTest.kt index 79b7a1e4..27c1ce83 100644 --- a/app/src/test/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModelTest.kt +++ b/app/src/test/java/com/canopas/yourspace/ui/flow/settings/space/SpaceProfileViewModelTest.kt @@ -29,10 +29,11 @@ import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +@OptIn(ExperimentalCoroutinesApi::class) class SpaceProfileViewModelTest { private val space = ApiSpace(id = "space1", admin_id = "user1", name = "space_name") - val user1 = ApiUser(id = "user1", first_name = "first_name", last_name = "last_name") + private val user1 = ApiUser(id = "user1", first_name = "first_name", last_name = "last_name") private val user2 = ApiUser(id = "user2", first_name = "first_name", last_name = "last_name") private val userInfo1 = UserInfo(user1, isLocationEnable = true) private val userInfo2 = UserInfo(user2) @@ -40,7 +41,6 @@ class SpaceProfileViewModelTest { val space_info1 = SpaceInfo(space = space, members = members) - @OptIn(ExperimentalCoroutinesApi::class) @get:Rule val mainCoroutineRule = MainCoroutineRule() @@ -53,7 +53,6 @@ class SpaceProfileViewModelTest { private val authService = mock() private val connectivityObserver = mock() - @OptIn(ExperimentalCoroutinesApi::class) private val testDispatcher = AppDispatcher(IO = UnconfinedTestDispatcher()) private lateinit var viewModel: SpaceProfileViewModel @@ -83,6 +82,7 @@ class SpaceProfileViewModelTest { } } setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.isLoading) } @@ -90,6 +90,7 @@ class SpaceProfileViewModelTest { fun `fetchSpaceDetail should update state with spaceInfo`() = runTest { whenever(spaceRepository.getSpaceInfo("space1")).thenReturn(space_info1) setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.spaceInfo == space_info1) } @@ -98,6 +99,7 @@ class SpaceProfileViewModelTest { whenever(spaceRepository.getSpaceInfo("space1")).thenReturn(space_info1) whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.currentUserId == user1.id) } @@ -106,6 +108,7 @@ class SpaceProfileViewModelTest { whenever(spaceRepository.getSpaceInfo("space1")).thenReturn(space_info1) whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.isAdmin) } @@ -113,6 +116,7 @@ class SpaceProfileViewModelTest { fun `fetchSpaceDetail should update state with spaceName`() = runTest { whenever(spaceRepository.getSpaceInfo("space1")).thenReturn(space_info1) setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.spaceName == space_info1.space.name) } @@ -121,6 +125,7 @@ class SpaceProfileViewModelTest { whenever(spaceRepository.getSpaceInfo("space1")).thenReturn(space_info1) whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.locationEnabled) } @@ -129,6 +134,7 @@ class SpaceProfileViewModelTest { val exception = RuntimeException("Error") whenever(spaceRepository.getSpaceInfo("space1")).thenThrow(exception) setup() + viewModel.fetchSpaceDetail() assert(viewModel.state.value.error == exception) assert(!viewModel.state.value.isLoading) } @@ -169,6 +175,7 @@ class SpaceProfileViewModelTest { whenever(spaceRepository.getSpaceInfo("space1")).thenReturn(space_info1) whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() viewModel.onLocationEnabledChanged(false) assert(viewModel.state.value.allowSave) } @@ -200,6 +207,7 @@ class SpaceProfileViewModelTest { } setup() + viewModel.fetchSpaceDetail() viewModel.onNameChanged("new_name") viewModel.saveSpace() assert(viewModel.state.value.saving) @@ -211,6 +219,7 @@ class SpaceProfileViewModelTest { whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() viewModel.onNameChanged("new_name") viewModel.saveSpace() verify(spaceRepository).updateSpace( @@ -240,6 +249,7 @@ class SpaceProfileViewModelTest { whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() viewModel.onLocationEnabledChanged(false) viewModel.saveSpace() verify(spaceRepository).enableLocation(space.id, user1.id, false) @@ -261,6 +271,7 @@ class SpaceProfileViewModelTest { whenever(authService.currentUser).thenReturn(user1) setup() + viewModel.fetchSpaceDetail() viewModel.saveSpace() verify(navigator).navigateBack() } diff --git a/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt b/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt index bfcc43b4..2da41959 100644 --- a/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt +++ b/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import timber.log.Timber import javax.inject.Inject class SpaceRepository @Inject constructor( @@ -200,6 +201,15 @@ class SpaceRepository @Inject constructor( currentSpaceId = getUserSpaces(userId).firstOrNull()?.sortedBy { it.created_at }?.firstOrNull()?.id ?: "" + + val user = userService.getUser(userId) + val updatedSpaceIds = user?.space_ids?.toMutableList()?.apply { + remove(spaceId) + } ?: return + + user.copy(space_ids = updatedSpaceIds).let { + userService.updateUser(it) + } } suspend fun leaveSpace(spaceId: String) { @@ -228,4 +238,13 @@ class SpaceRepository @Inject constructor( userService.updateUser(it) } } + + suspend fun changeSpaceAdmin(spaceId: String, newAdminId: String) { + try { + spaceService.changeAdmin(spaceId, newAdminId) + } catch (e: Exception) { + Timber.e(e, "Failed to change space admin") + throw e + } + } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt index c20f7fc2..6bbf1365 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt @@ -12,6 +12,7 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBER import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -100,4 +101,14 @@ class ApiSpaceService @Inject constructor( suspend fun updateSpace(space: ApiSpace) { spaceRef.document(space.id).set(space).await() } + + suspend fun changeAdmin(spaceId: String, newAdminId: String) { + try { + val spaceRef = spaceRef.document(spaceId) + spaceRef.update("admin_id", newAdminId).await() + } catch (e: Exception) { + Timber.e(e, "Failed to change admin") + throw e + } + } } diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index 86230239..7d91863b 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -212,4 +212,4 @@ You are advised to review this Privacy Policy periodically for any changes. Chan If you have any questions about this Privacy Policy, You can contact us: -- By email: jimmy@canopas.com +- By email: contact@canopas.com