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

DROID-1014 Account | Enhancement | Account name & image icon editing (#3062)

DROID-1014 Account | Enhancement | Account name & image icon editing
This commit is contained in:
Allan Quatermain 2023-03-30 16:33:44 +03:00 committed by GitHub
parent 85d579f24b
commit 776551ac12
Signed by: github
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 636 additions and 50 deletions

View file

@ -12,6 +12,11 @@ import com.anytypeio.anytype.domain.config.ConfigStorage
import com.anytypeio.anytype.domain.config.UserSettingsRepository
import com.anytypeio.anytype.domain.debugging.DebugSync
import com.anytypeio.anytype.domain.device.ClearFileCache
import com.anytypeio.anytype.domain.icon.SetDocumentImageIcon
import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.domain.search.SubscriptionEventChannel
import com.anytypeio.anytype.presentation.util.downloader.UriFileProvider
import com.anytypeio.anytype.providers.DefaultUriFileProvider
import com.anytypeio.anytype.ui.settings.AccountAndDataFragment
@ -46,12 +51,22 @@ object AccountAndDataModule {
clearFileCache: ClearFileCache,
deleteAccount: DeleteAccount,
debugSyncShareDownloader: DebugSyncShareDownloader,
analytics: Analytics
analytics: Analytics,
storelessSubscriptionContainer: StorelessSubscriptionContainer,
setObjectDetails: SetObjectDetails,
configStorage: ConfigStorage,
urlBuilder: UrlBuilder,
setDocumentImageIcon: SetDocumentImageIcon
): AccountAndDataViewModel.Factory = AccountAndDataViewModel.Factory(
clearFileCache = clearFileCache,
deleteAccount = deleteAccount,
debugSyncShareDownloader = debugSyncShareDownloader,
analytics = analytics
analytics = analytics,
storelessSubscriptionContainer = storelessSubscriptionContainer,
setObjectDetails = setObjectDetails,
configStorage = configStorage,
urlBuilder = urlBuilder,
setDocumentImageIcon = setDocumentImageIcon
)
@Provides
@ -102,6 +117,37 @@ object AccountAndDataModule {
@PerScreen
fun deleteAccount(repo: AuthRepository): DeleteAccount = DeleteAccount(repo)
@JvmStatic
@Provides
@PerScreen
fun provideSetObjectDetails(
repo: BlockRepository,
dispatchers: AppCoroutineDispatchers
): SetObjectDetails = SetObjectDetails(
repo,
dispatchers
)
@JvmStatic
@Provides
@PerScreen
fun provideSetDocumentImageIcon(
repo: BlockRepository
): SetDocumentImageIcon = SetDocumentImageIcon(repo)
@JvmStatic
@Provides
@PerScreen
fun provideStoreLessSubscriptionContainer(
repo: BlockRepository,
channel: SubscriptionEventChannel,
dispatchers: AppCoroutineDispatchers
): StorelessSubscriptionContainer = StorelessSubscriptionContainer.Impl(
repo = repo,
channel = channel,
dispatchers = dispatchers
)
@Module
interface Bindings {

View file

@ -1,29 +1,43 @@
package com.anytypeio.anytype.ui.settings
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.anytypeio.anytype.R
import com.anytypeio.anytype.analytics.base.EventsDictionary
import com.anytypeio.anytype.core_ui.common.ComposeDialogView
import com.anytypeio.anytype.core_utils.ext.GetImageContract
import com.anytypeio.anytype.core_utils.ext.parseImagePath
import com.anytypeio.anytype.core_utils.ext.shareFile
import com.anytypeio.anytype.core_utils.ext.subscribe
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
import com.anytypeio.anytype.ui.auth.account.DeleteAccountWarning
import com.anytypeio.anytype.ui.dashboard.ClearCacheAlertFragment
import com.anytypeio.anytype.ui.editor.modals.IconPickerFragmentBase
import com.anytypeio.anytype.ui.profile.KeychainPhraseDialog
import com.anytypeio.anytype.ui.sets.ARG_SHOW_REMOVE_BUTTON
import com.anytypeio.anytype.ui_settings.account.AccountAndDataScreen
import com.anytypeio.anytype.ui_settings.account.AccountAndDataViewModel
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import javax.inject.Inject
import timber.log.Timber
class AccountAndDataFragment : BaseBottomSheetComposeFragment() {
@ -55,7 +69,10 @@ class AccountAndDataFragment : BaseBottomSheetComposeFragment() {
shareFile(uri)
}
}
return ComposeView(requireContext()).apply {
return ComposeDialogView(
context = requireContext(),
dialog = requireDialog()
).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
MaterialTheme(typography = typography) {
@ -68,13 +85,28 @@ class AccountAndDataFragment : BaseBottomSheetComposeFragment() {
isLogoutInProgress = vm.isLoggingOut.collectAsState().value,
isClearCacheInProgress = vm.isClearFileCacheInProgress.collectAsState().value,
isDebugSyncReportInProgress = vm.isDebugSyncReportInProgress.collectAsState().value,
isShowDebug = toggles.isTroubleshootingMode
isShowDebug = toggles.isTroubleshootingMode,
onNameChange = { vm.onNameChange(it) },
onProfileIconClick = { proceedWithIconClick() },
vm.accountData.collectAsStateWithLifecycle().value
)
}
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val offsetFromTop = PADDING_TOP
(dialog as? BottomSheetDialog)?.behavior?.apply {
isFitToContents = false
expandedOffset = offsetFromTop
state = BottomSheetBehavior.STATE_EXPANDED
skipCollapsed = true
}
}
private fun proceedWithClearFileCacheWarning() {
vm.onClearCacheButtonClicked()
val dialog = ClearCacheAlertFragment.new()
@ -91,6 +123,48 @@ class AccountAndDataFragment : BaseBottomSheetComposeFragment() {
dialog.show(childFragmentManager, null)
}
private fun proceedWithIconClick() {
if (!hasExternalStoragePermission()) {
permissionReadStorage.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE))
} else {
openGallery()
}
}
private fun hasExternalStoragePermission() = ContextCompat.checkSelfPermission(
requireActivity(),
Manifest.permission.READ_EXTERNAL_STORAGE
).let { result -> result == PackageManager.PERMISSION_GRANTED }
private val permissionReadStorage =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grantResults ->
val readResult = grantResults[Manifest.permission.READ_EXTERNAL_STORAGE]
if (readResult == true) {
openGallery()
} else {
toast(R.string.permission_read_denied)
}
}
private fun openGallery() {
getContent.launch(SELECT_IMAGE_CODE)
}
private val getContent = registerForActivityResult(GetImageContract()) { uri: Uri? ->
if (uri != null) {
try {
val path = uri.parseImagePath(requireContext())
vm.onPickedImageFromDevice(path = path)
} catch (e: Exception) {
toast("Error while parsing path for cover image")
Timber.d(e, "Error while parsing path for cover image")
}
} else {
toast("Error while upload cover image, URI is null")
Timber.e("Error while upload cover image, URI is null")
}
}
override fun injectDependencies() {
componentManager().accountAndDataComponent.get().inject(this)
}
@ -100,3 +174,6 @@ class AccountAndDataFragment : BaseBottomSheetComposeFragment() {
}
}
private const val PADDING_TOP = 54
private const val SELECT_IMAGE_CODE = 1

View file

@ -6,7 +6,6 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.compose.material.MaterialTheme
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels
@ -16,7 +15,6 @@ import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.anytypeio.anytype.R
import com.anytypeio.anytype.core_ui.common.ComposeDialogView
import com.anytypeio.anytype.core_utils.ext.toast
import com.anytypeio.anytype.core_utils.tools.FeatureToggles
import com.anytypeio.anytype.core_utils.ui.BaseBottomSheetComposeFragment
import com.anytypeio.anytype.di.common.componentManager
@ -82,7 +80,7 @@ class MainSettingFragment : BaseBottomSheetComposeFragment() {
setContent {
MaterialTheme(typography = typography) {
MainSettingScreen(
workspace = vm.workspaceData.collectAsStateWithLifecycle().value,
workspace = vm.workspaceAndAccount.collectAsStateWithLifecycle().value,
onAccountAndDataClicked = onAccountAndDataClicked,
onAboutAppClicked = onAboutAppClicked,
onAppearanceClicked = onAppearanceClicked,

View file

@ -2,6 +2,7 @@ package com.anytypeio.anytype.core_utils.ext
import android.content.Context
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
@ -65,6 +66,9 @@ inline fun <T> List<T>.replace(replacement: (T) -> T, target: (T) -> Boolean): L
fun Context.toast(msg: CharSequence) = Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
fun Fragment.toast(msg: CharSequence) = requireActivity().toast(msg)
fun Fragment.toast(@StringRes msgId: Int) = requireActivity().toast(requireActivity().getString(msgId))
fun Fragment.dismissInnerDialog(tag: String) {
val fragment = childFragmentManager.findFragmentByTag(tag)
(fragment as? DialogFragment)?.dismiss()

View file

@ -0,0 +1,20 @@
package com.anytypeio.anytype.presentation.profile
import com.anytypeio.anytype.core_models.ObjectWrapper
import com.anytypeio.anytype.core_models.Url
import com.anytypeio.anytype.domain.misc.UrlBuilder
sealed class ProfileIconView {
object Loading : ProfileIconView()
object Placeholder : ProfileIconView()
data class Emoji(val unicode: String) : ProfileIconView()
data class Image(val url: Url) : ProfileIconView()
}
fun ObjectWrapper.Basic.profileIcon(builder: UrlBuilder): ProfileIconView = when {
!iconImage.isNullOrEmpty() -> {
val hash = checkNotNull(iconImage)
ProfileIconView.Image(builder.thumbnail(hash))
}
else -> ProfileIconView.Placeholder
}

View file

@ -15,6 +15,8 @@ import com.anytypeio.anytype.domain.library.StoreSearchByIdsParams
import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.presentation.profile.profileIcon
import com.anytypeio.anytype.presentation.spaces.SpaceIconView
import com.anytypeio.anytype.presentation.spaces.spaceIcon
import kotlinx.coroutines.flow.MutableSharedFlow
@ -37,10 +39,13 @@ class MainSettingsViewModel(
val events = MutableSharedFlow<Event>(replay = 0)
val commands = MutableSharedFlow<Command>(replay = 0)
val workspaceData = storelessSubscriptionContainer.subscribe(
private val profileId = configStorage.get().profile
private val workspaceId = configStorage.get().workspace
val workspaceAndAccount = storelessSubscriptionContainer.subscribe(
StoreSearchByIdsParams(
subscription = SPACE_SUBSCRIPTION_ID,
targets = listOf(configStorage.get().workspace),
targets = listOf(workspaceId, profileId),
keys = listOf(
Relations.ID,
Relations.NAME,
@ -49,15 +54,26 @@ class MainSettingsViewModel(
)
)
).map { result ->
val obj = result.firstOrNull()
WorkspaceData.Data(
name = obj?.name ?: "",
icon = obj?.spaceIcon(urlBuilder) ?: SpaceIconView.Placeholder
val workspace = result.find { it.id == workspaceId }
val profile = result.find { it.id == profileId }
WorkspaceAndAccount.Account(
space = workspace?.let {
WorkspaceAndAccount.SpaceData(
name = workspace.name ?: "",
icon = workspace.spaceIcon(urlBuilder)
)
},
profile = profile?.let {
WorkspaceAndAccount.ProfileData(
name = profile.name ?: "",
icon = profile.profileIcon(urlBuilder)
)
}
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(STOP_SUBSCRIPTION_TIMEOUT),
WorkspaceData.Idle
WorkspaceAndAccount.Idle
)
init {
@ -79,9 +95,11 @@ class MainSettingsViewModel(
Event.OnAppearanceClicked -> commands.emit(Command.OpenAppearanceScreen)
Event.OnPersonalizationClicked -> commands.emit(Command.OpenPersonalizationScreen)
Event.OnDebugClicked -> commands.emit(Command.OpenDebugScreen)
Event.OnSpaceImageClicked -> commands.emit(Command.OpenSpaceImageSet(
configStorage.get().workspace
))
Event.OnSpaceImageClicked -> commands.emit(
Command.OpenSpaceImageSet(
configStorage.get().workspace
)
)
}
}
@ -125,7 +143,7 @@ class MainSettingsViewModel(
}
}
fun onNameSet(name: String) {
fun onNameSet(name: String) {
viewModelScope.launch {
setObjectDetails.execute(
SetObjectDetails.Params(
@ -182,14 +200,26 @@ class MainSettingsViewModel(
class OpenSpaceImageSet(val id: Id) : Command()
}
sealed class WorkspaceData {
object Idle : WorkspaceData()
class Data(
sealed class WorkspaceAndAccount {
object Idle : WorkspaceAndAccount()
class Account(
val space: SpaceData?,
val profile: ProfileData?
) : WorkspaceAndAccount()
data class SpaceData(
val name: String,
val icon: SpaceIconView
) : WorkspaceData()
)
data class ProfileData(
val name: String,
val icon: ProfileIconView,
)
}
}
private const val SPACE_SUBSCRIPTION_ID = "settings_space_subscription"

View file

@ -1,31 +1,56 @@
package com.anytypeio.anytype.ui_settings.account
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import com.anytypeio.anytype.core_ui.foundation.Arrow
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.foundation.Option
import com.anytypeio.anytype.core_ui.foundation.Toolbar
import com.anytypeio.anytype.core_ui.foundation.noRippleClickable
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.ui_settings.R
import com.anytypeio.anytype.ui_settings.main.NameBlock
@Composable
fun AccountAndDataScreen(
@ -38,15 +63,23 @@ fun AccountAndDataScreen(
isClearCacheInProgress: Boolean,
isDebugSyncReportInProgress: Boolean,
isShowDebug: Boolean,
onNameChange: (String) -> Unit,
onProfileIconClick: () -> Unit,
account: AccountAndDataViewModel.AccountProfile
) {
Column {
Box(
Modifier
.padding(vertical = 6.dp)
.align(Alignment.CenterHorizontally)) {
Dragger()
}
Toolbar(stringResource(R.string.account_and_data))
Column(modifier = Modifier.fillMaxHeight()) {
Header(
modifier = Modifier.align(Alignment.CenterHorizontally),
account = account,
onNameSet = onNameChange,
onProfileIconClick = onProfileIconClick
)
Spacer(
modifier = Modifier
.height(10.dp)
.padding(top = 4.dp)
)
Divider()
Section(stringResource(R.string.access))
Option(
image = R.drawable.ic_keychain_phrase,
@ -206,4 +239,169 @@ fun ActionWithProgressBar(
)
}
}
}
@Composable
private fun Header(
modifier: Modifier = Modifier,
account: AccountAndDataViewModel.AccountProfile,
onProfileIconClick: () -> Unit,
onNameSet: (String) -> Unit
) {
when (account) {
is AccountAndDataViewModel.AccountProfile.Data -> {
Box(modifier = modifier.padding(vertical = 6.dp)) {
Dragger()
}
Box(modifier = modifier.padding(top = 12.dp, bottom = 28.dp)) {
ProfileNameBlock()
}
Box(modifier = modifier.padding(bottom = 16.dp)) {
ProfileImageBlock(
name = account.name,
icon = account.icon,
onProfileIconClick = onProfileIconClick
)
}
NameBlock(name = account.name, onNameSet = onNameSet)
}
is AccountAndDataViewModel.AccountProfile.Idle -> {}
}
}
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun NameBlock(
modifier: Modifier = Modifier,
name: String,
onNameSet: (String) -> Unit
) {
val nameValue = remember { mutableStateOf(name) }
val focusManager = LocalFocusManager.current
Column(modifier = modifier.padding(start = 20.dp)) {
Text(
text = "Name",
color = colorResource(id = R.color.text_secondary),
fontSize = 13.sp
)
BasicTextField(
value = nameValue.value,
onValueChange = {
nameValue.value = it
},
modifier = Modifier.padding(top = 4.dp, end = 20.dp),
enabled = true,
textStyle = TextStyle(
fontSize = 22.sp,
fontWeight = FontWeight.SemiBold,
color = colorResource(id = R.color.text_primary)
),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onNameSet.invoke(nameValue.value)
focusManager.clearFocus()
}
),
singleLine = true,
decorationBox = @Composable { innerTextField ->
TextFieldDefaults.OutlinedTextFieldDecorationBox(
value = nameValue.value,
innerTextField = innerTextField,
singleLine = true,
enabled = true,
isError = false,
placeholder = {
Text(text = "Account name")
},
colors = TextFieldDefaults.outlinedTextFieldColors(
textColor = colorResource(id = R.color.text_primary),
backgroundColor = Color.Transparent,
disabledBorderColor = Color.Transparent,
errorBorderColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
placeholderColor = colorResource(id = R.color.text_tertiary),
),
contentPadding = PaddingValues(
start = 0.dp,
top = 0.dp,
end = 0.dp,
bottom = 0.dp
),
border = {},
interactionSource = remember { MutableInteractionSource() },
visualTransformation = VisualTransformation.None
)
}
)
}
}
@Composable
fun ProfileNameBlock(modifier: Modifier = Modifier) {
Text(
text = stringResource(R.string.account_and_data),
style = MaterialTheme.typography.h3,
color = colorResource(id = R.color.text_primary)
)
}
@Composable
fun ProfileImageBlock(
name: String,
icon: ProfileIconView,
onProfileIconClick: () -> Unit
) {
when (icon) {
is ProfileIconView.Image -> {
Image(
painter = rememberAsyncImagePainter(
model = icon.url,
error = painterResource(id = R.drawable.ic_home_widget_space)
),
contentDescription = "Custom image profile",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(48.dp))
.noRippleClickable {
onProfileIconClick.invoke()
}
)
}
else -> {
val nameFirstChar = if (name.isEmpty()) {
stringResource(id = R.string.account_default_name)
} else {
name.first().uppercaseChar().toString()
}
Box(
modifier = Modifier
.size(96.dp)
.clip(RoundedCornerShape(48.dp))
.background(colorResource(id = R.color.shape_primary))
.noRippleClickable {
onProfileIconClick.invoke()
}
) {
Text(
text = nameFirstChar,
style = MaterialTheme.typography.h3.copy(
color = colorResource(id = R.color.text_white),
fontSize = 64.sp
),
modifier = Modifier.align(Alignment.Center)
)
}
}
}
}

View file

@ -7,14 +7,27 @@ import androidx.lifecycle.viewModelScope
import com.anytypeio.anytype.analytics.base.Analytics
import com.anytypeio.anytype.analytics.base.EventsDictionary
import com.anytypeio.anytype.analytics.base.sendEvent
import com.anytypeio.anytype.core_models.Relations
import com.anytypeio.anytype.domain.account.DeleteAccount
import com.anytypeio.anytype.domain.base.BaseUseCase
import com.anytypeio.anytype.domain.base.Interactor
import com.anytypeio.anytype.domain.base.fold
import com.anytypeio.anytype.domain.config.ConfigStorage
import com.anytypeio.anytype.domain.device.ClearFileCache
import com.anytypeio.anytype.domain.icon.SetDocumentImageIcon
import com.anytypeio.anytype.domain.icon.SetImageIcon
import com.anytypeio.anytype.domain.library.StoreSearchByIdsParams
import com.anytypeio.anytype.domain.library.StorelessSubscriptionContainer
import com.anytypeio.anytype.domain.misc.UrlBuilder
import com.anytypeio.anytype.domain.`object`.SetObjectDetails
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.presentation.profile.profileIcon
import com.anytypeio.anytype.ui_settings.account.repo.DebugSyncShareDownloader
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import timber.log.Timber
@ -23,6 +36,11 @@ class AccountAndDataViewModel(
private val analytics: Analytics,
private val deleteAccount: DeleteAccount,
private val debugSyncShareDownloader: DebugSyncShareDownloader,
private val storelessSubscriptionContainer: StorelessSubscriptionContainer,
private val setObjectDetails: SetObjectDetails,
private val configStorage: ConfigStorage,
private val urlBuilder: UrlBuilder,
private val setImageIcon: SetDocumentImageIcon
) : ViewModel() {
private val jobs = mutableListOf<Job>()
@ -32,6 +50,31 @@ class AccountAndDataViewModel(
val isLoggingOut = MutableStateFlow(false)
val debugSyncReportUri = MutableStateFlow<Uri?>(null)
private val profileId = configStorage.get().profile
val accountData = storelessSubscriptionContainer.subscribe(
StoreSearchByIdsParams(
subscription = ACCOUNT_AND_DATA_SUBSCRIPTION_ID,
keys = listOf(
Relations.ID,
Relations.NAME,
Relations.ICON_IMAGE,
Relations.ICON_EMOJI
),
targets = listOf(profileId)
)
).map { result ->
val obj = result.firstOrNull()
AccountProfile.Data(
name = obj?.name ?: "",
icon = obj?.profileIcon(urlBuilder) ?: ProfileIconView.Placeholder
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(STOP_SUBSCRIPTION_TIMEOUT),
AccountProfile.Idle
)
fun onClearFileCacheAccepted() {
Timber.d("onClearFileCacheAccepted, ")
jobs += viewModelScope.launch {
@ -57,6 +100,24 @@ class AccountAndDataViewModel(
}
}
fun onNameChange(name: String) {
viewModelScope.launch {
setObjectDetails.execute(
SetObjectDetails.Params(
ctx = profileId,
details = mapOf(Relations.NAME to name)
)
).fold(
onFailure = {
Timber.e(it, "Error while updating object details")
},
onSuccess = {
// do nothing
}
)
}
}
fun onClearCacheButtonClicked() {
jobs += viewModelScope.sendEvent(
analytics = analytics,
@ -108,6 +169,35 @@ class AccountAndDataViewModel(
forEach { it.cancel() }
clear()
}
viewModelScope.launch {
storelessSubscriptionContainer.unsubscribe(
listOf(ACCOUNT_AND_DATA_SUBSCRIPTION_ID)
)
}
}
fun onPickedImageFromDevice(path: String) {
viewModelScope.launch {
setImageIcon(
SetImageIcon.Params(target = profileId, path = path)
).process(
failure = {
Timber.e("Error while setting image icon")
},
success = { (payload, _) ->
// do nothing
}
)
}
}
sealed class AccountProfile {
object Idle: AccountProfile()
class Data(
val name: String,
val icon: ProfileIconView
): AccountProfile()
}
class Factory(
@ -115,6 +205,11 @@ class AccountAndDataViewModel(
private val deleteAccount: DeleteAccount,
private val debugSyncShareDownloader: DebugSyncShareDownloader,
private val analytics: Analytics,
private val storelessSubscriptionContainer: StorelessSubscriptionContainer,
private val setObjectDetails: SetObjectDetails,
private val configStorage: ConfigStorage,
private val urlBuilder: UrlBuilder,
private val setDocumentImageIcon: SetDocumentImageIcon
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@ -122,8 +217,16 @@ class AccountAndDataViewModel(
clearFileCache = clearFileCache,
deleteAccount = deleteAccount,
debugSyncShareDownloader = debugSyncShareDownloader,
analytics = analytics
analytics = analytics,
storelessSubscriptionContainer = storelessSubscriptionContainer,
setObjectDetails = setObjectDetails,
configStorage = configStorage,
urlBuilder = urlBuilder,
setImageIcon = setDocumentImageIcon
) as T
}
}
}
}
private const val STOP_SUBSCRIPTION_TIMEOUT = 1_000L
private const val ACCOUNT_AND_DATA_SUBSCRIPTION_ID = "account_and_data_subscription"

View file

@ -1,25 +1,41 @@
package com.anytypeio.anytype.ui_settings.main
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import com.anytypeio.anytype.core_ui.foundation.Arrow
import com.anytypeio.anytype.core_ui.foundation.Divider
import com.anytypeio.anytype.core_ui.foundation.Dragger
import com.anytypeio.anytype.core_ui.foundation.Option
import com.anytypeio.anytype.presentation.profile.ProfileIconView
import com.anytypeio.anytype.presentation.settings.MainSettingsViewModel
import com.anytypeio.anytype.ui_settings.R
@Composable
fun MainSettingScreen(
workspace: MainSettingsViewModel.WorkspaceData,
workspace: MainSettingsViewModel.WorkspaceAndAccount,
onSpaceIconClick: () -> Unit,
onAccountAndDataClicked: () -> Unit,
onAboutAppClicked: () -> Unit,
@ -27,7 +43,7 @@ fun MainSettingScreen(
onPersonalizationClicked: () -> Unit,
onAppearanceClicked: () -> Unit,
onNameSet: (String) -> Unit,
showDebugMenu: Boolean
showDebugMenu: Boolean,
) {
Column(Modifier.fillMaxSize()) {
Header(
@ -36,7 +52,11 @@ fun MainSettingScreen(
onSpaceIconClick = onSpaceIconClick,
onNameSet = onNameSet
)
Spacer(modifier = Modifier.height(10.dp).padding(top = 4.dp))
Spacer(
modifier = Modifier
.height(10.dp)
.padding(top = 4.dp)
)
Divider()
Spacer(modifier = Modifier.height(26.dp))
Settings(
@ -45,7 +65,8 @@ fun MainSettingScreen(
onAppearanceClicked = onAppearanceClicked,
onAboutAppClicked = onAboutAppClicked,
showDebugMenu = showDebugMenu,
onDebugClicked = onDebugClicked
onDebugClicked = onDebugClicked,
accountData = workspace
)
Box(modifier = Modifier.height(16.dp))
}
@ -58,14 +79,15 @@ private fun Settings(
onAppearanceClicked: () -> Unit,
onAboutAppClicked: () -> Unit,
showDebugMenu: Boolean,
onDebugClicked: () -> Unit
onDebugClicked: () -> Unit,
accountData: MainSettingsViewModel.WorkspaceAndAccount
) {
Section(
modifier = Modifier.padding(start = 20.dp, bottom = 4.dp),
title = "Settings"
title = stringResource(id = R.string.settings)
)
Option(
image = R.drawable.ic_account_and_data,
AccountOption(
data = (accountData as? MainSettingsViewModel.WorkspaceAndAccount.Account)?.profile,
text = stringResource(R.string.account_and_data),
onClick = onAccountAndDataClicked
)
@ -101,12 +123,12 @@ private fun Settings(
@Composable
private fun Header(
modifier: Modifier = Modifier,
workspace: MainSettingsViewModel.WorkspaceData,
workspace: MainSettingsViewModel.WorkspaceAndAccount,
onSpaceIconClick: () -> Unit,
onNameSet: (String) -> Unit
) {
when (workspace) {
is MainSettingsViewModel.WorkspaceData.Data -> {
is MainSettingsViewModel.WorkspaceAndAccount.Account -> {
Box(modifier = modifier.padding(vertical = 6.dp)) {
Dragger()
}
@ -114,13 +136,97 @@ private fun Header(
SpaceNameBlock()
}
Box(modifier = modifier.padding(bottom = 16.dp)) {
SpaceImageBlock(
icon = workspace.icon,
onSpaceIconClick = onSpaceIconClick
)
workspace.space?.icon?.let {
SpaceImageBlock(
icon = it,
onSpaceIconClick = onSpaceIconClick
)
}
}
workspace.space?.name?.let {
NameBlock(name = it, onNameSet = onNameSet)
}
NameBlock(name = workspace.name, onNameSet = onNameSet)
}
is MainSettingsViewModel.WorkspaceData.Idle -> {}
is MainSettingsViewModel.WorkspaceAndAccount.Idle -> {}
}
}
@Composable
fun AccountOption(
data: MainSettingsViewModel.WorkspaceAndAccount.ProfileData?,
text: String,
onClick: () -> Unit = {}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(52.dp)
.clickable(onClick = onClick)
) {
data?.let {
when (val icon = it.icon) {
is ProfileIconView.Image -> {
Image(
painter = rememberAsyncImagePainter(
model = icon.url,
error = painterResource(id = R.drawable.ic_home_widget_space)
),
contentDescription = "Custom image profile",
contentScale = ContentScale.Crop,
modifier = Modifier
.padding(start = 20.dp)
.size(28.dp)
.clip(RoundedCornerShape(14.dp))
)
}
else -> {
val nameFirstChar = if (data.name.isEmpty()) {
stringResource(id = R.string.account_default_name)
} else {
data.name.first().uppercaseChar().toString()
}
Box(
modifier = Modifier
.padding(start = 20.dp)
.size(28.dp)
.clip(RoundedCornerShape(14.dp))
.background(colorResource(id = R.color.shape_primary))
) {
Text(
text = nameFirstChar,
style = MaterialTheme.typography.h3.copy(
color = colorResource(id = R.color.text_white),
fontSize = 14.sp
),
modifier = Modifier.align(Alignment.Center)
)
}
}
}
} ?: kotlin.run {
Image(
painterResource(R.drawable.ic_account_and_data),
contentDescription = "Option icon",
modifier = Modifier.padding(
start = 20.dp
)
)
}
Text(
text = text,
color = colorResource(com.anytypeio.anytype.core_ui.R.color.text_primary),
modifier = Modifier.padding(
start = 12.dp
)
)
Box(
modifier = Modifier.weight(1.0f, true),
contentAlignment = Alignment.CenterEnd
) {
Arrow()
}
}
}

View file

@ -26,6 +26,10 @@
<string name="dark">Dark</string>
<string name="system">System</string>
<string name="account_default_name">U</string>
<string name="settings">Settings</string>
<string name="have_you_back_up_your_recovery_phrase">Have you backed up your recovery phrase?</string>
<string name="you_will_need_to_sign_in">You will need it to sign in. Keep it in a safe place. If you lose it, you can no longer access your account.</string>