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

DROID-2793 Date as an Object | Epic (#1782)

Co-authored-by: Evgenii Kozlov <enklave.mare.balticum@protonmail.com>
Co-authored-by: Evgenii Kozlov <ubuphobos@gmail.com>
This commit is contained in:
Konstantin Ivanov 2024-12-04 16:06:16 +01:00 committed by GitHub
parent 2b40f21910
commit ca8721b725
Signed by: github
GPG key ID: B5690EEEBB952194
284 changed files with 6589 additions and 1211 deletions

60
feature-date/build.gradle Normal file
View file

@ -0,0 +1,60 @@
plugins {
id "com.android.library"
id "kotlin-android"
alias(libs.plugins.compose.compiler)
}
android {
defaultConfig {
buildConfigField "boolean", "USE_NEW_WINDOW_INSET_API", "true"
buildConfigField "boolean", "USE_EDGE_TO_EDGE", "true"
}
buildFeatures {
compose true
}
namespace 'com.anytypeio.anytype.feature_date'
}
dependencies {
implementation project(':domain')
implementation project(':core-ui')
implementation project(':analytics')
implementation project(':core-models')
implementation project(':core-utils')
implementation project(':localization')
implementation project(':presentation')
implementation project(':library-emojifier')
compileOnly libs.javaxInject
implementation libs.lifecycleViewModel
implementation libs.lifecycleRuntime
implementation libs.appcompat
implementation libs.compose
implementation libs.composeFoundation
implementation libs.composeToolingPreview
implementation libs.composeMaterial3
implementation libs.composeMaterial
implementation libs.navigationCompose
debugImplementation libs.composeTooling
implementation libs.timber
testImplementation project(':test:android-utils')
testImplementation project(':test:utils')
testImplementation project(":test:core-models-stub")
testImplementation libs.junit
testImplementation libs.kotlinTest
testImplementation libs.robolectric
testImplementation libs.androidXTestCore
testImplementation libs.mockitoKotlin
testImplementation libs.coroutineTesting
testImplementation libs.timberJUnit
testImplementation libs.turbine
}

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest/>

View file

@ -0,0 +1,81 @@
package com.anytypeio.anytype.feature_date.mapping
import com.anytypeio.anytype.core_models.MarketplaceObjectTypeIds
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.ObjectTypeUniqueKeys
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.RelationListWithValueItem
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.objects.StoreOfRelations
import com.anytypeio.anytype.domain.primitives.FieldParser
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsItem
import com.anytypeio.anytype.feature_date.viewmodel.UiObjectsListItem
import com.anytypeio.anytype.presentation.mapper.objectIcon
import com.anytypeio.anytype.presentation.objects.getProperType
import timber.log.Timber
suspend fun List<RelationListWithValueItem>.toUiFieldsItem(
storeOfRelations: StoreOfRelations
): List<UiFieldsItem.Item> {
return this
.sortedByDescending { it.key.key == Relations.MENTIONS }
.mapNotNull { item ->
val relation = storeOfRelations.getByKey(item.key.key)
if (relation == null) {
Timber.e("Relation ${item.key.key} not found in the relation store")
return@mapNotNull null
}
if (relation.key == Relations.LINKS || relation.key == Relations.BACKLINKS) {
Timber.w("Relation ${item.key.key} is LINKS or BACKLINKS")
return@mapNotNull null
}
if (relation.key != Relations.MENTIONS && relation.isHidden == true) {
Timber.w("Relation ${item.key.key} is hidden")
return@mapNotNull null
}
if (relation.key == Relations.MENTIONS) {
UiFieldsItem.Item.Mention(
id = item.key.key,
key = item.key,
title = relation.name.orEmpty(),
relationFormat = relation.format
)
} else {
UiFieldsItem.Item.Default(
id = item.key.key,
key = item.key,
title = relation.name.orEmpty(),
relationFormat = relation.format
)
}
}
}
fun ObjectWrapper.Basic.toUiObjectsListItem(
space: SpaceId,
urlBuilder: UrlBuilder,
objectTypes: List<ObjectWrapper.Type>,
fieldParser: FieldParser
): UiObjectsListItem {
val obj = this
val typeUrl = obj.getProperType()
val isProfile = typeUrl == MarketplaceObjectTypeIds.PROFILE
val layout = obj.layout ?: ObjectType.Layout.BASIC
return UiObjectsListItem.Item(
id = obj.id,
space = space,
name = fieldParser.getObjectName(obj),
type = typeUrl,
typeName = objectTypes.firstOrNull { type ->
if (isProfile) {
type.uniqueKey == ObjectTypeUniqueKeys.PROFILE
} else {
type.id == typeUrl
}
}?.name,
layout = layout,
icon = obj.objectIcon(builder = urlBuilder)
)
}

View file

@ -0,0 +1,49 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.relations.DatePickerContent
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.viewmodel.UiCalendarState
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.presentation.sets.DateValueView
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CalendarScreen(
uiState: UiCalendarState,
onDateEvent: (DateEvent) -> Unit
) {
val bottomSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
ModalBottomSheet(
dragHandle = null,
scrimColor = colorResource(id = R.color.modal_screen_outside_background),
containerColor = colorResource(id = R.color.background_secondary),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
sheetState = bottomSheetState,
onDismissRequest = { onDateEvent(DateEvent.Calendar.OnCalendarDismiss) },
content = {
when (uiState) {
is UiCalendarState.Calendar -> {
DatePickerContent(
state = DateValueView(timeInMillis = uiState.timeInMillis),
showHeader = false,
onDateSelected = { onDateEvent(DateEvent.Calendar.OnCalendarDateSelected(it)) },
onTodayClicked = { onDateEvent(DateEvent.Calendar.OnTodayClick) },
onTomorrowClicked = { onDateEvent(DateEvent.Calendar.OnTomorrowClick) }
)
}
UiCalendarState.Hidden -> {}
}
},
)
}

View file

@ -0,0 +1,199 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.primitives.RelationKey
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.common.ShimmerEffect
import com.anytypeio.anytype.core_ui.extensions.swapList
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
import com.anytypeio.anytype.core_ui.views.PreviewTitle2Medium
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.feature_date.ui.models.StubHorizontalItems
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsItem
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsState
@Composable
fun FieldsScreen(
uiState: UiFieldsState,
onDateEvent: (DateEvent) -> Unit
) {
val lazyFieldsListState = rememberLazyListState()
val items = remember { mutableStateListOf<UiFieldsItem>() }
items.swapList(uiState.items)
// Effect to scroll to the selected item when needToScrollTo and selectedRelationKey are set
LaunchedEffect(uiState.needToScrollTo, uiState.selectedRelationKey) {
if (uiState.needToScrollTo && uiState.selectedRelationKey != null) {
val relationKey = uiState.selectedRelationKey
val index = items.indexOfFirst { it.id == relationKey.key }
if (index != -1) {
lazyFieldsListState.animateScrollToItem(index)
onDateEvent(DateEvent.FieldsList.OnScrolledToItemDismiss)
}
}
}
LazyRow(
state = lazyFieldsListState,
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 24.dp)
) {
items(
count = items.size,
key = { items[it].id },
contentType = { index ->
when (items[index]) {
is UiFieldsItem.Settings -> "settings"
is UiFieldsItem.Item -> "item"
is UiFieldsItem.Loading -> "loading"
}
}
) {
val item = items[it]
val background = if (uiState.selectedRelationKey?.key == item.id) {
colorResource(R.color.shape_secondary)
} else {
Color.Transparent
}
Box(
modifier = Modifier
.height(40.dp)
.wrapContentWidth()
.background(
color = background,
shape = RoundedCornerShape(size = 10.dp)
)
.border(
width = 1.dp,
color = colorResource(R.color.shape_primary),
shape = RoundedCornerShape(size = 10.dp)
)
.noRippleThrottledClickable {
onDateEvent(DateEvent.FieldsList.OnFieldClick(item))
}
) {
when (item) {
is UiFieldsItem.Settings -> {
Image(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(24.dp)
.align(Alignment.Center),
painter = painterResource(R.drawable.ic_burger_24),
contentDescription = "List of date relations"
)
}
is UiFieldsItem.Item.Mention -> {
Row(
modifier = Modifier
.fillParentMaxHeight()
.wrapContentWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier
.padding(end = 6.dp)
.size(24.dp),
painter = painterResource(R.drawable.ic_mention_24),
contentDescription = "Mentioned in"
)
Text(
modifier = Modifier
.wrapContentSize(),
text = stringResource(R.string.date_layout_mentioned_in),
color = colorResource(R.color.text_primary),
style = PreviewTitle2Medium
)
}
}
is UiFieldsItem.Item.Default -> {
Text(
modifier = Modifier
.padding(horizontal = 12.dp)
.wrapContentSize()
.align(Alignment.Center),
text = item.title,
color = colorResource(R.color.text_primary),
style = PreviewTitle2Medium
)
}
is UiFieldsItem.Loading.Item -> {
ShimmerEffect(
modifier = Modifier
.padding(8.dp)
.align(Alignment.Center)
.width(88.dp)
.height(20.dp)
)
}
is UiFieldsItem.Loading.Settings -> {
ShimmerEffect(
modifier = Modifier
.padding(8.dp)
.align(Alignment.Center)
.size(24.dp)
)
}
}
}
}
}
}
@Composable
@DefaultPreviews
fun FieldsScreenPreview() {
FieldsScreen(
uiState = UiFieldsState(
items = StubHorizontalItems,
selectedRelationKey = RelationKey("1")
),
onDateEvent = {}
)
}
@Composable
@DefaultPreviews
fun LoadingPreview() {
FieldsScreen(
uiState = UiFieldsState.LoadingState,
onDateEvent = {}
)
}

View file

@ -0,0 +1,320 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
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.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.TextFieldDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
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.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.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
import com.anytypeio.anytype.core_ui.views.BodyRegular
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsSheetState
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsItem
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.feature_date.ui.models.StubHorizontalItems
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FieldsSheetScreen(
uiState: UiFieldsSheetState,
onDateEvent: (DateEvent) -> Unit
) {
val bottomSheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
ModalBottomSheet(
dragHandle = {
Column {
Spacer(modifier = Modifier.height(6.dp))
Dragger()
Spacer(modifier = Modifier.height(6.dp))
}
},
scrimColor = colorResource(id = R.color.modal_screen_outside_background),
containerColor = colorResource(id = R.color.background_secondary),
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
sheetState = bottomSheetState,
onDismissRequest = {
onDateEvent(DateEvent.FieldsSheet.OnSheetDismiss)
},
content = {
when (uiState) {
is UiFieldsSheetState.Visible -> {
DateObjectSheetScreen(
uiSheetState = uiState,
onDateEvent = onDateEvent
)
}
UiFieldsSheetState.Hidden -> {}
}
},
)
}
@Composable
private fun ColumnScope.DateObjectSheetScreen(
uiSheetState: UiFieldsSheetState.Visible,
onDateEvent: (DateEvent) -> Unit
) {
val listState = rememberLazyListState()
Spacer(Modifier.height(10.dp))
SearchBar(onDateEvent = onDateEvent)
Spacer(Modifier.height(10.dp))
Divider(paddingStart = 0.dp, paddingEnd = 0.dp)
LazyColumn(
modifier = Modifier.fillMaxWidth(),
state = listState
) {
items(
count = uiSheetState.items.size,
key = { index -> uiSheetState.items[index].id },
itemContent = { index ->
when (val item = uiSheetState.items[index]) {
is UiFieldsItem.Item.Default -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.height(52.dp)
.noRippleThrottledClickable {
onDateEvent(DateEvent.FieldsSheet.OnFieldClick(item))
},
contentAlignment = Alignment.CenterStart,
) {
Text(
modifier = Modifier.fillMaxWidth(),
text = item.title,
maxLines = 1,
color = colorResource(R.color.text_primary),
style = BodyRegular,
textAlign = TextAlign.Start
)
}
Divider()
}
is UiFieldsItem.Item.Mention -> {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp)
.height(52.dp)
.noRippleThrottledClickable {
onDateEvent(DateEvent.FieldsSheet.OnFieldClick(item))
},
contentAlignment = Alignment.CenterStart,
) {
Row(
modifier = Modifier
.fillParentMaxHeight()
.wrapContentWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
modifier = Modifier
.padding(end = 6.dp)
.size(24.dp),
painter = painterResource(R.drawable.ic_mention_24),
contentDescription = "Mentioned in"
)
Text(
modifier = Modifier
.wrapContentSize(),
text = stringResource(R.string.date_layout_mentioned_in),
color = colorResource(R.color.text_primary),
style = BodyRegular
)
}
}
Divider()
}
else -> {
//do nothing
}
}
}
)
}
Spacer(Modifier.height(64.dp))
}
//region SearchBar
@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SearchBar(onDateEvent: (DateEvent) -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val focus = LocalFocusManager.current
val focusRequester = FocusRequester()
val selectionColors = TextSelectionColors(
backgroundColor = colorResource(id = R.color.cursor_color).copy(
alpha = 0.2f
),
handleColor = colorResource(id = R.color.cursor_color),
)
var query by remember { mutableStateOf(TextFieldValue()) }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.background(
color = colorResource(id = R.color.shape_transparent),
shape = RoundedCornerShape(10.dp)
)
.height(40.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.ic_search_18),
contentDescription = "Search icon",
modifier = Modifier
.align(Alignment.CenterVertically)
.padding(
start = 11.dp
)
)
CompositionLocalProvider(value = LocalTextSelectionColors provides selectionColors) {
BasicTextField(
value = query,
modifier = Modifier
.weight(1.0f)
.padding(start = 6.dp)
.align(Alignment.CenterVertically)
.focusRequester(focusRequester),
textStyle = BodyRegular.copy(
color = colorResource(id = R.color.text_primary)
),
onValueChange = { input ->
query = input.also {
onDateEvent(DateEvent.FieldsSheet.OnSearchQueryChanged(input.text))
}
},
singleLine = true,
maxLines = 1,
keyboardActions = KeyboardActions(
onDone = {
focus.clearFocus(true)
}
),
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = query.text,
innerTextField = innerTextField,
enabled = true,
singleLine = true,
visualTransformation = VisualTransformation.None,
interactionSource = interactionSource,
placeholder = {
Text(
text = stringResource(id = R.string.search),
style = BodyRegular.copy(
color = colorResource(id = R.color.text_tertiary)
)
)
},
colors = TextFieldDefaults.textFieldColors(
backgroundColor = Color.Transparent,
cursorColor = colorResource(id = R.color.cursor_color),
),
border = {},
contentPadding = PaddingValues()
)
},
cursorBrush = SolidColor(colorResource(id = R.color.palette_system_blue)),
)
}
Spacer(Modifier.width(9.dp))
AnimatedVisibility(
visible = query.text.isNotEmpty(),
enter = fadeIn(tween(100)),
exit = fadeOut(tween(100))
) {
Image(
painter = painterResource(id = R.drawable.ic_clear_18),
contentDescription = "Clear icon",
modifier = Modifier
.padding(end = 9.dp)
.noRippleClickable {
query = TextFieldValue().also {
onDateEvent(DateEvent.FieldsSheet.OnSearchQueryChanged(""))
}
}
)
}
}
}
@DefaultPreviews
@Composable
private fun SearchBarPreview() {
Column {
DateObjectSheetScreen(
uiSheetState = UiFieldsSheetState.Visible(
items = StubHorizontalItems
),
onDateEvent = {}
)
}
}
//endregion

View file

@ -0,0 +1,143 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
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.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
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.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.common.ShimmerEffect
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
import com.anytypeio.anytype.core_ui.views.HeadlineTitle
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.viewmodel.UiHeaderState
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
@Composable
fun HeaderScreen(
modifier: Modifier,
uiState: UiHeaderState,
onDateEvent: (DateEvent) -> Unit
) {
Row(
modifier = modifier
) {
Image(
modifier = Modifier
.height(48.dp)
.width(52.dp)
.rotate(180f)
.noRippleThrottledClickable {
onDateEvent(DateEvent.Header.OnPreviousClick)
},
contentDescription = "Previous day",
painter = painterResource(id = R.drawable.ic_arrow_disclosure_18),
contentScale = ContentScale.None
)
when (uiState) {
is UiHeaderState.Content -> {
Text(
textAlign = TextAlign.Center,
text = uiState.title,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically),
style = HeadlineTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = colorResource(id = R.color.text_primary)
)
}
UiHeaderState.Loading -> {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically),
contentAlignment = Alignment.Center
) {
ShimmerEffect(
modifier = Modifier
.width(200.dp)
.height(30.dp)
)
}
}
UiHeaderState.Empty -> {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.align(Alignment.CenterVertically),
contentAlignment = Alignment.Center
) {
Spacer(
modifier = Modifier
.width(200.dp)
.height(30.dp)
)
}
}
}
Image(
modifier = Modifier
.height(48.dp)
.width(52.dp)
.noRippleThrottledClickable {
onDateEvent(DateEvent.Header.OnNextClick)
},
contentDescription = "Next day",
painter = painterResource(id = R.drawable.ic_arrow_disclosure_18),
contentScale = ContentScale.None
)
}
}
@Composable
@DefaultPreviews
fun DateLayoutHeaderEmptyPreview() {
val state = UiHeaderState.Empty
HeaderScreen(
modifier = Modifier
.fillMaxWidth()
.height(48.dp), uiState = state
) {}
}
@Composable
@DefaultPreviews
fun DateLayoutHeaderLoadingPreview() {
val state = UiHeaderState.Loading
HeaderScreen(
Modifier
.fillMaxWidth()
.height(48.dp), state
) {}
}
@Composable
@DefaultPreviews
fun DateLayoutHeaderPreview() {
val state = UiHeaderState.Content("Tue, 12 Oct")
HeaderScreen(
Modifier
.fillMaxWidth()
.height(48.dp), state
) {}
}

View file

@ -0,0 +1,177 @@
package com.anytypeio.anytype.feature_date.ui
import android.os.Build
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.foundation.components.BottomNavigationMenu
import com.anytypeio.anytype.core_ui.syncstatus.SpaceSyncStatusScreen
import com.anytypeio.anytype.core_utils.insets.EDGE_TO_EDGE_MIN_SDK
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.feature_date.viewmodel.UiCalendarIconState
import com.anytypeio.anytype.feature_date.viewmodel.UiCalendarState
import com.anytypeio.anytype.feature_date.viewmodel.UiContentState
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsSheetState
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsState
import com.anytypeio.anytype.feature_date.viewmodel.UiHeaderState
import com.anytypeio.anytype.feature_date.viewmodel.UiNavigationWidget
import com.anytypeio.anytype.feature_date.viewmodel.UiObjectsListState
import com.anytypeio.anytype.feature_date.viewmodel.UiSyncStatusBadgeState
import com.anytypeio.anytype.feature_date.viewmodel.UiSyncStatusWidgetState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateMainScreen(
uiCalendarIconState: UiCalendarIconState,
uiSyncStatusBadgeState: UiSyncStatusBadgeState,
uiHeaderState: UiHeaderState,
uiFieldsState: UiFieldsState,
uiObjectsListState: UiObjectsListState,
uiNavigationWidget: UiNavigationWidget,
uiFieldsSheetState: UiFieldsSheetState,
uiSyncStatusState: UiSyncStatusWidgetState,
uiCalendarState: UiCalendarState,
uiContentState: UiContentState,
canPaginate: Boolean,
onDateEvent: (DateEvent) -> Unit
) {
val scope = rememberCoroutineScope()
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = colorResource(id = R.color.background_primary),
contentColor = colorResource(id = R.color.background_primary),
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = colorResource(id = R.color.background_primary))
) {
if (Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK) {
Spacer(
modifier = Modifier.windowInsetsTopHeight(
WindowInsets.statusBars
)
)
}
TopToolbarScreen(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
uiCalendarIconState = uiCalendarIconState,
uiSyncStatusBadgeState = uiSyncStatusBadgeState,
onDateEvent = onDateEvent
)
Spacer(
modifier = Modifier.height(24.dp)
)
HeaderScreen(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
uiState = uiHeaderState,
onDateEvent = onDateEvent
)
FieldsScreen(
uiState = uiFieldsState,
onDateEvent = onDateEvent
)
Spacer(
modifier = Modifier.height(8.dp)
)
}
},
content = { paddingValues ->
val contentModifier =
if (Build.VERSION.SDK_INT >= EDGE_TO_EDGE_MIN_SDK)
Modifier
.windowInsetsPadding(WindowInsets.navigationBars)
.fillMaxSize()
.padding(top = paddingValues.calculateTopPadding())
else
Modifier
.fillMaxSize()
.padding(paddingValues)
Box(
modifier = contentModifier,
contentAlignment = Alignment.TopCenter
) {
if (uiContentState is UiContentState.Empty) {
EmptyScreen()
}
ObjectsScreen(
state = uiObjectsListState,
uiState = uiContentState,
canPaginate = canPaginate,
onDateEvent = onDateEvent,
)
BottomNavigationMenu(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 16.dp),
backClick = {
onDateEvent(DateEvent.NavigationWidget.OnBackClick)
},
backLongClick = {
onDateEvent(DateEvent.NavigationWidget.OnBackLongClick)
},
searchClick = {
onDateEvent(DateEvent.NavigationWidget.OnGlobalSearchClick)
},
addDocClick = {
onDateEvent(DateEvent.NavigationWidget.OnAddDocClick)
},
addDocLongClick = {
onDateEvent(DateEvent.NavigationWidget.OnAddDocLongClick)
},
isOwnerOrEditor = uiNavigationWidget is UiNavigationWidget.Editor
)
}
}
)
if (uiSyncStatusState is UiSyncStatusWidgetState.Visible) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
SpaceSyncStatusScreen(
uiState = uiSyncStatusState.status,
onDismiss = { onDateEvent(DateEvent.SyncStatusWidget.OnSyncStatusDismiss) },
scope = scope,
onUpdateAppClick = {}
)
}
}
if (uiFieldsSheetState is UiFieldsSheetState.Visible) {
FieldsSheetScreen(
uiState = uiFieldsSheetState,
onDateEvent = onDateEvent
)
}
if (uiCalendarState is UiCalendarState.Calendar) {
CalendarScreen(
uiState = uiCalendarState,
onDateEvent = onDateEvent
)
}
}

View file

@ -0,0 +1,258 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.common.ShimmerEffect
import com.anytypeio.anytype.core_ui.extensions.swapList
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
import com.anytypeio.anytype.core_ui.views.ButtonSize
import com.anytypeio.anytype.core_ui.views.PreviewTitle2Regular
import com.anytypeio.anytype.core_ui.views.Relations3
import com.anytypeio.anytype.core_ui.views.animations.DotsLoadingIndicator
import com.anytypeio.anytype.core_ui.views.animations.FadeAnimationSpecs
import com.anytypeio.anytype.core_ui.widgets.ListWidgetObjectIcon
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.feature_date.ui.models.StubVerticalItems
import com.anytypeio.anytype.feature_date.viewmodel.UiContentState
import com.anytypeio.anytype.feature_date.viewmodel.UiObjectsListItem
import com.anytypeio.anytype.feature_date.viewmodel.UiObjectsListState
import kotlinx.coroutines.launch
@Composable
fun ObjectsScreen(
state: UiObjectsListState,
uiState: UiContentState,
canPaginate: Boolean,
onDateEvent: (DateEvent) -> Unit
) {
val items = remember { mutableStateListOf<UiObjectsListItem>() }
items.swapList(state.items)
val scope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
val canPaginateState = remember { mutableStateOf(false) }
LaunchedEffect(key1 = canPaginate) {
canPaginateState.value = canPaginate
}
val shouldStartPaging = remember {
derivedStateOf {
canPaginateState.value && (lazyListState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: -9) >= (lazyListState.layoutInfo.totalItemsCount - 2)
}
}
LaunchedEffect(key1 = shouldStartPaging.value) {
if (shouldStartPaging.value && uiState is UiContentState.Idle) {
onDateEvent(DateEvent.ObjectsList.OnLoadMore)
}
}
LazyColumn(
modifier = Modifier
.padding(top = 8.dp)
.fillMaxSize(),
state = lazyListState
) {
items(
count = items.size,
key = { index -> items[index].id },
contentType = { index ->
when (items[index]) {
is UiObjectsListItem.Loading -> "loading"
is UiObjectsListItem.Item -> "item"
}
}
) { index ->
val item = items[index]
when (item) {
is UiObjectsListItem.Item -> {
ListItem(
modifier = Modifier
.noRippleThrottledClickable {
onDateEvent(DateEvent.ObjectsList.OnObjectClicked(item))
},
item = item
)
}
is UiObjectsListItem.Loading -> {
ListItemLoading(modifier = Modifier)
}
}
}
if (uiState is UiContentState.Paging) {
item {
Box(
modifier = Modifier
.fillParentMaxWidth()
.height(52.dp),
contentAlignment = Alignment.Center
) {
LoadingState()
}
}
}
item {
Spacer(modifier = Modifier.height(200.dp))
}
}
LaunchedEffect(key1 = uiState) {
if (uiState is UiContentState.Idle) {
if (uiState.scrollToTop) {
scope.launch {
lazyListState.scrollToItem(0)
}
}
}
}
}
@Composable
private fun ListItem(
modifier: Modifier,
item: UiObjectsListItem.Item
) {
val name = item.name.trim().ifBlank { stringResource(R.string.untitled) }
val createdBy = item.createdBy
val typeName = item.typeName
ListItem(
colors = ListItemDefaults.colors(
containerColor = colorResource(id = R.color.background_primary),
),
modifier = modifier
.height(72.dp)
.fillMaxWidth(),
headlineContent = {
Text(
text = name,
style = PreviewTitle2Regular,
color = colorResource(id = R.color.text_primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
supportingContent = {
Row {
if (typeName != null) {
Text(
text = typeName,
style = Relations3,
color = colorResource(id = R.color.text_secondary),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (!createdBy.isNullOrBlank()) {
Text(
text = "${stringResource(R.string.date_layout_item_created_by)}$createdBy",
style = Relations3,
color = colorResource(id = R.color.text_secondary),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
leadingContent = {
ListWidgetObjectIcon(icon = item.icon, modifier = Modifier, iconSize = 48.dp)
}
)
}
@Composable
private fun ListItemLoading(
modifier: Modifier
) {
ListItem(
colors = ListItemDefaults.colors(
containerColor = colorResource(id = R.color.background_primary),
),
modifier = modifier
.height(72.dp)
.fillMaxWidth(),
headlineContent = {
ShimmerEffect(
modifier = Modifier
.width(164.dp)
.height(18.dp)
)
},
supportingContent = {
ShimmerEffect(
modifier = Modifier
.width(64.dp)
.height(13.dp)
)
},
leadingContent = {
ShimmerEffect(
modifier = Modifier
.size(48.dp)
)
}
)
}
@Composable
private fun BoxScope.LoadingState() {
val loadingAlpha by animateFloatAsState(targetValue = 1f, label = "")
DotsLoadingIndicator(
animating = true,
modifier = Modifier
.graphicsLayer { alpha = loadingAlpha }
.align(Alignment.Center),
animationSpecs = FadeAnimationSpecs(itemCount = 3),
color = colorResource(id = R.color.glyph_active),
size = ButtonSize.Small
)
}
@Composable
@DefaultPreviews
fun ObjectsListScreenPreview() {
val contentListState = UiObjectsListState(
items = StubVerticalItems
)
ObjectsScreen(
state = contentListState,
uiState = UiContentState.Idle(scrollToTop = false),
canPaginate = true,
onDateEvent = {}
)
}

View file

@ -0,0 +1,40 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_ui.views.UXBody
import com.anytypeio.anytype.feature_date.R
@Composable
fun EmptyScreen() {
val title = stringResource(R.string.date_layout_empty_items)
Box(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime)
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
text = title,
color = colorResource(id = R.color.text_primary),
style = UXBody,
textAlign = TextAlign.Center
)
}
}

View file

@ -0,0 +1,106 @@
package com.anytypeio.anytype.feature_date.ui
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.anytypeio.anytype.core_models.multiplayer.P2PStatusUpdate
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncAndP2PStatusState
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncError
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncNetwork
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncStatus
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncUpdate
import com.anytypeio.anytype.core_models.primitives.TimestampInSeconds
import com.anytypeio.anytype.core_ui.common.DefaultPreviews
import com.anytypeio.anytype.core_ui.foundation.noRippleThrottledClickable
import com.anytypeio.anytype.core_ui.syncstatus.StatusBadge
import com.anytypeio.anytype.feature_date.R
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.feature_date.viewmodel.UiCalendarIconState
import com.anytypeio.anytype.feature_date.viewmodel.UiSyncStatusBadgeState
@Composable
fun TopToolbarScreen(
modifier: Modifier,
uiCalendarIconState: UiCalendarIconState,
uiSyncStatusBadgeState: UiSyncStatusBadgeState,
onDateEvent: (DateEvent) -> Unit
) {
Box(
modifier = modifier
.fillMaxWidth()
.height(48.dp)
) {
if (uiSyncStatusBadgeState is UiSyncStatusBadgeState.Visible) {
val s = uiSyncStatusBadgeState.status
Box(
modifier = Modifier
.size(48.dp)
.noRippleThrottledClickable {
onDateEvent(
DateEvent.TopToolbar.OnSyncStatusClick(
status = uiSyncStatusBadgeState.status
)
)
},
) {
StatusBadge(
status = uiSyncStatusBadgeState.status,
modifier = Modifier
.size(20.dp)
.align(Alignment.Center)
)
}
}
if (uiCalendarIconState is UiCalendarIconState.Visible) {
Image(
modifier = Modifier
.size(48.dp)
.align(Alignment.CenterEnd)
.noRippleThrottledClickable {
onDateEvent(
DateEvent.TopToolbar.OnCalendarClick(
timestampInSeconds = uiCalendarIconState.timestampInSeconds
)
)
},
contentDescription = null,
painter = painterResource(id = R.drawable.ic_calendar_24),
contentScale = ContentScale.None
)
}
}
}
@Composable
@DefaultPreviews
fun TopToolbarPreview() {
val spaceSyncUpdate = SpaceSyncUpdate.Update(
id = "1",
status = SpaceSyncStatus.SYNCING,
network = SpaceSyncNetwork.ANYTYPE,
error = SpaceSyncError.NULL,
syncingObjectsCounter = 2
)
TopToolbarScreen(
modifier = Modifier.fillMaxWidth(),
uiCalendarIconState = UiCalendarIconState.Visible(
timestampInSeconds = TimestampInSeconds(3232L)
),
uiSyncStatusBadgeState = UiSyncStatusBadgeState.Visible(
status = SpaceSyncAndP2PStatusState.Success(
spaceSyncUpdate = spaceSyncUpdate,
p2PStatusUpdate = P2PStatusUpdate.Initial
)
),
onDateEvent = {}
)
}

View file

@ -0,0 +1,54 @@
package com.anytypeio.anytype.feature_date.ui.models
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncAndP2PStatusState
import com.anytypeio.anytype.core_models.primitives.TimestampInSeconds
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsItem
import com.anytypeio.anytype.feature_date.viewmodel.UiObjectsListItem
sealed class DateEvent {
sealed class TopToolbar : DateEvent() {
data class OnSyncStatusClick(val status: SpaceSyncAndP2PStatusState) : TopToolbar()
data class OnCalendarClick(val timestampInSeconds: TimestampInSeconds) : TopToolbar()
}
sealed class Header : DateEvent() {
data object OnNextClick : Header()
data object OnPreviousClick : Header()
}
sealed class FieldsSheet : DateEvent() {
data object OnSheetDismiss : FieldsSheet()
data class OnFieldClick(val item: UiFieldsItem) : FieldsSheet()
data class OnSearchQueryChanged(val query: String) : FieldsSheet()
}
sealed class Calendar : DateEvent() {
data object OnCalendarDismiss : Calendar()
data class OnCalendarDateSelected(val timeInMillis: Long?) : Calendar()
data object OnTodayClick : Calendar()
data object OnTomorrowClick : Calendar()
}
sealed class NavigationWidget : DateEvent() {
data object OnGlobalSearchClick : NavigationWidget()
data object OnAddDocClick : NavigationWidget()
data object OnAddDocLongClick : NavigationWidget()
data object OnBackClick : NavigationWidget()
data object OnBackLongClick : NavigationWidget()
}
sealed class ObjectsList : DateEvent() {
data class OnObjectClicked(val item: UiObjectsListItem) : ObjectsList()
data object OnLoadMore : ObjectsList()
}
sealed class FieldsList : DateEvent() {
data class OnFieldClick(val item: UiFieldsItem) : FieldsList()
data object OnScrolledToItemDismiss : FieldsList()
}
sealed class SyncStatusWidget : DateEvent() {
data object OnSyncStatusDismiss : SyncStatusWidget()
}
}

View file

@ -0,0 +1,86 @@
package com.anytypeio.anytype.feature_date.ui.models
import com.anytypeio.anytype.core_models.ObjectType
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.feature_date.viewmodel.UiFieldsItem
import com.anytypeio.anytype.feature_date.viewmodel.UiObjectsListItem
import com.anytypeio.anytype.presentation.objects.ObjectIcon
val StubVerticalItems = listOf(
UiObjectsListItem.Item(
id = "1",
name = "Task Object",
space = SpaceId("space1"),
type = "type1",
typeName = "Task",
createdBy = "by Joseph Wolf",
layout = ObjectType.Layout.TODO,
icon = ObjectIcon.Task(isChecked = true)
),
UiObjectsListItem.Item(
id = "2",
name = "Page Object",
space = SpaceId("space2"),
type = "type2",
typeName = "Page",
createdBy = "by Mike Long",
layout = ObjectType.Layout.BASIC,
icon = ObjectIcon.Empty.Page
),
UiObjectsListItem.Item(
id = "3",
name = "File Object",
space = SpaceId("space3"),
type = "type3",
typeName = "File",
createdBy = "by John Doe",
layout = ObjectType.Layout.FILE,
icon = ObjectIcon.File(
mime = "image/png",
fileName = "test_image.png"
)
)
)
val StubHorizontalItems = listOf(
UiFieldsItem.Settings(),
UiFieldsItem.Item.Mention(
id = "Item 54",
title = "Mentionssssss",
key = RelationKey(key = Relations.MENTIONS),
relationFormat = RelationFormat.DATE
),
UiFieldsItem.Item.Default(
"Item 1",
title = "Title1",
key = RelationKey("key1"),
relationFormat = RelationFormat.DATE
),
UiFieldsItem.Item.Default(
"Item 2",
title = "Title2",
key = RelationKey("key2"),
relationFormat = RelationFormat.DATE
),
UiFieldsItem.Item.Default(
"Item 3",
title = "Title3",
key = RelationKey("key3"),
relationFormat = RelationFormat.DATE
),
UiFieldsItem.Item.Default(
"Item 4",
title = "Title4",
key = RelationKey("key4"),
relationFormat = RelationFormat.DATE
),
UiFieldsItem.Item.Default(
"Item 5",
title = "Title5",
key = RelationKey("key5"),
relationFormat = RelationFormat.DATE
),
)

View file

@ -0,0 +1,179 @@
package com.anytypeio.anytype.feature_date.viewmodel
import com.anytypeio.anytype.core_models.DVSortType
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.RelationFormat
import com.anytypeio.anytype.core_models.TimeInMillis
import com.anytypeio.anytype.core_models.multiplayer.SpaceSyncAndP2PStatusState
import com.anytypeio.anytype.core_models.primitives.RelationKey
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_models.primitives.TimestampInSeconds
import com.anytypeio.anytype.feature_date.viewmodel.UiFieldsItem.Loading
import com.anytypeio.anytype.presentation.objects.ObjectIcon
import com.anytypeio.anytype.presentation.sync.SyncStatusWidgetState
data class DateObjectVmParams(
val objectId: Id,
val spaceId: SpaceId
)
data class ActiveField(
val key: RelationKey,
val format: RelationFormat,
val sort: DVSortType = DVSortType.DESC
)
sealed class UiHeaderState {
data object Empty : UiHeaderState()
data object Loading : UiHeaderState()
data class Content(
val title: String
) : UiHeaderState()
}
sealed class UiCalendarIconState {
data object Hidden : UiCalendarIconState()
data class Visible(val timestampInSeconds: TimestampInSeconds) : UiCalendarIconState()
}
sealed class UiSyncStatusBadgeState {
data object Hidden : UiSyncStatusBadgeState()
data class Visible(val status: SpaceSyncAndP2PStatusState) : UiSyncStatusBadgeState()
}
sealed class UiSyncStatusWidgetState {
data object Hidden : UiSyncStatusWidgetState()
data class Visible(val status: SyncStatusWidgetState) : UiSyncStatusWidgetState()
}
data class UiFieldsState(
val items: List<UiFieldsItem>,
val selectedRelationKey: RelationKey? = null,
val needToScrollTo: Boolean = false
) {
companion object {
val Empty = UiFieldsState(items = emptyList())
val LoadingState =
UiFieldsState(
items = listOf(
UiFieldsItem.Loading.Settings("Loading-Settings"),
UiFieldsItem.Loading.Item("Loading-Item-1"),
UiFieldsItem.Loading.Item("Loading-Item-2"),
UiFieldsItem.Loading.Item("Loading-Item-3"),
UiFieldsItem.Loading.Item("Loading-Item-4")
),
)
}
}
sealed class UiFieldsItem {
abstract val id: String
sealed class Loading(override val id: String) : UiFieldsItem() {
data class Item(override val id: String) : Loading(id)
data class Settings(override val id: String) : Loading(id)
}
data class Settings(
override val id: String = "UiHorizontalListItem-Settings-Id"
) : UiFieldsItem()
sealed class Item : UiFieldsItem() {
abstract val key: RelationKey
abstract val relationFormat: RelationFormat
abstract val title: String
data class Default(
override val id: String,
override val key: RelationKey,
override val relationFormat: RelationFormat,
override val title: String
) : Item()
data class Mention(
override val id: String,
override val key: RelationKey,
override val relationFormat: RelationFormat,
override val title: String
) : Item()
}
}
data class UiObjectsListState(
val items: List<UiObjectsListItem>
) {
companion object {
val Empty = UiObjectsListState(items = emptyList())
val LoadingState = UiObjectsListState(
items = listOf(
UiObjectsListItem.Loading("Loading-Item-1"),
UiObjectsListItem.Loading("Loading-Item-2"),
UiObjectsListItem.Loading("Loading-Item-3"),
UiObjectsListItem.Loading("Loading-Item-4"),
)
)
}
}
sealed class UiObjectsListItem {
abstract val id: String
data class Loading(override val id: String) : UiObjectsListItem()
data class Item(
override val id: String,
val name: String,
val space: SpaceId,
val type: String? = null,
val typeName: String? = null,
val createdBy: String? = null,
val layout: ObjectType.Layout? = null,
val icon: ObjectIcon = ObjectIcon.None
) : UiObjectsListItem()
}
sealed class UiNavigationWidget {
data object Hidden : UiNavigationWidget()
data object Editor : UiNavigationWidget()
data object Viewer : UiNavigationWidget()
}
sealed class UiContentState {
data class Idle(val scrollToTop: Boolean = false) : UiContentState()
data object InitLoading : UiContentState()
data object Paging : UiContentState()
data object Empty : UiContentState()
}
sealed class UiFieldsSheetState {
data object Hidden : UiFieldsSheetState()
data class Visible(
val items: List<UiFieldsItem>
) : UiFieldsSheetState()
}
sealed class UiCalendarState {
data object Hidden : UiCalendarState()
data class Calendar(
val timeInMillis: TimeInMillis?
) : UiCalendarState()
}
sealed class UiErrorState {
data object Hidden : UiErrorState()
data class Show(val reason: Reason) : UiErrorState()
sealed class Reason {
data class YearOutOfRange(val min: Int, val max: Int) : Reason()
data class ErrorGettingFields(val msg: String) : Reason()
data class ErrorGettingObjects(val msg: String) : Reason()
data class Other(val msg: String) : Reason()
}
}

View file

@ -0,0 +1,19 @@
package com.anytypeio.anytype.feature_date.viewmodel
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.primitives.SpaceId
sealed class DateObjectCommand {
data class OpenChat(val target: Id, val space: SpaceId) : DateObjectCommand()
data class NavigateToEditor(val id: Id, val space: SpaceId) : DateObjectCommand()
data class NavigateToSetOrCollection(val id: Id, val space: SpaceId) : DateObjectCommand()
data class NavigateToDateObject(val objectId: Id, val space: SpaceId) : DateObjectCommand()
data object TypeSelectionScreen : DateObjectCommand()
data object ExitToSpaceWidgets : DateObjectCommand()
sealed class SendToast : DateObjectCommand() {
data class UnexpectedLayout(val layout: String) : SendToast()
}
data object OpenGlobalSearch : DateObjectCommand()
data object ExitToVault : DateObjectCommand()
data object Back : DateObjectCommand()
}

View file

@ -0,0 +1,58 @@
package com.anytypeio.anytype.feature_date.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.domain.event.interactor.SpaceSyncAndP2PStatusProvider
import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
import com.anytypeio.anytype.domain.misc.DateProvider
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.`object`.GetObject
import com.anytypeio.anytype.domain.objects.GetDateObjectByTimestamp
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.domain.objects.StoreOfRelations
import com.anytypeio.anytype.domain.page.CreateObject
import com.anytypeio.anytype.domain.primitives.FieldParser
import com.anytypeio.anytype.domain.relations.GetObjectRelationListById
import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate
import javax.inject.Inject
class DateObjectVMFactory @Inject constructor(
private val vmParams: DateObjectVmParams,
private val getObject: GetObject,
private val analytics: Analytics,
private val urlBuilder: UrlBuilder,
private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate,
private val userPermissionProvider: UserPermissionProvider,
private val getObjectRelationListById: GetObjectRelationListById,
private val storeOfRelations: StoreOfRelations,
private val storeOfObjectTypes: StoreOfObjectTypes,
private val storelessSubscriptionContainer: StorelessSubscriptionContainer,
private val getDateObjectByTimestamp: GetDateObjectByTimestamp,
private val dateProvider: DateProvider,
private val spaceSyncAndP2PStatusProvider: SpaceSyncAndP2PStatusProvider,
private val createObject: CreateObject,
private val fieldParser: FieldParser
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T =
DateObjectViewModel(
vmParams = vmParams,
getObject = getObject,
analytics = analytics,
urlBuilder = urlBuilder,
analyticSpaceHelperDelegate = analyticSpaceHelperDelegate,
userPermissionProvider = userPermissionProvider,
getObjectRelationListById = getObjectRelationListById,
storeOfRelations = storeOfRelations,
storeOfObjectTypes = storeOfObjectTypes,
storelessSubscriptionContainer = storelessSubscriptionContainer,
getDateObjectByTimestamp = getDateObjectByTimestamp,
dateProvider = dateProvider,
spaceSyncAndP2PStatusProvider = spaceSyncAndP2PStatusProvider,
createObject = createObject,
fieldParser = fieldParser
) as T
}

View file

@ -0,0 +1,863 @@
package com.anytypeio.anytype.feature_date.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.analytics.base.EventsDictionary
import com.anytypeio.anytype.core_models.DATE_PICKER_YEAR_RANGE
import com.anytypeio.anytype.core_models.DVSortType
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.Struct
import com.anytypeio.anytype.core_models.TimeInSeconds
import com.anytypeio.anytype.core_models.getSingleValue
import com.anytypeio.anytype.core_models.primitives.SpaceId
import com.anytypeio.anytype.core_models.primitives.TimestampInSeconds
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.event.interactor.SpaceSyncAndP2PStatusProvider
import com.anytypeio.anytype.domain.library.StoreSearchParams
import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
import com.anytypeio.anytype.domain.misc.DateProvider
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.multiplayer.UserPermissionProvider
import com.anytypeio.anytype.domain.`object`.GetObject
import com.anytypeio.anytype.domain.objects.GetDateObjectByTimestamp
import com.anytypeio.anytype.domain.objects.StoreOfObjectTypes
import com.anytypeio.anytype.domain.objects.StoreOfRelations
import com.anytypeio.anytype.domain.page.CreateObject
import com.anytypeio.anytype.domain.primitives.FieldParser
import com.anytypeio.anytype.domain.relations.GetObjectRelationListById
import com.anytypeio.anytype.feature_date.viewmodel.UiErrorState.Reason
import com.anytypeio.anytype.feature_date.mapping.toUiFieldsItem
import com.anytypeio.anytype.feature_date.mapping.toUiObjectsListItem
import com.anytypeio.anytype.feature_date.ui.models.DateEvent
import com.anytypeio.anytype.feature_date.viewmodel.UiContentState.*
import com.anytypeio.anytype.presentation.analytics.AnalyticSpaceHelperDelegate
import com.anytypeio.anytype.presentation.extension.sendAnalyticsAllContentScreen
import com.anytypeio.anytype.presentation.extension.sendAnalyticsObjectCreateEvent
import com.anytypeio.anytype.presentation.home.OpenObjectNavigation
import com.anytypeio.anytype.presentation.home.navigation
import com.anytypeio.anytype.presentation.objects.getCreateObjectParams
import com.anytypeio.anytype.presentation.search.GlobalSearchViewModel.Companion.DEFAULT_DEBOUNCE_DURATION
import com.anytypeio.anytype.presentation.search.ObjectSearchConstants.defaultKeys
import com.anytypeio.anytype.presentation.sync.toSyncStatusWidgetState
import kotlin.collections.map
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* ViewState: @see [UiContentState]
* Factory: @see [DateObjectVMFactory]
* Screen: @see [com.anytypeio.anytype.feature_date.ui.DateMainScreen]
* Models: @see [UiObjectsListState]
*/
class DateObjectViewModel(
private val vmParams: DateObjectVmParams,
private val getObject: GetObject,
private val analytics: Analytics,
private val urlBuilder: UrlBuilder,
private val analyticSpaceHelperDelegate: AnalyticSpaceHelperDelegate,
private val userPermissionProvider: UserPermissionProvider,
private val getObjectRelationListById: GetObjectRelationListById,
private val storeOfRelations: StoreOfRelations,
private val storeOfObjectTypes: StoreOfObjectTypes,
private val storelessSubscriptionContainer: StorelessSubscriptionContainer,
private val getDateObjectByTimestamp: GetDateObjectByTimestamp,
private val dateProvider: DateProvider,
private val spaceSyncAndP2PStatusProvider: SpaceSyncAndP2PStatusProvider,
private val createObject: CreateObject,
private val fieldParser: FieldParser
) : ViewModel(), AnalyticSpaceHelperDelegate by analyticSpaceHelperDelegate {
val uiCalendarIconState = MutableStateFlow<UiCalendarIconState>(UiCalendarIconState.Hidden)
val uiSyncStatusBadgeState =
MutableStateFlow<UiSyncStatusBadgeState>(UiSyncStatusBadgeState.Hidden)
val uiHeaderState = MutableStateFlow<UiHeaderState>(UiHeaderState.Empty)
val uiNavigationWidget = MutableStateFlow<UiNavigationWidget>(UiNavigationWidget.Hidden)
val uiFieldsState = MutableStateFlow<UiFieldsState>(UiFieldsState.Empty)
val uiFieldsSheetState = MutableStateFlow<UiFieldsSheetState>(UiFieldsSheetState.Hidden)
val uiObjectsListState = MutableStateFlow<UiObjectsListState>(UiObjectsListState.Empty)
val uiContentState = MutableStateFlow<UiContentState>(UiContentState.Idle())
val uiCalendarState = MutableStateFlow<UiCalendarState>(UiCalendarState.Hidden)
val uiSyncStatusWidgetState =
MutableStateFlow<UiSyncStatusWidgetState>(UiSyncStatusWidgetState.Hidden)
val effects = MutableSharedFlow<DateObjectCommand>()
val errorState = MutableStateFlow<UiErrorState>(UiErrorState.Hidden)
private val _dateId = MutableStateFlow<Id?>(null)
private val _dateTimestamp = MutableStateFlow<TimeInSeconds?>(null)
private val _activeField = MutableStateFlow<ActiveField?>(null)
/**
* Paging and subscription limit. If true, we can paginate after reaching bottom items.
* Could be true only after the first subscription results (if results size == limit)
*/
val canPaginate = MutableStateFlow(false)
private var _itemsLimit = DEFAULT_SEARCH_LIMIT
private val restartSubscription = MutableStateFlow(0L)
/**
* Search query
*/
private val userInput = MutableStateFlow("")
@OptIn(FlowPreview::class)
private val searchQuery = userInput
.take(1)
.onCompletion {
emitAll(userInput.drop(1).debounce(DEFAULT_DEBOUNCE_DURATION).distinctUntilChanged())
}
private var shouldScrollToTopItems = false
private val permission = MutableStateFlow(userPermissionProvider.get(vmParams.spaceId))
init {
Timber.d("Init DateObjectViewModel, date object id: [${vmParams.objectId}], space: [${vmParams.spaceId}]")
uiHeaderState.value = UiHeaderState.Loading
uiFieldsState.value = UiFieldsState.LoadingState
uiObjectsListState.value = UiObjectsListState.LoadingState
proceedWithObservingPermissions()
proceedWithGettingDateObject()
proceedWithGettingDateObjectRelationList()
proceedWithObservingSyncStatus()
setupSearchStateFlow()
_dateId.value = vmParams.objectId
}
fun onStart() {
Timber.d("onStart")
setupUiStateFlow()
viewModelScope.launch {
sendAnalyticsAllContentScreen(
analytics = analytics
)
}
}
fun onStop() {
unsubscribe()
resetLimit()
canPaginate.value = false
uiObjectsListState.value = UiObjectsListState.Empty
uiContentState.value = UiContentState.Empty
}
override fun onCleared() {
Timber.d("onCleared")
super.onCleared()
uiContentState.value = UiContentState.Empty
uiHeaderState.value = UiHeaderState.Empty
uiCalendarIconState.value = UiCalendarIconState.Hidden
uiSyncStatusBadgeState.value = UiSyncStatusBadgeState.Hidden
uiFieldsState.value = UiFieldsState.Empty
uiObjectsListState.value = UiObjectsListState.Empty
uiFieldsSheetState.value = UiFieldsSheetState.Hidden
resetLimit()
}
private fun proceedWithReopenDateObjectByTimestamp(timestamp: TimeInSeconds) {
proceedWithGettingDateByTimestamp(
timestamp = timestamp
) { dateObject ->
val id = dateObject?.getSingleValue<String>(Relations.ID)
if (id != null) {
reopenDateObject(id)
} else {
Timber.e("GettingDateByTimestamp error, object has no id")
}
}
}
private fun reopenDateObject(dateObjectId: Id) {
Timber.d("Reopen date object: $dateObjectId")
canPaginate.value = false
resetLimit()
shouldScrollToTopItems = true
uiFieldsState.value = UiFieldsState.Empty
uiObjectsListState.value = UiObjectsListState.Empty
uiFieldsSheetState.value = UiFieldsSheetState.Hidden
_activeField.value = null
_dateId.value = dateObjectId
}
private fun setupSearchStateFlow() {
viewModelScope.launch {
searchQuery.collectLatest { query ->
if (uiFieldsSheetState.value is UiFieldsSheetState.Hidden) return@collectLatest
val items = uiFieldsState.value.items
if (items.isEmpty()) return@collectLatest
val filteredItems = if (query.isBlank()) {
items
} else {
items.filterIsInstance<UiFieldsItem.Item>()
.filter { it.title.contains(query, ignoreCase = true) }
}
uiFieldsSheetState.value = UiFieldsSheetState.Visible(
items = filteredItems
)
}
}
}
//region Initialization
private fun proceedWithObservingPermissions() {
viewModelScope.launch {
userPermissionProvider
.observe(space = vmParams.spaceId)
.collect { result ->
uiNavigationWidget.value = if (result?.isOwnerOrEditor() == true) {
UiNavigationWidget.Editor
} else {
UiNavigationWidget.Viewer
}
permission.value = result
}
}
}
private fun proceedWithObservingSyncStatus() {
viewModelScope.launch {
spaceSyncAndP2PStatusProvider
.observe()
.catch {
Timber.e(it, "Error while observing sync status")
}
.collect { syncAndP2pState ->
Timber.d("Sync status: $syncAndP2pState")
uiSyncStatusBadgeState.value = UiSyncStatusBadgeState.Visible(syncAndP2pState)
val state = uiSyncStatusWidgetState.value
uiSyncStatusWidgetState.value = when (state) {
UiSyncStatusWidgetState.Hidden -> UiSyncStatusWidgetState.Hidden
is UiSyncStatusWidgetState.Visible -> state.copy(
status = syncAndP2pState.toSyncStatusWidgetState()
)
}
}
}
}
private fun proceedWithGettingDateObjectRelationList() {
viewModelScope.launch {
_dateId
.filterNotNull()
.collect { id ->
val params = GetObjectRelationListById.Params(
space = vmParams.spaceId,
value = id
)
Timber.d("Start RelationListWithValue with params: $params")
getObjectRelationListById.async(params).fold(
onSuccess = { result ->
Timber.d("RelationListWithValue Success: $result")
val items =
result.toUiFieldsItem(storeOfRelations = storeOfRelations)
initFieldsState(items)
},
onFailure = { e ->
Timber.e(e, "RelationListWithValue Error")
errorState.value = UiErrorState.Show(
Reason.ErrorGettingFields(
msg = e.message ?: "Error getting fields"
)
)
}
)
}
}
}
private fun proceedWithGettingDateObject() {
viewModelScope.launch {
_dateId
.filterNotNull()
.collect { id ->
val params = GetObject.Params(
target = id,
space = vmParams.spaceId
)
Timber.d("Start GetObject with params: $params")
getObject.async(params).fold(
onSuccess = { obj ->
Timber.d("GetObject Success, obj:[$obj]")
val timestampInSeconds =
obj.details[id]?.getSingleValue<Double>(
Relations.TIMESTAMP
)?.toLong()
if (timestampInSeconds != null) {
_dateTimestamp.value = timestampInSeconds
val (formattedDate, _) = dateProvider.formatTimestampToDateAndTime(
timestamp = timestampInSeconds * 1000,
)
uiCalendarIconState.value = UiCalendarIconState.Visible(
timestampInSeconds = TimestampInSeconds(timestampInSeconds)
)
uiHeaderState.value = UiHeaderState.Content(
title = formattedDate
)
}
},
onFailure = { e -> Timber.e(e, "GetObject Error") }
)
}
}
}
private fun proceedWithGettingDateByTimestamp(timestamp: Long, action: (Struct?) -> Unit) {
val params = GetDateObjectByTimestamp.Params(
space = vmParams.spaceId,
timestamp = timestamp
)
Timber.d("Start ObjectDateByTimestamp with params: [$params]")
viewModelScope.launch {
getDateObjectByTimestamp.async(params).fold(
onSuccess = { dateObject ->
Timber.d("ObjectDateByTimestamp Success, dateObject: [$dateObject]")
action(dateObject)
},
onFailure = { e -> Timber.e(e, "ObjectDateByTimestamp Error") }
)
}
}
//endregion
//region Subscription
private fun subscriptionId() = "date_object_subscription_${vmParams.spaceId}"
@OptIn(ExperimentalCoroutinesApi::class)
private fun setupUiStateFlow() {
viewModelScope.launch {
combine(
_dateId.filterNotNull(),
_dateTimestamp.filterNotNull(),
_activeField.filterNotNull(),
restartSubscription
) { dateId, timestamp, activeField, _ ->
createSearchParams(
dateId = dateId,
timestamp = timestamp,
space = vmParams.spaceId,
itemsLimit = _itemsLimit,
field = activeField
)
}
.flatMapLatest { searchParams ->
loadData(searchParams)
}
.catch {
errorState.value = UiErrorState.Show(
Reason.Other(it.message ?: "Error getting data")
)
}
.collect { items ->
uiObjectsListState.value = UiObjectsListState(items)
}
}
}
private fun loadData(
searchParams: StoreSearchParams
): Flow<List<UiObjectsListItem>> {
return storelessSubscriptionContainer.subscribe(searchParams)
.onStart {
uiContentState.value = if (_itemsLimit == DEFAULT_SEARCH_LIMIT) {
UiContentState.InitLoading
} else {
UiContentState.Paging
}
Timber.d("Restart subscription: with params: $searchParams")
}
.map { objWrappers ->
handleData(objWrappers)
}.catch { e ->
Timber.e("Error loading data: $e")
errorState.value = UiErrorState.Show(
Reason.ErrorGettingObjects(
e.message ?: "Error getting objects"
)
)
}
}
private suspend fun handleData(
objWrappers: List<ObjectWrapper.Basic>
): List<UiObjectsListItem> {
canPaginate.value = objWrappers.size == _itemsLimit
val items = objWrappers.map {
it.toUiObjectsListItem(
space = vmParams.spaceId,
urlBuilder = urlBuilder,
objectTypes = storeOfObjectTypes.getAll(),
fieldParser = fieldParser
)
}
uiContentState.value = if (items.isEmpty()) {
UiContentState.Empty
} else {
UiContentState.Idle(scrollToTop = shouldScrollToTopItems).also {
shouldScrollToTopItems = false
}
}
return items
}
private fun createSearchParams(
dateId: Id,
timestamp: TimeInSeconds,
field: ActiveField,
space: SpaceId,
itemsLimit: Int
): StoreSearchParams {
val (filters, sorts) = filtersAndSortsForSearch(
spaces = listOf(space.id),
field = field,
timestamp = timestamp,
dateId = dateId
)
return StoreSearchParams(
space = space,
filters = filters,
sorts = sorts,
keys = defaultKeys,
limit = itemsLimit,
subscription = subscriptionId()
)
}
/**
* Updates the limit for the number of items fetched and triggers data reload.
*/
fun updateLimit() {
Timber.d("Update limit, canPaginate: ${canPaginate.value} uiContentState: ${uiContentState.value}")
if (canPaginate.value && uiContentState.value is UiContentState.Idle) {
_itemsLimit += DEFAULT_SEARCH_LIMIT
restartSubscription.value++
}
}
private fun resetLimit() {
Timber.d("Reset limit")
_itemsLimit = DEFAULT_SEARCH_LIMIT
}
private fun unsubscribe() {
viewModelScope.launch {
storelessSubscriptionContainer.unsubscribe(listOf(subscriptionId()))
}
}
//endregion
//region Ui Actions
private fun onFieldsEvent(item: UiFieldsItem, needToScroll: Boolean = false) {
when (item) {
is UiFieldsItem.Item -> {
if (_activeField.value?.key == item.key) {
val value = _activeField.value
val activeSort = _activeField.value?.sort ?: DEFAULT_SORT_TYPE
_activeField.value = value?.copy(
sort = if (activeSort == DVSortType.ASC) {
DVSortType.DESC
} else {
DVSortType.ASC
}
)
shouldScrollToTopItems = true
resetLimit()
canPaginate.value = false
uiContentState.value = Idle()
uiObjectsListState.value = UiObjectsListState.Empty
restartSubscription.value++
updateHorizontalListState(selectedItem = item, needToScroll = needToScroll)
} else {
shouldScrollToTopItems = true
resetLimit()
canPaginate.value = false
uiContentState.value = Idle()
_activeField.value = ActiveField(
key = item.key,
format = item.relationFormat
)
restartSubscription.value++
updateHorizontalListState(selectedItem = item, needToScroll = needToScroll)
}
}
is UiFieldsItem.Settings -> {
val items = uiFieldsState.value.items
uiFieldsSheetState.value = UiFieldsSheetState.Visible(
items = items.filterIsInstance<UiFieldsItem.Item>()
)
}
else -> {}
}
}
private fun proceedWithReopeningDate(offset: Int) {
val timestamp = _dateTimestamp.value
if (timestamp == null) {
Timber.w("Error getting timestamp")
return
}
val newTimestamp = timestamp + offset
val isValid = dateProvider.isTimestampWithinYearRange(
timeStampInMillis = newTimestamp * 1000,
yearRange = DATE_PICKER_YEAR_RANGE
)
if (isValid) {
proceedWithReopenDateObjectByTimestamp(
timestamp = newTimestamp
)
} else {
showDateOutOfRangeError()
}
}
private fun proceedWithCreateDoc(
objType: ObjectWrapper.Type? = null
) {
val startTime = System.currentTimeMillis()
val params = objType?.uniqueKey.getCreateObjectParams(
space = vmParams.spaceId,
objType?.defaultTemplateId
)
viewModelScope.launch {
createObject.async(params).fold(
onSuccess = { result ->
proceedWithNavigation(
navigation = result.obj.navigation()
)
sendAnalyticsObjectCreateEvent(
analytics = analytics,
route = EventsDictionary.Routes.objDate,
startTime = startTime,
objType = objType ?: storeOfObjectTypes.getByKey(result.typeKey.key),
view = EventsDictionary.View.viewHome,
spaceParams = provideParams(space = vmParams.spaceId.id)
)
},
onFailure = { e -> Timber.e(e, "Error while creating a new object") }
)
}
}
private fun proceedWithNavigation(navigation: OpenObjectNavigation) {
viewModelScope.launch {
when (navigation) {
is OpenObjectNavigation.OpenDataView -> {
effects.emit(
DateObjectCommand.NavigateToSetOrCollection(
id = navigation.target,
space = SpaceId(navigation.space)
)
)
}
is OpenObjectNavigation.OpenEditor -> {
effects.emit(
DateObjectCommand.NavigateToEditor(
id = navigation.target,
space = SpaceId(navigation.space)
)
)
}
is OpenObjectNavigation.UnexpectedLayoutError -> {
Timber.e("Unexpected layout: ${navigation.layout}")
effects.emit(DateObjectCommand.SendToast.UnexpectedLayout(navigation.layout?.name.orEmpty()))
}
is OpenObjectNavigation.OpenDiscussion -> {
effects.emit(
DateObjectCommand.OpenChat(
target = navigation.target,
space = SpaceId(navigation.space)
)
)
}
OpenObjectNavigation.NonValidObject -> {
Timber.e("Object id is missing")
}
is OpenObjectNavigation.OpenDataObject -> {
effects.emit(
DateObjectCommand.NavigateToEditor(
id = navigation.target,
space = SpaceId(navigation.space)
)
)
}
}
}
}
private fun onItemClicked(item: UiObjectsListItem) {
Timber.d("onItemClicked: ${item.id}")
when (item) {
is UiObjectsListItem.Item -> {
val layout = item.layout ?: return
proceedWithNavigation(
navigation = layout.navigation(
target = item.id,
space = vmParams.spaceId.id
)
)
viewModelScope.launch {
//sendAnalyticsAllContentResult(analytics = analytics)
}
}
is UiObjectsListItem.Loading -> {
Timber.d("Loading item clicked")
}
}
}
fun onCreateObjectOfTypeClicked(objType: ObjectWrapper.Type) {
proceedWithCreateDoc(objType)
}
fun onDateEvent(event: DateEvent) {
when (event) {
is DateEvent.Calendar -> onCalendarEvent(event)
is DateEvent.TopToolbar -> onTopToolbarEvent(event)
is DateEvent.Header -> onHeaderEvent(event)
is DateEvent.FieldsSheet -> onFieldsSheetEvent(event)
is DateEvent.FieldsList -> onFieldsListEvent(event)
is DateEvent.NavigationWidget -> onNavigationWidgetEvent(event)
is DateEvent.ObjectsList -> onObjectsListEvent(event)
is DateEvent.SyncStatusWidget -> onSyncStatusWidgetEvent(event)
}
}
private fun onFieldsListEvent(event: DateEvent.FieldsList) {
when (event) {
DateEvent.FieldsList.OnScrolledToItemDismiss -> {
uiFieldsState.value = uiFieldsState.value.copy(
needToScrollTo = false
)
}
is DateEvent.FieldsList.OnFieldClick -> onFieldsEvent(event.item)
}
}
private fun onSyncStatusWidgetEvent(event: DateEvent.SyncStatusWidget) {
when (event) {
DateEvent.SyncStatusWidget.OnSyncStatusDismiss -> {
uiSyncStatusWidgetState.value = UiSyncStatusWidgetState.Hidden
}
}
}
private fun onObjectsListEvent(event: DateEvent.ObjectsList) {
when (event) {
DateEvent.ObjectsList.OnLoadMore -> updateLimit()
is DateEvent.ObjectsList.OnObjectClicked -> onItemClicked(event.item)
}
}
private fun onTopToolbarEvent(event: DateEvent.TopToolbar) {
when (event) {
is DateEvent.TopToolbar.OnCalendarClick -> {
val timestampInSeconds = event.timestampInSeconds
val timeInMillis = dateProvider.adjustToStartOfDayInUserTimeZone(
timestamp = timestampInSeconds.time
)
val isValid = dateProvider.isTimestampWithinYearRange(
timeStampInMillis = timeInMillis,
yearRange = DATE_PICKER_YEAR_RANGE
)
if (isValid) {
uiCalendarState.value = UiCalendarState.Calendar(
timeInMillis = timeInMillis
)
} else {
showDateOutOfRangeError()
}
}
is DateEvent.TopToolbar.OnSyncStatusClick -> {
uiSyncStatusWidgetState.value =
UiSyncStatusWidgetState.Visible(
status = event.status.toSyncStatusWidgetState()
)
}
}
}
private fun onHeaderEvent(event: DateEvent.Header) {
when (event) {
DateEvent.Header.OnNextClick -> proceedWithReopeningDate(offset = SECONDS_IN_DAY)
DateEvent.Header.OnPreviousClick -> proceedWithReopeningDate(offset = -SECONDS_IN_DAY)
}
}
private fun onCalendarEvent(event: DateEvent.Calendar) {
when (event) {
is DateEvent.Calendar.OnCalendarDateSelected -> {
uiCalendarState.value = UiCalendarState.Hidden
val timeInMillis = event.timeInMillis
Timber.d("Selected date in millis: [$timeInMillis]")
if (timeInMillis == null) return
proceedWithReopenDateObjectByTimestamp(
timestamp = dateProvider.adjustFromStartOfDayInUserTimeZoneToUTC(
timeInMillis = timeInMillis
)
)
}
DateEvent.Calendar.OnCalendarDismiss -> {
uiCalendarState.value = UiCalendarState.Hidden
}
DateEvent.Calendar.OnTodayClick -> {
uiCalendarState.value = UiCalendarState.Hidden
proceedWithReopenDateObjectByTimestamp(
timestamp = dateProvider.getTimestampForTodayAtStartOfDay()
)
}
DateEvent.Calendar.OnTomorrowClick -> {
uiCalendarState.value = UiCalendarState.Hidden
proceedWithReopenDateObjectByTimestamp(
timestamp = dateProvider.getTimestampForTomorrowAtStartOfDay()
)
}
}
}
private fun onFieldsSheetEvent(event: DateEvent.FieldsSheet) {
when (event) {
is DateEvent.FieldsSheet.OnFieldClick -> {
uiFieldsSheetState.value = UiFieldsSheetState.Hidden
onFieldsEvent(event.item, needToScroll = true)
}
is DateEvent.FieldsSheet.OnSearchQueryChanged -> {
Timber.d("Search query: ${event.query}")
userInput.value = event.query
}
DateEvent.FieldsSheet.OnSheetDismiss -> {
uiFieldsSheetState.value = UiFieldsSheetState.Hidden
}
}
}
private fun onNavigationWidgetEvent(event: DateEvent.NavigationWidget) {
when (event) {
DateEvent.NavigationWidget.OnAddDocClick -> {
proceedWithCreateDoc()
}
DateEvent.NavigationWidget.OnAddDocLongClick -> {
viewModelScope.launch {
effects.emit(DateObjectCommand.TypeSelectionScreen)
}
}
DateEvent.NavigationWidget.OnBackClick -> {
viewModelScope.launch {
effects.emit(DateObjectCommand.Back)
}
}
DateEvent.NavigationWidget.OnBackLongClick -> {
viewModelScope.launch {
effects.emit(DateObjectCommand.ExitToSpaceWidgets)
}
}
DateEvent.NavigationWidget.OnGlobalSearchClick -> {
viewModelScope.launch {
effects.emit(DateObjectCommand.OpenGlobalSearch)
}
}
}
}
//endregion
//region Ui State
private fun initFieldsState(relations: List<UiFieldsItem.Item>) {
val relation = relations.getOrNull(0)
if (relation == null) {
Timber.e("Error getting relation")
return
}
_activeField.value = ActiveField(
key = relation.key,
format = relation.relationFormat
)
restartSubscription.value++
uiFieldsState.value = UiFieldsState(
items = buildList {
add(UiFieldsItem.Settings())
addAll(relations)
},
selectedRelationKey = _activeField.value?.key
)
if (relations.isEmpty()) {
uiContentState.value = UiContentState.Empty
uiObjectsListState.value = UiObjectsListState.Empty
}
}
private fun updateHorizontalListState(selectedItem: UiFieldsItem.Item, needToScroll: Boolean = false) {
uiFieldsState.value = uiFieldsState.value.copy(
selectedRelationKey = selectedItem.key,
needToScrollTo = needToScroll
)
}
fun hideError() {
errorState.value = UiErrorState.Hidden
}
fun showDateOutOfRangeError() {
viewModelScope.launch {
errorState.emit(
UiErrorState.Show(
Reason.YearOutOfRange(
min = DATE_PICKER_YEAR_RANGE.first,
max = DATE_PICKER_YEAR_RANGE.last
)
)
)
}
}
//endregion
companion object {
//INITIAL STATE
const val SECONDS_IN_DAY = 86400
const val DEFAULT_SEARCH_LIMIT = 25
val DEFAULT_SORT_TYPE = DVSortType.DESC
}
}

View file

@ -0,0 +1,134 @@
package com.anytypeio.anytype.feature_date.viewmodel
import com.anytypeio.anytype.core_models.DVFilter
import com.anytypeio.anytype.core_models.DVFilterCondition
import com.anytypeio.anytype.core_models.DVSort
import com.anytypeio.anytype.core_models.Id
import com.anytypeio.anytype.core_models.ObjectType
import com.anytypeio.anytype.core_models.ObjectTypeUniqueKeys
import com.anytypeio.anytype.core_models.Relation
import com.anytypeio.anytype.core_models.RelationFormat
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.core_models.TimeInSeconds
fun filtersAndSortsForSearch(
dateId: Id,
field: ActiveField,
timestamp: TimeInSeconds,
spaces: List<Id>
): Pair<List<DVFilter>, List<DVSort>> {
val filters = buildList {
addAll(buildDeletedFilter())
add(buildSpaceIdFilter(spaces))
add(buildTemplateFilter())
add(
buildFieldFilter(
dateObjectId = dateId,
field = field,
timestamp = timestamp
)
)
add(buildLayoutFilter())
}
return filters to buildSorts(field)
}
private fun buildSorts(
field: ActiveField,
): List<DVSort> {
return listOf(
DVSort(
relationKey = field.key.key,
type = field.sort,
relationFormat = RelationFormat.DATE
)
)
}
private fun buildFieldFilter(
dateObjectId: Id,
field: ActiveField,
timestamp: TimeInSeconds
): DVFilter {
val fieldKey = field.key.key
return when (field.format) {
Relation.Format.DATE -> {
DVFilter(
relation = fieldKey,
condition = DVFilterCondition.EQUAL,
value = timestamp.toDouble(),
relationFormat = RelationFormat.DATE
)
}
else -> {
DVFilter(
relation = fieldKey,
condition = DVFilterCondition.IN,
value = dateObjectId
)
}
}
}
private fun buildTemplateFilter(): DVFilter = DVFilter(
relation = Relations.TYPE_UNIQUE_KEY,
condition = DVFilterCondition.NOT_EQUAL,
value = ObjectTypeUniqueKeys.TEMPLATE
)
private fun buildSpaceIdFilter(spaces: List<Id>): DVFilter = DVFilter(
relation = Relations.SPACE_ID,
condition = DVFilterCondition.IN,
value = spaces
)
private fun buildLayoutFilter(): DVFilter = DVFilter(
relation = Relations.LAYOUT,
condition = DVFilterCondition.IN,
value = SUPPORTED_DATE_OBJECT_LAYOUTS.map { it.code.toDouble() }
)
private fun buildDeletedFilter(): List<DVFilter> {
return listOf(
DVFilter(
relation = Relations.IS_ARCHIVED,
condition = DVFilterCondition.NOT_EQUAL,
value = true
),
DVFilter(
relation = Relations.IS_HIDDEN,
condition = DVFilterCondition.NOT_EQUAL,
value = true
),
DVFilter(
relation = Relations.IS_DELETED,
condition = DVFilterCondition.NOT_EQUAL,
value = true
),
DVFilter(
relation = Relations.IS_HIDDEN_DISCOVERY,
condition = DVFilterCondition.NOT_EQUAL,
value = true
)
)
}
private val SUPPORTED_DATE_OBJECT_LAYOUTS = listOf(
ObjectType.Layout.SET,
ObjectType.Layout.COLLECTION,
ObjectType.Layout.TODO,
ObjectType.Layout.NOTE,
ObjectType.Layout.BASIC,
ObjectType.Layout.PROFILE,
ObjectType.Layout.PARTICIPANT,
ObjectType.Layout.BOOKMARK,
ObjectType.Layout.DATE,
ObjectType.Layout.FILE,
ObjectType.Layout.IMAGE,
ObjectType.Layout.VIDEO,
ObjectType.Layout.AUDIO,
ObjectType.Layout.PDF
)