From 78fb9002f7b098cd84789454c75d9d33ab0a1a4a Mon Sep 17 00:00:00 2001 From: Konstantin Ivanov <54908981+konstantiniiv@users.noreply.github.com> Date: Mon, 2 Jun 2025 09:53:21 +0200 Subject: [PATCH] DROID-3446 Vault | New space or chat creation flow (#2481) --- .../di/feature/spaces/CreateSpaceDI.kt | 4 - .../anytype/ui/spaces/CreateSpaceFragment.kt | 42 +- .../anytype/ui/spaces/CreateSpaceScreen.kt | 372 ++++++++++-------- .../anytype/ui/vault/ChooseSpaceTypeScreen.kt | 139 +++++++ .../anytype/ui/vault/VaultFragment.kt | 57 ++- app/src/main/res/navigation/graph.xml | 7 + .../main/res/drawable/ic_space_type_chat.xml | 21 + .../main/res/drawable/ic_space_type_space.xml | 12 + localization/src/main/res/values/strings.xml | 9 +- .../middleware/service/MiddlewareService.kt | 1 + .../spaces/CreateSpaceViewModel.kt | 138 ++++--- .../presentation/spaces/SpaceIconView.kt | 2 +- .../presentation/vault/VaultViewModel.kt | 26 +- 13 files changed, 595 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/com/anytypeio/anytype/ui/vault/ChooseSpaceTypeScreen.kt create mode 100644 core-ui/src/main/res/drawable/ic_space_type_chat.xml create mode 100644 core-ui/src/main/res/drawable/ic_space_type_space.xml diff --git a/app/src/main/java/com/anytypeio/anytype/di/feature/spaces/CreateSpaceDI.kt b/app/src/main/java/com/anytypeio/anytype/di/feature/spaces/CreateSpaceDI.kt index be2a4b7e52..56117cf770 100644 --- a/app/src/main/java/com/anytypeio/anytype/di/feature/spaces/CreateSpaceDI.kt +++ b/app/src/main/java/com/anytypeio/anytype/di/feature/spaces/CreateSpaceDI.kt @@ -6,16 +6,13 @@ import com.anytypeio.anytype.core_utils.di.scope.PerScreen import com.anytypeio.anytype.di.common.ComponentDependencies import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers import com.anytypeio.anytype.domain.block.repo.BlockRepository -import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer import com.anytypeio.anytype.domain.workspace.SpaceManager import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate import com.anytypeio.anytype.presentation.spaces.CreateSpaceViewModel -import com.anytypeio.anytype.presentation.spaces.SpaceGradientProvider import com.anytypeio.anytype.ui.spaces.CreateSpaceFragment import dagger.Binds import dagger.Component import dagger.Module -import dagger.Provides @Component( dependencies = [CreateSpaceDependencies::class], @@ -50,5 +47,4 @@ interface CreateSpaceDependencies : ComponentDependencies { fun dispatchers(): AppCoroutineDispatchers fun spaceManager(): SpaceManager fun analyticSpaceHelper(): AnalyticSpaceHelperDelegate - fun spaceViewContainer(): SpaceViewSubscriptionContainer } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceFragment.kt index 4927bdd22b..ae273c9ed5 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceFragment.kt @@ -4,6 +4,9 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material.MaterialTheme import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -12,6 +15,8 @@ import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import com.anytypeio.anytype.R +import com.anytypeio.anytype.core_utils.ext.argString +import com.anytypeio.anytype.core_utils.ext.parseImagePath import com.anytypeio.anytype.core_utils.ext.toast import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment import com.anytypeio.anytype.di.common.componentManager @@ -29,6 +34,8 @@ class CreateSpaceFragment : BaseBottomSheetComposeFragment() { private val vm by viewModels { factory } + private val spaceType get() = argString(ARG_SPACE_TYPE) + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -39,11 +46,33 @@ class CreateSpaceFragment : BaseBottomSheetComposeFragment() { MaterialTheme( typography = typography ) { + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia(), + onResult = { uri -> + if (uri != null) { + vm.onImageSelected(url = uri.parseImagePath(context)) + } + } + ) + CreateSpaceScreen( spaceIconView = vm.spaceIconView.collectAsState().value, - onCreate = vm::onCreateSpace, - onSpaceIconClicked = vm::onSpaceIconClicked, - isLoading = vm.isInProgress.collectAsState() + onCreate = { name, isSpaceLevelChatSwitchChecked -> + vm.onCreateSpace( + name = name, + withChat = spaceType == TYPE_CHAT + ) + }, + onSpaceIconUploadClicked = { + imagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + onSpaceIconRemoveClicked = { + vm.onSpaceIconRemovedClicked() + }, + isLoading = vm.isInProgress.collectAsState(), + isChatSpace = spaceType == TYPE_CHAT ) LaunchedEffect(Unit) { vm.toasts.collect { toast(it) } } LaunchedEffect(Unit) { @@ -99,4 +128,11 @@ class CreateSpaceFragment : BaseBottomSheetComposeFragment() { override fun releaseDependencies() { componentManager().createSpaceComponent.release() } + + companion object { + const val ARG_SPACE_TYPE = "arg.space_type" + + const val TYPE_SPACE = "space" + const val TYPE_CHAT = "chat" + } } \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceScreen.kt index 2ee66f5939..1e0c00a72b 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceScreen.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/spaces/CreateSpaceScreen.kt @@ -1,76 +1,98 @@ package com.anytypeio.anytype.ui.spaces -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background 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.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.DropdownMenuItem import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp -import com.anytypeio.anytype.BuildConfig import com.anytypeio.anytype.R import com.anytypeio.anytype.core_models.Name -import com.anytypeio.anytype.core_models.PRIVATE_SPACE_TYPE +import com.anytypeio.anytype.core_models.SystemColor +import com.anytypeio.anytype.core_ui.common.DefaultPreviews import com.anytypeio.anytype.core_ui.features.SpaceIconView import com.anytypeio.anytype.core_ui.foundation.Divider import com.anytypeio.anytype.core_ui.foundation.Dragger +import com.anytypeio.anytype.core_ui.views.BodyCalloutMedium import com.anytypeio.anytype.core_ui.views.BodyRegular +import com.anytypeio.anytype.core_ui.views.BodySemiBold import com.anytypeio.anytype.core_ui.views.ButtonPrimaryLoading import com.anytypeio.anytype.core_ui.views.ButtonSize import com.anytypeio.anytype.core_ui.views.Caption1Regular -import com.anytypeio.anytype.core_ui.views.HeadlineHeading -import com.anytypeio.anytype.core_ui.views.Title2 +import com.anytypeio.anytype.core_ui.views.Title1 import com.anytypeio.anytype.presentation.spaces.SpaceIconView -import com.anytypeio.anytype.ui_settings.space.TypeOfSpace @Composable fun CreateSpaceScreen( - spaceIconView: SpaceIconView.Placeholder, + spaceIconView: SpaceIconView, onCreate: (Name, IsSpaceLevelChatSwitchChecked) -> Unit, - onSpaceIconClicked: () -> Unit, - isLoading: State + onSpaceIconUploadClicked: () -> Unit, + onSpaceIconRemoveClicked: () -> Unit, + isLoading: State, + isChatSpace: Boolean = false ) { - var isSpaceLevelChatSwitchChecked = remember { mutableStateOf(false) } - val input = remember { - mutableStateOf("") + var innerValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue("")) } + + val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + Box( - modifier = Modifier.fillMaxSize() + modifier = Modifier + .fillMaxSize() + .background( + color = colorResource(id = R.color.background_primary), + shape = RoundedCornerShape(16.dp) + ) + .imePadding() ) { Dragger( modifier = Modifier @@ -83,44 +105,101 @@ fun CreateSpaceScreen( .verticalScroll(rememberScrollState()) .padding(top = 16.dp) ) { - Header() - Spacer(modifier = Modifier.height(16.dp)) + Header(isChatSpace = isChatSpace) + Spacer(modifier = Modifier.height(8.dp)) SpaceIcon( modifier = Modifier.align(Alignment.CenterHorizontally), - spaceIconView = spaceIconView.copy( - name = input.value.ifEmpty { - stringResource(id = R.string.s) - } - ), - onSpaceIconClicked = onSpaceIconClicked + spaceIconView = when (spaceIconView) { + is SpaceIconView.Placeholder -> spaceIconView.copy( + name = innerValue.text.ifEmpty { + stringResource(id = R.string.u) + } + ) + else -> spaceIconView + }, + onSpaceIconUploadClicked = onSpaceIconUploadClicked, + onSpaceIconRemoveClicked = onSpaceIconRemoveClicked ) - Spacer(modifier = Modifier.height(10.dp)) - SpaceNameInput( - input = input, - onActionDone = { - focusManager.clearFocus() - onCreate(input.value, isSpaceLevelChatSwitchChecked.value) - } + Spacer(modifier = Modifier.height(6.dp)) + Text( + modifier = Modifier + .align(Alignment.CenterHorizontally), + text = stringResource(id = R.string.create_space_change_icon), + style = BodyCalloutMedium, + color = colorResource(id = R.color.glyph_active) ) - Divider() - Section(title = stringResource(id = R.string.type)) - TypeOfSpace(spaceType = PRIVATE_SPACE_TYPE) - Divider() - if (BuildConfig.DEBUG) { - DebugCreateSpaceLevelChatToggle(isSpaceLevelChatSwitchChecked) + Spacer(modifier = Modifier.height(20.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + ) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .focusRequester(focusRequester), + value = innerValue, + onValueChange = { + innerValue = it + }, + placeholder = { + Text( + text = stringResource(id = R.string.untitled), + style = BodySemiBold, + color = colorResource(id = R.color.text_tertiary) + ) + }, + label = { + Text( + text = stringResource(id = R.string.name), + style = Caption1Regular, + color = colorResource(id = R.color.text_secondary) + ) + }, + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + onCreate(innerValue.text, isSpaceLevelChatSwitchChecked.value) + } + ), + textStyle = BodySemiBold.copy( + color = colorResource(id = R.color.text_primary) + ), + shape = RoundedCornerShape(size = 12.dp), + colors = TextFieldDefaults.colors( + cursorColor = colorResource(id = R.color.text_primary), + focusedContainerColor = colorResource(id = R.color.transparent), + unfocusedContainerColor = colorResource(id = R.color.transparent), + focusedIndicatorColor = colorResource(id = R.color.shape_primary), + unfocusedIndicatorColor = colorResource(id = R.color.shape_tertiary), + ) + ) } - Spacer(modifier = Modifier.height(78.dp)) } - CreateSpaceButton( - onCreate = { name -> + ButtonPrimaryLoading( + onClick = { focusManager.clearFocus() - onCreate(name, isSpaceLevelChatSwitchChecked.value) + keyboardController?.hide() + onCreate(innerValue.text, isSpaceLevelChatSwitchChecked.value) }, - input = input, - modifier = Modifier.align(Alignment.BottomCenter), - isLoading = isLoading + text = stringResource(id = R.string.create), + size = ButtonSize.Large, + modifierBox = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 8.dp), + modifierButton = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + loading = isLoading.value, + enabled = innerValue.text.isNotEmpty() ) } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } } @Composable @@ -147,43 +226,14 @@ private fun DebugCreateSpaceLevelChatToggle(isChatToggleChecked: MutableState Unit, - input: State, - isLoading: State -) { - Box( - modifier = modifier - .height(78.dp) - .fillMaxWidth() - ) { - ButtonPrimaryLoading( - onClick = { onCreate(input.value) }, - text = stringResource(id = R.string.create), - size = ButtonSize.Large, - modifierButton = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp) - , - modifierBox = Modifier - .padding(bottom = 10.dp) - .align(Alignment.BottomCenter) - , - loading = isLoading.value - ) - } -} - -@Composable -fun Header() { +fun Header(isChatSpace: Boolean = false) { Box( modifier = Modifier .height(48.dp) @@ -191,8 +241,8 @@ fun Header() { ) { Text( modifier = Modifier.align(Alignment.Center), - text = stringResource(id = R.string.create_space), - style = Title2, + text = stringResource(id = if (isChatSpace) R.string.create_chat else R.string.create_space), + style = Title1, color = colorResource(id = R.color.text_primary) ) } @@ -202,94 +252,65 @@ fun Header() { fun SpaceIcon( modifier: Modifier, spaceIconView: SpaceIconView, - onSpaceIconClicked: () -> Unit + onSpaceIconUploadClicked: () -> Unit, + onSpaceIconRemoveClicked: () -> Unit, ) { + val context = LocalContext.current + + val isIconMenuExpanded = remember { + mutableStateOf(false) + } + Box(modifier = modifier.wrapContentSize()) { SpaceIconView( icon = spaceIconView, - onSpaceIconClick = onSpaceIconClicked - ) - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun SpaceNameInput( - input: MutableState, - onActionDone: () -> Unit -) { - val focusRequester = FocusRequester() - Box( - modifier = Modifier - .height(72.dp) - .fillMaxWidth() - ) { - BasicTextField( - value = input.value, - onValueChange = { input.value = it }, - keyboardActions = KeyboardActions( - onDone = { onActionDone() } - ), - modifier = Modifier - .fillMaxWidth() - .padding(start = 20.dp, bottom = 12.dp) - .align(Alignment.BottomStart) - .focusRequester(focusRequester) - , - maxLines = 1, - singleLine = true, - textStyle = HeadlineHeading.copy( - color = colorResource(id = R.color.text_primary) - ), - cursorBrush = SolidColor( - colorResource(id = R.color.cursor_color) - ), - decorationBox = @Composable { innerTextField -> - TextFieldDefaults.OutlinedTextFieldDecorationBox( - value = input.value, - innerTextField = innerTextField, - singleLine = true, - enabled = true, - isError = false, - placeholder = { - Text( - text = stringResource(R.string.space_name), - style = HeadlineHeading - ) - }, - colors = TextFieldDefaults.outlinedTextFieldColors( - textColor = colorResource(id = R.color.text_primary), - backgroundColor = Color.Transparent, - disabledBorderColor = Color.Transparent, - errorBorderColor = Color.Transparent, - focusedBorderColor = Color.Transparent, - unfocusedBorderColor = Color.Transparent, - placeholderColor = colorResource(id = R.color.text_tertiary) - ), - contentPadding = PaddingValues( - start = 0.dp, - top = 0.dp, - end = 0.dp, - bottom = 0.dp - ), - border = {}, - interactionSource = remember { MutableInteractionSource() }, - visualTransformation = VisualTransformation.None - ) + onSpaceIconClick = { + isIconMenuExpanded.value = !isIconMenuExpanded.value } ) - Text( - color = colorResource(id = R.color.text_secondary), - style = Caption1Regular, - modifier = Modifier.padding( - start = 20.dp, - top = 11.dp - ), - text = stringResource(id = R.string.space_name) - ) - } - LaunchedEffect(Unit) { - focusRequester.requestFocus() + DropdownMenu( + modifier = Modifier, + expanded = isIconMenuExpanded.value, + offset = DpOffset(x = 0.dp, y = 6.dp), + onDismissRequest = { + isIconMenuExpanded.value = false + }, + shape = RoundedCornerShape(10.dp), + containerColor = colorResource(id = R.color.background_secondary) + ) { + if (ActivityResultContracts.PickVisualMedia.isPhotoPickerAvailable(context)) { + DropdownMenuItem( + onClick = { + onSpaceIconUploadClicked() + isIconMenuExpanded.value = false + }, + ) { + Text( + text = stringResource(R.string.profile_settings_apply_upload_image), + style = BodyRegular, + color = colorResource(id = R.color.text_primary) + ) + } + } + if (spaceIconView is SpaceIconView.Image) { + Divider( + paddingStart = 0.dp, + paddingEnd = 0.dp, + ) + DropdownMenuItem( + onClick = { + isIconMenuExpanded.value = false + onSpaceIconRemoveClicked() + }, + ) { + Text( + text = stringResource(R.string.remove_image), + style = BodyRegular, + color = colorResource(id = R.color.text_primary) + ) + } + } + } } } @@ -299,9 +320,11 @@ fun Section( color: Color = colorResource(id = R.color.text_secondary), textPaddingStart: Dp = 20.dp ) { - Box(modifier = Modifier - .height(52.dp) - .fillMaxWidth()) { + Box( + modifier = Modifier + .height(52.dp) + .fillMaxWidth() + ) { Text( modifier = Modifier .padding( @@ -330,4 +353,21 @@ fun UseCase() { } } -typealias IsSpaceLevelChatSwitchChecked = Boolean \ No newline at end of file +typealias IsSpaceLevelChatSwitchChecked = Boolean + +@DefaultPreviews +@Composable +fun CreateSpaceScreenPreview() { + val state = remember { mutableStateOf(false) } + CreateSpaceScreen( + spaceIconView = SpaceIconView.Placeholder( + color = SystemColor.RED, + name = "My Space" + ), + onCreate = { _, _ -> }, + onSpaceIconUploadClicked = {}, + onSpaceIconRemoveClicked = {}, + isChatSpace = true, + isLoading = state + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/vault/ChooseSpaceTypeScreen.kt b/app/src/main/java/com/anytypeio/anytype/ui/vault/ChooseSpaceTypeScreen.kt new file mode 100644 index 0000000000..28e21402a6 --- /dev/null +++ b/app/src/main/java/com/anytypeio/anytype/ui/vault/ChooseSpaceTypeScreen.kt @@ -0,0 +1,139 @@ +package com.anytypeio.anytype.ui.vault + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.anytypeio.anytype.R +import com.anytypeio.anytype.core_ui.common.DefaultPreviews +import com.anytypeio.anytype.core_ui.foundation.Dragger +import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable +import com.anytypeio.anytype.core_ui.views.Caption1Regular +import com.anytypeio.anytype.core_ui.views.Title1 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChooseSpaceTypeScreen( + onCreateChatClicked: () -> Unit, + onCreateSpaceClicked: () -> Unit, + onDismiss: () -> Unit = {} +) { + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + ModalBottomSheet( + modifier = Modifier + .fillMaxWidth(), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = Color.Transparent, + contentColor = Color.Transparent, + dragHandle = null, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .background(shape = RoundedCornerShape(16.dp), color = colorResource(id = R.color.widget_background)) + ) { + Dragger(modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 16.dp) + .noRippleThrottledClickable { + onCreateChatClicked() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.ic_space_type_chat), + contentDescription = "Create Chat", + modifier = Modifier.size(56.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(id = R.string.vault_create_chat), + style = Title1, + color = colorResource(id = R.color.text_primary) + ) + Text( + text = stringResource(id = R.string.vault_create_chat_description), + style = Caption1Regular, + color = colorResource(id = R.color.text_secondary), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .padding(horizontal = 16.dp) + .noRippleThrottledClickable { + onCreateSpaceClicked() + }, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.ic_space_type_space), + contentDescription = "Create Space", + modifier = Modifier.size(56.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Column { + Text( + text = stringResource(id = R.string.vault_create_space), + style = Title1, + color = colorResource(id = R.color.text_primary) + ) + Text( + text = stringResource(id = R.string.vault_create_space_description), + style = Caption1Regular, + color = colorResource(id = R.color.text_secondary), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } +} + +@DefaultPreviews +@Composable +private fun ChooseSpaceTypeScreenPreview() { + ChooseSpaceTypeScreen( + onCreateChatClicked = {}, + onCreateSpaceClicked = {} + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultFragment.kt index e3f50ef113..3b9a78167f 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/vault/VaultFragment.kt @@ -5,14 +5,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.os.bundleOf import androidx.fragment.app.viewModels import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavOptions +import androidx.navigation.NavOptions.* import androidx.navigation.fragment.findNavController import com.anytypeio.anytype.R import com.anytypeio.anytype.core_utils.ext.argOrNull @@ -31,6 +35,10 @@ import com.anytypeio.anytype.ui.home.HomeScreenFragment import com.anytypeio.anytype.ui.multiplayer.RequestJoinSpaceFragment import com.anytypeio.anytype.ui.payments.MembershipFragment import com.anytypeio.anytype.ui.settings.typography +import com.anytypeio.anytype.ui.spaces.CreateSpaceFragment +import com.anytypeio.anytype.ui.spaces.CreateSpaceFragment.Companion.ARG_SPACE_TYPE +import com.anytypeio.anytype.ui.spaces.CreateSpaceFragment.Companion.TYPE_CHAT +import com.anytypeio.anytype.ui.spaces.CreateSpaceFragment.Companion.TYPE_SPACE import javax.inject.Inject import timber.log.Timber @@ -51,14 +59,30 @@ class VaultFragment : BaseComposeFragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { MaterialTheme(typography = typography) { - VaultScreen( - spaces = vm.spaces.collectAsStateWithLifecycle().value, - onSpaceClicked = vm::onSpaceClicked, - onCreateSpaceClicked = vm::onCreateSpaceClicked, - onSettingsClicked = vm::onSettingsClicked, - onOrderChanged = vm::onOrderChanged, - profile = vm.profileView.collectAsStateWithLifecycle().value - ) + Box(modifier = Modifier.fillMaxSize()) { + VaultScreen( + spaces = vm.spaces.collectAsStateWithLifecycle().value, + onSpaceClicked = vm::onSpaceClicked, + onCreateSpaceClicked = vm::onChooseSpaceTypeClicked, + onSettingsClicked = vm::onSettingsClicked, + onOrderChanged = vm::onOrderChanged, + profile = vm.profileView.collectAsStateWithLifecycle().value + ) + + if (vm.showChooseSpaceType.collectAsStateWithLifecycle().value) { + ChooseSpaceTypeScreen( + onCreateChatClicked = { + vm.onCreateChatClicked() + }, + onCreateSpaceClicked = { + vm.onCreateSpaceClicked() + }, + onDismiss = { + vm.onChooseSpaceTypeDismissed() + } + ) + } + } } LaunchedEffect(Unit) { vm.commands.collect { command -> proceed(command) } @@ -100,10 +124,21 @@ class VaultFragment : BaseComposeFragment() { is Command.CreateNewSpace -> { runCatching { findNavController().navigate( - R.id.actionCreateSpaceFromVault + R.id.actionCreateSpaceFromVault, + bundleOf(ARG_SPACE_TYPE to TYPE_SPACE) ) }.onFailure { - Timber.e(it, "Error while opening create-space screen from vault") + Timber.e(it, "Error while opening create space screen from vault") + } + } + Command.CreateChat -> { + runCatching { + findNavController().navigate( + R.id.actionCreateChatFromVault, + bundleOf(ARG_SPACE_TYPE to TYPE_CHAT) + ) + }.onFailure { + Timber.e(it, "Error while opening create chat screen from vault") } } is Command.OpenProfileSettings -> { @@ -135,7 +170,7 @@ class VaultFragment : BaseComposeFragment() { findNavController().navigate( R.id.paymentsScreen, MembershipFragment.args(command.tierId), - NavOptions.Builder().setLaunchSingleTop(true).build() + Builder().setLaunchSingleTop(true).build() ) } is Command.Deeplink.DeepLinkToObjectNotWorking -> { diff --git a/app/src/main/res/navigation/graph.xml b/app/src/main/res/navigation/graph.xml index 24f089aa1e..92fb02276d 100644 --- a/app/src/main/res/navigation/graph.xml +++ b/app/src/main/res/navigation/graph.xml @@ -320,11 +320,18 @@ + + + + + + + + diff --git a/core-ui/src/main/res/drawable/ic_space_type_space.xml b/core-ui/src/main/res/drawable/ic_space_type_space.xml new file mode 100644 index 0000000000..ac3947b7f3 --- /dev/null +++ b/core-ui/src/main/res/drawable/ic_space_type_space.xml @@ -0,0 +1,12 @@ + + + + diff --git a/localization/src/main/res/values/strings.xml b/localization/src/main/res/values/strings.xml index 9203301776..961907f56e 100644 --- a/localization/src/main/res/values/strings.xml +++ b/localization/src/main/res/values/strings.xml @@ -916,7 +916,9 @@ Exiting... please wait Loading... please wait Default - Create a space + Create space + Create chat + Change icon Something went wrong. Please try again. Type @@ -2070,4 +2072,9 @@ Please provide specific details of your needs here. Enable notifications Not now + Chat + For real-time conversations + Space + For organized content and data + \ No newline at end of file diff --git a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt index 87c5f08ebb..acfd9f9179 100644 --- a/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt +++ b/middleware/src/main/java/com/anytypeio/anytype/middleware/service/MiddlewareService.kt @@ -481,6 +481,7 @@ interface MiddlewareService { @Throws(Exception::class) fun workspaceOpen(request: Rpc.Workspace.Open.Request): Rpc.Workspace.Open.Response + @Throws(Exception::class) fun workspaceSetInfo(request: Rpc.Workspace.SetInfo.Request): Rpc.Workspace.SetInfo.Response @Throws(Exception::class) diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/CreateSpaceViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/CreateSpaceViewModel.kt index 68ba87a0b7..a08f4861b4 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/CreateSpaceViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/CreateSpaceViewModel.kt @@ -8,16 +8,17 @@ import com.anytypeio.anytype.analytics.base.EventsDictionary import com.anytypeio.anytype.analytics.base.EventsPropertiesKey import com.anytypeio.anytype.analytics.base.sendEvent import com.anytypeio.anytype.analytics.props.Props +import com.anytypeio.anytype.core_models.Block import com.anytypeio.anytype.core_models.Id import com.anytypeio.anytype.core_models.Relations import com.anytypeio.anytype.core_models.SystemColor +import com.anytypeio.anytype.core_models.Url import com.anytypeio.anytype.core_models.primitives.Space -import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.domain.base.fold -import com.anytypeio.anytype.domain.multiplayer.SpaceViewSubscriptionContainer +import com.anytypeio.anytype.domain.media.UploadFile import com.anytypeio.anytype.domain.spaces.CreateSpace +import com.anytypeio.anytype.domain.spaces.SetSpaceDetails import com.anytypeio.anytype.domain.workspace.SpaceManager -import com.anytypeio.anytype.presentation.BuildConfig import com.anytypeio.anytype.presentation.common.BaseViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableSharedFlow @@ -29,14 +30,15 @@ class CreateSpaceViewModel( private val createSpace: CreateSpace, private val spaceManager: SpaceManager, private val analytics: Analytics, - private val spaceViewContainer: SpaceViewSubscriptionContainer + private val uploadFile: UploadFile, + private val setSpaceDetails: SetSpaceDetails ) : BaseViewModel() { val isInProgress = MutableStateFlow(false) val commands = MutableSharedFlow(replay = 0) - val spaceIconView : MutableStateFlow = MutableStateFlow( + val spaceIconView : MutableStateFlow = MutableStateFlow( SpaceIconView.Placeholder( color = SystemColor.entries.random() ) @@ -50,8 +52,13 @@ class CreateSpaceViewModel( val isDismissed = MutableStateFlow(false) - fun onCreateSpace(name: String, isSpaceLevelChatSwitchChecked: Boolean) { - Timber.d("onCreateSpace, isSpaceLevelChatSwitchChecked: $isSpaceLevelChatSwitchChecked") + fun onImageSelected(url: Url) { + Timber.d("onImageSelected: $url") + spaceIconView.value = SpaceIconView.Image(url = url) + } + + fun onCreateSpace(name: String, withChat: Boolean) { + Timber.d("onCreateSpace, withChat: $withChat") if (isDismissed.value) { return } @@ -59,55 +66,88 @@ class CreateSpaceViewModel( sendToast("Please wait...") return } - val numberOfActiveSpaces = spaceViewContainer.get().filter { it.isActive }.size viewModelScope.launch { - createSpace.stream( - CreateSpace.Params( - details = mapOf( - Relations.NAME to name, - Relations.ICON_OPTION to spaceIconView.value.color.index.toDouble() - ), - shouldApplyEmptyUseCase = true, - withChat = BuildConfig.DEBUG && isSpaceLevelChatSwitchChecked - ) - ).collect { result -> + val params = CreateSpace.Params( + details = mapOf( + Relations.NAME to name, + Relations.ICON_OPTION to when (val icon = spaceIconView.value) { + is SpaceIconView.Placeholder -> icon.color.index.toDouble() + else -> SystemColor.SKY.index.toDouble() + } + ), + shouldApplyEmptyUseCase = true, + withChat = withChat + ) + createSpace.stream(params = params).collect { result -> result.fold( onLoading = { isInProgress.value = true }, - onSuccess = { response -> - val space = response.space.id - analytics.sendEvent( - eventName = EventsDictionary.createSpace, - props = Props( - mapOf(EventsPropertiesKey.route to EventsDictionary.Routes.navigation) - ) - ) - setNewSpaceAsCurrentSpace(space) - Timber.d("Successfully created space: $space").also { - isInProgress.value = false - commands.emit( - Command.SwitchSpace( - space = Space(space), - startingObject = response.startingObject - ) - ) - } - }, - onFailure = { - Timber.e(it, "Error while creating space").also { - sendToast("Error while creating space, please try again.") - isInProgress.value = false - } - } + onSuccess = { onSpaceCreated(it) }, + onFailure = { onError(it) } ) } } } - private suspend fun setNewSpaceAsCurrentSpace(space: Id) { - spaceManager.set(space) + private suspend fun onSpaceCreated(response: com.anytypeio.anytype.core_models.Command.CreateSpace.Result) { + val spaceId = response.space.id + analytics.sendEvent( + eventName = EventsDictionary.createSpace, + props = Props( + mapOf(EventsPropertiesKey.route to EventsDictionary.Routes.navigation) + ) + ) + spaceManager.set(spaceId) + + when (val icon = spaceIconView.value) { + is SpaceIconView.Image -> uploadAndSetIcon( + url = icon.url, + spaceId = spaceId, + startingObject = response.startingObject + ) + else -> finishCreation(spaceId, response.startingObject) + } } - fun onSpaceIconClicked() { + private suspend fun uploadAndSetIcon(url: Url, spaceId: Id, startingObject: Id?) { + uploadFile.async( + UploadFile.Params( + path = url, + space = Space(spaceId), + type = Block.Content.File.Type.IMAGE, + createTypeWidgetIfMissing = false + ) + ).fold( + onSuccess = { file -> + setSpaceDetails.async( + SetSpaceDetails.Params( + space = Space(spaceId), + details = mapOf(Relations.ICON_IMAGE to file.id) + ) + ) + finishCreation(spaceId, startingObject) + }, + onFailure = { onError(it) } + ) + } + + private suspend fun finishCreation(spaceId: Id, startingObject: Id?) { + Timber.d("Space created: %s", spaceId) + isInProgress.value = false + commands.emit( + Command.SwitchSpace( + space = Space(spaceId), + startingObject = startingObject + ) + ) + } + + private fun onError(error: Throwable) { + Timber.e(error, "Error creating space") + sendToast("Error while creating space, please try again.") + isInProgress.value = false + } + + fun onSpaceIconRemovedClicked() { proceedWithResettingRandomSpaceGradient() } @@ -121,7 +161,8 @@ class CreateSpaceViewModel( private val createSpace: CreateSpace, private val spaceManager: SpaceManager, private val analytics: Analytics, - private val spaceViewContainer: SpaceViewSubscriptionContainer + private val uploadFile: UploadFile, + private val setSpaceDetails: SetSpaceDetails ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create( @@ -130,7 +171,8 @@ class CreateSpaceViewModel( createSpace = createSpace, spaceManager = spaceManager, analytics = analytics, - spaceViewContainer = spaceViewContainer + uploadFile = uploadFile, + setSpaceDetails = setSpaceDetails ) as T } diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/SpaceIconView.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/SpaceIconView.kt index 5223ff6e2d..81dc272c76 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/SpaceIconView.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/spaces/SpaceIconView.kt @@ -8,7 +8,7 @@ import com.anytypeio.anytype.domain.misc.UrlBuilder sealed class SpaceIconView { data object Loading : SpaceIconView() data class Placeholder( - val color: SystemColor = SystemColor.YELLOW, + val color: SystemColor = SystemColor.SKY, val name: String = "" ): SpaceIconView() data class Image(val url: Url) : SpaceIconView() diff --git a/presentation/src/main/java/com/anytypeio/anytype/presentation/vault/VaultViewModel.kt b/presentation/src/main/java/com/anytypeio/anytype/presentation/vault/VaultViewModel.kt index 26fddd5455..5f82f282e1 100644 --- a/presentation/src/main/java/com/anytypeio/anytype/presentation/vault/VaultViewModel.kt +++ b/presentation/src/main/java/com/anytypeio/anytype/presentation/vault/VaultViewModel.kt @@ -77,6 +77,7 @@ class VaultViewModel( val spaces = MutableStateFlow>(emptyList()) val commands = MutableSharedFlow(replay = 0) + val showChooseSpaceType = MutableStateFlow(false) val profileView = profileContainer.observe().map { obj -> AccountProfile.Data( @@ -179,8 +180,30 @@ class VaultViewModel( viewModelScope.launch { setVaultSpaceOrder.async(params = order) } } + fun onChooseSpaceTypeClicked() { + viewModelScope.launch { + showChooseSpaceType.value = true + } + } + fun onCreateSpaceClicked() { - viewModelScope.launch { commands.emit(Command.CreateNewSpace) } + viewModelScope.launch { + showChooseSpaceType.value = false + commands.emit(Command.CreateNewSpace) + } + } + + fun onCreateChatClicked() { + viewModelScope.launch { + showChooseSpaceType.value = false + commands.emit(Command.CreateChat) + } + } + + fun onChooseSpaceTypeDismissed() { + viewModelScope.launch { + showChooseSpaceType.value = false + } } fun onResume(deeplink: DeepLinkResolver.Action? = null) { @@ -410,6 +433,7 @@ class VaultViewModel( data class EnterSpaceHomeScreen(val space: Space): Command() data class EnterSpaceLevelChat(val space: Space, val chat: Id): Command() data object CreateNewSpace: Command() + data object CreateChat: Command() data object OpenProfileSettings: Command() sealed class Deeplink : Command() {