1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-08 05:47:05 +09:00

DROID-3426 Space settings | Type and properties library (#2247)

This commit is contained in:
Konstantin Ivanov 2025-04-07 13:22:28 +02:00 committed by GitHub
parent c45901934a
commit cd8eb2b1e9
Signed by: github
GPG key ID: B5690EEEBB952194
21 changed files with 1176 additions and 260 deletions

View file

@ -1,5 +0,0 @@
package com.anytypeio.anytype.feature_properties
class EditSpacePropertiesViewModel {
//todo: implement later
}

View file

@ -206,7 +206,7 @@ class EditTypePropertiesViewModel(
formatIcon = format.simpleIcon(),
format = format,
showLimitTypes = false,
limitObjectTypes = getAllObjectTypesByFormat(format)
limitObjectTypes = format.getAllObjectTypesByFormat(storeOfObjectTypes)
)
}
}
@ -235,7 +235,7 @@ class EditTypePropertiesViewModel(
formatIcon = format.simpleIcon(),
format = format,
showLimitTypes = false,
limitObjectTypes = getAllObjectTypesByFormat(format)
limitObjectTypes = format.getAllObjectTypesByFormat(storeOfObjectTypes)
)
}
}
@ -289,7 +289,7 @@ class EditTypePropertiesViewModel(
),
formatIcon = newFormat.simpleIcon(),
format = newFormat,
limitObjectTypes = getAllObjectTypesByFormat(newFormat),
limitObjectTypes = newFormat.getAllObjectTypesByFormat(storeOfObjectTypes),
selectedLimitTypeIds = emptyList(),
showLimitTypes = false
)
@ -408,39 +408,6 @@ class EditTypePropertiesViewModel(
}
//endregion
//region Limit Object Types
private suspend fun getAllObjectTypesByFormat(format: RelationFormat): List<UiPropertyLimitTypeItem> {
if (format != RelationFormat.OBJECT) return emptyList()
return storeOfObjectTypes.getAll().mapNotNull { type ->
when (type.recommendedLayout) {
ObjectType.Layout.RELATION,
ObjectType.Layout.DASHBOARD,
ObjectType.Layout.SPACE,
ObjectType.Layout.RELATION_OPTION_LIST,
ObjectType.Layout.RELATION_OPTION,
ObjectType.Layout.SPACE_VIEW,
ObjectType.Layout.CHAT,
ObjectType.Layout.DATE,
ObjectType.Layout.OBJECT_TYPE,
ObjectType.Layout.CHAT_DERIVED,
ObjectType.Layout.TAG -> {
null
}
else -> {
UiPropertyLimitTypeItem(
id = type.id,
name = type.name.orEmpty(),
icon = type.objectIcon(),
uniqueKey = type.uniqueKey
)
}
}
}
}
//endregion
//region Commands
sealed class EditTypePropertiesCommand {
data object Exit : EditTypePropertiesCommand()
@ -452,3 +419,34 @@ class EditTypePropertiesViewModel(
}
}
suspend fun RelationFormat.getAllObjectTypesByFormat(
storeOfObjectTypes: StoreOfObjectTypes
): List<UiPropertyLimitTypeItem> {
if (this != RelationFormat.OBJECT) return emptyList()
return storeOfObjectTypes.getAll().mapNotNull { type ->
when (type.recommendedLayout) {
ObjectType.Layout.RELATION,
ObjectType.Layout.DASHBOARD,
ObjectType.Layout.SPACE,
ObjectType.Layout.RELATION_OPTION_LIST,
ObjectType.Layout.RELATION_OPTION,
ObjectType.Layout.SPACE_VIEW,
ObjectType.Layout.CHAT,
ObjectType.Layout.DATE,
ObjectType.Layout.OBJECT_TYPE,
ObjectType.Layout.CHAT_DERIVED,
ObjectType.Layout.TAG -> {
null
}
else -> {
UiPropertyLimitTypeItem(
id = type.id,
name = type.name.orEmpty(),
icon = type.objectIcon(),
uniqueKey = type.uniqueKey
)
}
}
}
}

View file

@ -0,0 +1,365 @@
package com.anytypeio.anytype.feature_properties.space
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.RelationFormat
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.primitives.RelationKey
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_ui.extensions.simpleIcon
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.domain.objects.StoreOfRelations
import com.anytypeio.anytype.domain.objects.mapLimitObjectTypes
import com.anytypeio.anytype.domain.primitives.FieldParser
import com.anytypeio.anytype.domain.relations.CreateRelation
import com.anytypeio.anytype.domain.resources.StringResourceProvider
import com.anytypeio.anytype.feature_properties.add.UiEditTypePropertiesItem.Format
import com.anytypeio.anytype.feature_properties.add.UiEditTypePropertiesState
import com.anytypeio.anytype.feature_properties.edit.UiEditPropertyState
import com.anytypeio.anytype.feature_properties.edit.UiEditPropertyState.Visible.New
import com.anytypeio.anytype.feature_properties.edit.UiEditPropertyState.Visible.View
import com.anytypeio.anytype.feature_properties.edit.UiEditPropertyState.Visible.Edit
import com.anytypeio.anytype.feature_properties.edit.UiPropertyFormatsListState
import com.anytypeio.anytype.feature_properties.edit.UiPropertyFormatsListState.Hidden
import com.anytypeio.anytype.feature_properties.edit.UiPropertyFormatsListState.Visible
import com.anytypeio.anytype.feature_properties.edit.UiPropertyLimitTypeItem
import com.anytypeio.anytype.feature_properties.getAllObjectTypesByFormat
import com.anytypeio.anytype.feature_properties.space.SpacePropertiesViewModel.Command.*
import com.anytypeio.anytype.feature_properties.space.ui.SpacePropertiesEvent
import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate
import com.anytypeio.anytype.presentation.mapper.objectIcon
import javax.inject.Inject
import kotlin.collections.sortedBy
import kotlin.text.orEmpty
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
class SpacePropertiesViewModel(
private val vmParams: VmParams,
private val analytics: Analytics,
private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate,
private val fieldParser: FieldParser,
private val storeOfObjectTypes: StoreOfObjectTypes,
private val userPermissionProvider: UserPermissionProvider,
private val storeOfRelations: StoreOfRelations,
private val stringResourceProvider: StringResourceProvider,
private val setObjectDetails: SetObjectDetails,
private val createRelation: CreateRelation
) : ViewModel(), AnalyticSpaceHelperDelegate by analyticSpaceHelperDelegate {
// Main UI states
val uiItemsState =
MutableStateFlow<UiSpacePropertiesScreenState>(UiSpacePropertiesScreenState.Empty)
val uiEditPropertyScreen = MutableStateFlow<UiEditPropertyState>(UiEditPropertyState.Hidden)
val uiPropertyFormatsListState =
MutableStateFlow<UiPropertyFormatsListState>(Hidden)
val commands = MutableSharedFlow<Command>()
private val permission = MutableStateFlow(userPermissionProvider.get(vmParams.spaceId))
init {
Timber.d("Space Properties ViewModel init")
setupUIState()
}
private fun setupUIState() {
viewModelScope.launch {
storeOfRelations.trackChanges()
.collectLatest { event ->
val allProperties = storeOfRelations.getAll().mapNotNull { property ->
if (property.isHidden == true) {
null
} else {
UiSpacePropertyItem(
id = property.id,
key = RelationKey(property.key),
name = property.name.orEmpty(),
format = property.format,
isEditableField = fieldParser.isPropertyEditable(property),
limitObjectTypes = storeOfObjectTypes.mapLimitObjectTypes(property = property)
)
}
}.sortedBy { it.name }
uiItemsState.value = UiSpacePropertiesScreenState(allProperties)
}
}
}
fun onBackClicked() {
viewModelScope.launch {
commands.emit(Command.Back)
}
}
fun onCreateNewPropertyClicked() {
if (permission.value?.isOwnerOrEditor() == true) {
createNewProperty()
} else {
viewModelScope.launch {
commands.emit(ShowToast("You don't have permission to create new properties"))
}
}
}
private fun createNewProperty() {
viewModelScope.launch {
val format = DEFAULT_NEW_PROPERTY_FORMAT
uiEditPropertyScreen.value = New(
name = "",
formatName = stringResourceProvider.getPropertiesFormatPrettyString(format),
formatIcon = format.simpleIcon(),
format = format,
showLimitTypes = false,
limitObjectTypes = format.getAllObjectTypesByFormat(storeOfObjectTypes)
)
}
}
fun onPropertyClicked(item: UiSpacePropertyItem) {
viewModelScope.launch {
val computedLimitTypes = computeLimitTypes(item = item)
val formatName = stringResourceProvider.getPropertiesFormatPrettyString(item.format)
val formatIcon = item.format.simpleIcon()
uiEditPropertyScreen.value =
if (permission.value?.isOwnerOrEditor() == true && item.isEditableField) {
Edit(
id = item.id,
key = item.key.key,
name = item.name,
formatName = formatName,
formatIcon = formatIcon,
format = item.format,
limitObjectTypes = computedLimitTypes,
isPossibleToUnlinkFromType = false,
showLimitTypes = false
)
} else {
View(
id = item.id,
key = item.key.key,
name = item.name,
formatName = formatName,
formatIcon = formatIcon,
format = item.format,
limitObjectTypes = computedLimitTypes,
isPossibleToUnlinkFromType = false,
showLimitTypes = false
)
}
}
}
private suspend fun computeLimitTypes(item: UiSpacePropertyItem): List<UiPropertyLimitTypeItem> {
return item.limitObjectTypes.mapNotNull { id ->
storeOfObjectTypes.get(id = id)?.let { objType ->
UiPropertyLimitTypeItem(
id = objType.id,
name = fieldParser.getObjectName(objectWrapper = objType),
icon = objType.objectIcon(),
uniqueKey = objType.uniqueKey
)
}
}
}
//region Edit or Create Property
fun onDismissPropertyScreen() {
uiEditPropertyScreen.value = UiEditPropertyState.Hidden
}
fun onEvent(event: SpacePropertiesEvent) {
when (event) {
is SpacePropertiesEvent.OnPropertyNameUpdate -> updatePropertyName(event.name)
SpacePropertiesEvent.OnSaveButtonClicked -> saveProperty()
SpacePropertiesEvent.OnLimitTypesClick -> toggleLimitTypes(show = true)
SpacePropertiesEvent.OnLimitTypesDismiss -> toggleLimitTypes(show = false)
is SpacePropertiesEvent.OnLimitTypesDoneClick -> applyLimitTypes(event.items)
SpacePropertiesEvent.OnPropertyFormatClick -> showPropertyFormatsList()
SpacePropertiesEvent.OnPropertyFormatsListDismiss -> {
uiPropertyFormatsListState.value = Hidden
}
is SpacePropertiesEvent.OnPropertyFormatSelected -> updatePropertyFormat(event.format)
SpacePropertiesEvent.OnCreateNewButtonClicked -> proceedWithCreatingProperty()
}
}
private fun updatePropertyName(newName: String) {
val state = uiEditPropertyScreen.value as? UiEditPropertyState.Visible ?: return
uiEditPropertyScreen.value = when (state) {
is Edit -> state.copy(name = newName)
is New -> state.copy(name = newName)
is View -> state // Not editable
}
}
private fun saveProperty() {
val state = uiEditPropertyScreen.value as? Edit ?: return
viewModelScope.launch {
val params = SetObjectDetails.Params(
ctx = state.id,
details = mapOf(Relations.NAME to state.name)
)
setObjectDetails.async(params).fold(
onSuccess = {
Timber.d("Property updated: $it")
uiEditPropertyScreen.value = UiEditPropertyState.Hidden
},
onFailure = { error ->
Timber.e(error, "Failed to update property")
commands.emit(ShowToast("Failed to update property"))
}
)
}
}
private fun toggleLimitTypes(show: Boolean) {
uiEditPropertyScreen.value = when (val state = uiEditPropertyScreen.value) {
is Edit -> state.copy(showLimitTypes = show)
is New -> state.copy(showLimitTypes = show)
is View -> state.copy(showLimitTypes = show)
else -> state
}
}
private fun applyLimitTypes(items: List<Id>) {
val state = uiEditPropertyScreen.value as? New ?: run {
Timber.w("Possible only for New state")
return
}
uiEditPropertyScreen.value = state.copy(
selectedLimitTypeIds = items,
showLimitTypes = false
)
}
private fun showPropertyFormatsList() {
uiEditPropertyScreen.value as? New ?: run {
Timber.w("Possible only for New state")
return
}
uiPropertyFormatsListState.value = Visible(
items = UiEditTypePropertiesState.PROPERTIES_FORMATS.map { format ->
Format(
format = format,
prettyName = stringResourceProvider.getPropertiesFormatPrettyString(format)
)
}
)
}
private fun updatePropertyFormat(selectedFormat: Format) {
viewModelScope.launch {
uiPropertyFormatsListState.value = Hidden
val state = uiEditPropertyScreen.value as? UiEditPropertyState.Visible ?: return@launch
uiEditPropertyScreen.value = when (state) {
is New -> {
val newFormat = selectedFormat.format
state.copy(
formatName = stringResourceProvider.getPropertiesFormatPrettyString(
newFormat
),
formatIcon = newFormat.simpleIcon(),
format = newFormat,
limitObjectTypes = newFormat.getAllObjectTypesByFormat(storeOfObjectTypes),
selectedLimitTypeIds = emptyList(),
showLimitTypes = false
)
}
else -> state
}
}
}
private fun proceedWithCreatingProperty() {
viewModelScope.launch {
val state = uiEditPropertyScreen.value as? New ?: return@launch
val params = CreateRelation.Params(
space = vmParams.spaceId.id,
format = state.format,
name = state.name,
limitObjectTypes = state.selectedLimitTypeIds,
prefilled = emptyMap()
)
createRelation(params).process(
success = { property ->
Timber.d("Property created: $property")
uiEditPropertyScreen.value = UiEditPropertyState.Hidden
},
failure = { error ->
Timber.e(error, "Failed to create property")
commands.emit(Command.ShowToast("Error while creating property"))
}
)
}
}
//endregion
sealed class Command {
data object Back : Command()
data class ShowToast(val message: String) : Command()
}
companion object {
val DEFAULT_NEW_PROPERTY_FORMAT = RelationFormat.LONG_TEXT
}
data class VmParams(
val spaceId: SpaceId
)
}
class SpacePropertiesVmFactory @Inject constructor(
private val vmParams: SpacePropertiesViewModel.VmParams,
private val storeOfObjectTypes: StoreOfObjectTypes,
private val analytics: Analytics,
private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate,
private val userPermissionProvider: UserPermissionProvider,
private val fieldParser: FieldParser,
private val storeOfRelations: StoreOfRelations,
private val stringResourceProvider: StringResourceProvider,
private val setObjectDetails: SetObjectDetails,
private val createRelation: CreateRelation
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
SpacePropertiesViewModel(
vmParams = vmParams,
storeOfObjectTypes = storeOfObjectTypes,
analytics = analytics,
analyticSpaceHelperDelegate = analyticSpaceHelperDelegate,
userPermissionProvider = userPermissionProvider,
fieldParser = fieldParser,
storeOfRelations = storeOfRelations,
stringResourceProvider = stringResourceProvider,
setObjectDetails = setObjectDetails,
createRelation = createRelation
) as T
}
data class UiSpacePropertiesScreenState(
val items: List<UiSpacePropertyItem>
) {
companion object {
val Empty = UiSpacePropertiesScreenState(emptyList())
}
}
data class UiSpacePropertyItem(
val id: Id,
val key: RelationKey,
val name: String,
val format: RelationFormat,
val isEditableField: Boolean,
val limitObjectTypes: List<Id>
)

View file

@ -0,0 +1,219 @@
package com.anytypeio.anytype.feature_properties.space.ui
import android.os.Build
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
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.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.R
import com.anytypeio.anytype.core_ui.extensions.simpleIcon
import com.anytypeio.anytype.core_ui.extensions.swapList
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
import com.anytypeio.anytype.core_ui.views.PreviewTitle1Regular
import com.anytypeio.anytype.core_ui.views.Title1
import com.anytypeio.anytype.core_utils.insets.EDGE_TO_EDGE_MIN_SDK
import com.anytypeio.anytype.feature_properties.space.UiSpacePropertiesScreenState
import com.anytypeio.anytype.feature_properties.space.UiSpacePropertyItem
@Composable
fun SpacePropertiesListScreen(
uiState: UiSpacePropertiesScreenState,
onPropertyClicked: (UiSpacePropertyItem) -> Unit,
onBackPressed: () -> Unit,
onAddIconClicked: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(color = colorResource(id = R.color.background_primary))
.systemBarsPadding()
) {
Topbar(
onBackPressed = onBackPressed,
onAddIconClicked = onAddIconClicked
)
val contentModifier =
if (Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK)
Modifier
.windowInsetsPadding(WindowInsets.safeDrawing)
.fillMaxWidth()
else
Modifier
.fillMaxWidth()
val lazyListState = rememberLazyListState()
val items = remember {
mutableStateListOf<UiSpacePropertyItem>()
}
items.swapList(uiState.items)
LazyColumn(
modifier = contentModifier,
state = lazyListState,
) {
items(
count = items.size,
key = { index -> items[index].id },
itemContent = {
val item = items[it]
Relation(
modifier = Modifier
.fillMaxWidth()
.height(52.dp)
.padding(start = 20.dp, end = 20.dp)
.clickable {
onPropertyClicked(item)
},
item = item
)
Divider()
}
)
item {
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(32.dp)
)
}
}
}
}
@Composable
private fun Relation(
modifier: Modifier,
item: UiSpacePropertyItem
) {
Row(
modifier = modifier.fillMaxWidth(),
verticalAlignment = CenterVertically
) {
val icon = item.format.simpleIcon()
PropertyIcon(
modifier = Modifier.size(24.dp),
formatIconRes = icon
)
val name = item.name.trim().ifBlank { stringResource(R.string.untitled) }
Text(
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp),
text = name,
style = PreviewTitle1Regular,
color = colorResource(id = R.color.text_primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
private fun RowScope.PropertyIcon(
modifier: Modifier,
formatIconRes: Int?
) {
if (formatIconRes != null) {
Image(
painter = painterResource(id = formatIconRes),
contentDescription = "Property format icon",
contentScale = ContentScale.None,
modifier = modifier
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun Topbar(
onBackPressed: () -> Unit,
onAddIconClicked: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.statusBars)
.height(48.dp),
) {
Box(
modifier = Modifier
.width(56.dp)
.fillMaxHeight()
.noRippleThrottledClickable {
onBackPressed()
},
contentAlignment = Alignment.CenterStart
) {
Image(
modifier = Modifier
.padding(start = 12.dp)
.wrapContentSize(),
painter = painterResource(R.drawable.ic_default_top_back),
contentDescription = stringResource(R.string.content_desc_back_button)
)
}
Text(
modifier = Modifier.align(Alignment.Center),
text = stringResource(R.string.space_properties_screen_title),
style = Title1,
color = colorResource(R.color.text_primary),
textAlign = TextAlign.Center
)
Box(
modifier = Modifier
.width(56.dp)
.height(48.dp)
.align(Alignment.CenterEnd)
.noRippleThrottledClickable {
onAddIconClicked()
},
contentAlignment = Alignment.CenterStart
) {
Image(
modifier = Modifier
.padding(start = 12.dp)
.wrapContentSize(),
painter = painterResource(R.drawable.ic_default_plus),
contentDescription = "Add new type"
)
}
}
}

View file

@ -0,0 +1,19 @@
package com.anytypeio.anytype.feature_properties.space.ui
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.feature_properties.add.UiEditTypePropertiesItem.Format
sealed class SpacePropertiesEvent {
data class OnPropertyNameUpdate(val name: String) : SpacePropertiesEvent()
data object OnSaveButtonClicked : SpacePropertiesEvent()
data object OnCreateNewButtonClicked : SpacePropertiesEvent()
data object OnPropertyFormatClick : SpacePropertiesEvent()
data object OnPropertyFormatsListDismiss : SpacePropertiesEvent()
data class OnPropertyFormatSelected(val format: Format) : SpacePropertiesEvent()
data object OnLimitTypesClick : SpacePropertiesEvent()
data object OnLimitTypesDismiss : SpacePropertiesEvent()
data class OnLimitTypesDoneClick(val items: List<Id>) : SpacePropertiesEvent()
}