diff --git a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt index 3a4b377a7ef..e3a9d9c3cc4 100644 --- a/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt +++ b/app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt @@ -151,6 +151,15 @@ interface NcApiCoroutines { @DELETE suspend fun deleteConversationAvatar(@Header("Authorization") authorization: String, @Url url: String): RoomOverall + @FormUrlEncoded + @POST + suspend fun setConversationEmojiAvatar( + @Header("Authorization") authorization: String, + @Url url: String, + @Field("emoji") emoji: String, + @Field("color") color: String? + ): RoomOverall + @POST suspend fun archiveConversation(@Header("Authorization") authorization: String, @Url url: String): GenericOverall diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt index 0c8af106f11..5e83a3cf375 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -72,6 +73,7 @@ import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.ui.theme.protectEmojiPickerScrollGesture import com.nextcloud.talk.ui.theme.themeEmojiPickerCategoryTabs import com.nextcloud.talk.utils.CapabilitiesUtil import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability @@ -298,7 +300,7 @@ fun MessageActionsBottomSheet( ) { val context = LocalContext.current val recentEmojiProvider = remember(context) { ReactionRecentEmojiProvider(context) } - var showEmojiPicker by remember { mutableStateOf(false) } + var showEmojiPicker by rememberSaveable { mutableStateOf(false) } val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState() @@ -711,6 +713,7 @@ private fun EmojiPickerSheetContent( setRecentEmojiProvider(recentEmojiProvider) setOnEmojiPickedListener(Consumer { item -> onEmojiSelected(item.emoji) }) themeEmojiPickerCategoryTabs(this, selectedTabColor, unselectedTabColor) + protectEmojiPickerScrollGesture(this) } } ) diff --git a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt index fee0a1301a0..e7d4b785854 100644 --- a/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chooseaccount/ui/StatusMessageSheet.kt @@ -32,7 +32,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb @@ -47,6 +47,7 @@ import com.nextcloud.talk.R import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel import com.nextcloud.talk.models.json.status.Status import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus +import com.nextcloud.talk.ui.theme.protectEmojiPickerScrollGesture import com.nextcloud.talk.ui.theme.themeEmojiPickerCategoryTabs private val emojiPickerHeight = 360.dp @@ -56,7 +57,7 @@ private val emojiPickerHeight = 360.dp fun StatusMessageModalBottomSheet(currentStatus: Status, viewModel: StatusMessageViewModel, onDismiss: () -> Unit) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isDismissed by viewModel.isDismissed.collectAsState() - var showEmojiPicker by remember { mutableStateOf(false) } + var showEmojiPicker by rememberSaveable { mutableStateOf(false) } val backToStatus: () -> Unit = { showEmojiPicker = false } LaunchedEffect(currentStatus) { @@ -114,6 +115,7 @@ private fun EmojiPickerSheetContent(onEmojiSelected: (String) -> Unit, onBack: ( setBackgroundColor(backgroundColor.toArgb()) setOnEmojiPickedListener(Consumer { item -> onEmojiSelected(item.emoji) }) themeEmojiPickerCategoryTabs(this, selectedTabColor, unselectedTabColor) + protectEmojiPickerScrollGesture(this) } } ) diff --git a/app/src/main/java/com/nextcloud/talk/components/AvatarEditPanel.kt b/app/src/main/java/com/nextcloud/talk/components/AvatarEditPanel.kt new file mode 100644 index 00000000000..c1ea0e937f9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/components/AvatarEditPanel.kt @@ -0,0 +1,123 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.EmojiEmotions +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R + +data class AvatarEditPanelState( + val selectedEmoji: String? = null, + val selectedEmojiColor: Int? = null, + val showDeleteButton: Boolean = false, + val enabled: Boolean = true +) + +data class AvatarEditPanelCallbacks( + val onCameraClick: () -> Unit = {}, + val onUploadClick: () -> Unit = {}, + val onChooseClick: () -> Unit = {}, + val onEmojiAvatarConfirmed: (emoji: String, color: Int?) -> Unit = { _, _ -> }, + val onDeleteClick: () -> Unit = {} +) + +@Composable +fun AvatarEditPanel(state: AvatarEditPanelState, callbacks: AvatarEditPanelCallbacks, modifier: Modifier = Modifier) { + var showEmojiAvatarPicker by rememberSaveable { mutableStateOf(false) } + + AvatarActionButtonsRow( + state = state, + callbacks = callbacks, + onEmojiButtonClick = { showEmojiAvatarPicker = true }, + modifier = modifier + ) + + if (showEmojiAvatarPicker) { + EmojiAvatarPickerBottomSheet( + initialEmoji = state.selectedEmoji, + initialColor = state.selectedEmojiColor, + onConfirm = { emoji, color -> + callbacks.onEmojiAvatarConfirmed(emoji, color) + showEmojiAvatarPicker = false + }, + onDismiss = { showEmojiAvatarPicker = false } + ) + } +} + +@Composable +private fun AvatarActionButtonsRow( + state: AvatarEditPanelState, + callbacks: AvatarEditPanelCallbacks, + onEmojiButtonClick: () -> Unit, + modifier: Modifier = Modifier +) { + Row(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AvatarActionButton(onClick = callbacks.onCameraClick, enabled = state.enabled) { + Icon( + painter = painterResource(R.drawable.ic_baseline_photo_camera_24), + contentDescription = stringResource(R.string.set_avatar_from_camera) + ) + } + AvatarActionButton(onClick = callbacks.onUploadClick, enabled = state.enabled) { + Icon( + painter = painterResource(R.drawable.upload), + contentDescription = stringResource(R.string.upload_new_avatar_from_device) + ) + } + AvatarActionButton(onClick = callbacks.onChooseClick, enabled = state.enabled) { + Icon( + painter = painterResource(R.drawable.ic_folder), + contentDescription = stringResource(R.string.choose_avatar_from_cloud) + ) + } + AvatarActionButton(onClick = onEmojiButtonClick, enabled = state.enabled) { + Icon( + imageVector = Icons.Outlined.EmojiEmotions, + contentDescription = stringResource(R.string.nc_set_emoji_as_conversation_picture) + ) + } + if (state.showDeleteButton) { + AvatarActionButton(onClick = callbacks.onDeleteClick, enabled = state.enabled) { + Icon( + painter = painterResource(R.drawable.trashbin), + contentDescription = stringResource(R.string.delete_avatar) + ) + } + } + } +} + +@Composable +private fun AvatarActionButton(onClick: () -> Unit, enabled: Boolean, icon: @Composable () -> Unit) { + FilledTonalIconButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.size(avatarEditButtonSize), + shape = avatarEditButtonShape, + content = icon + ) +} + +private val avatarEditButtonSize = 40.dp +private val avatarEditButtonShape = RoundedCornerShape(12.dp) diff --git a/app/src/main/java/com/nextcloud/talk/components/EmojiColorPickers.kt b/app/src/main/java/com/nextcloud/talk/components/EmojiColorPickers.kt new file mode 100644 index 00000000000..da3ba95cd7c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/components/EmojiColorPickers.kt @@ -0,0 +1,526 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024-2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.components + +import android.content.res.Configuration +import android.view.ContextThemeWrapper +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.drag +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.FlowRow +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.navigationBarsPadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.FormatColorReset +import androidx.compose.material.icons.outlined.Palette +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.util.Consumer +import androidx.emoji2.emojipicker.EmojiPickerView +import com.nextcloud.talk.R +import com.nextcloud.talk.ui.theme.protectEmojiPickerScrollGesture +import com.nextcloud.talk.ui.theme.themeEmojiPickerCategoryTabs +import com.nextcloud.talk.utils.ColorGenerator +import kotlin.math.roundToInt + +/** + * A single bottom sheet combining emoji and background-color selection for a conversation + * avatar, with a live preview and explicit save/discard actions - mirrors the iOS emoji + * avatar picker. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmojiAvatarPickerBottomSheet( + initialEmoji: String?, + initialColor: Int?, + onConfirm: (emoji: String, color: Int?) -> Unit, + onDismiss: () -> Unit +) { + val paletteColors = remember { predefinedPaletteColors() } + var emoji by rememberSaveable { mutableStateOf(initialEmoji ?: DEFAULT_EMOJI) } + var color by rememberSaveable { mutableStateOf(initialColor) } + var showCustomColorPicker by rememberSaveable { mutableStateOf(false) } + val initialHsv = remember { + val hsv = FloatArray(HSV_COMPONENT_COUNT) + android.graphics.Color.colorToHSV(initialColor ?: paletteColors.first(), hsv) + hsv + } + var hue by rememberSaveable { mutableStateOf(initialHsv[0]) } + var saturation by rememberSaveable { mutableStateOf(initialHsv[1]) } + var value by rememberSaveable { mutableStateOf(initialHsv[2].coerceAtLeast(MIN_VALUE)) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (showCustomColorPicker) Modifier else Modifier.fillMaxHeight(SHEET_HEIGHT_FRACTION)) + .navigationBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + EmojiAvatarPickerTopBar(onDiscard = onDismiss, onSave = { onConfirm(emoji, color) }) + Spacer(modifier = Modifier.height(8.dp)) + EmojiAvatarPickerBody( + state = EmojiPickerBodyState( + showCustomColorPicker = showCustomColorPicker, + isLandscape = isLandscape, + emoji = emoji, + color = color, + paletteColors = paletteColors, + hsv = HsvColor(hue, saturation, value) + ), + onHsvChange = { newHsv -> + hue = newHsv.hue + saturation = newHsv.saturation + value = newHsv.value + color = newHsv.toColorInt() + }, + callbacks = EmojiPickerCallbacks( + onColorSelected = { color = it }, + onShowCustomColorPickerChange = { showCustomColorPicker = it }, + onEmojiSelected = { emoji = it } + ) + ) + } + } +} + +private data class EmojiPickerBodyState( + val showCustomColorPicker: Boolean, + val isLandscape: Boolean, + val emoji: String, + val color: Int?, + val paletteColors: List, + val hsv: HsvColor +) + +private data class EmojiPickerCallbacks( + val onColorSelected: (Int?) -> Unit, + val onShowCustomColorPickerChange: (Boolean) -> Unit, + val onEmojiSelected: (String) -> Unit +) + +@Composable +private fun ColumnScope.EmojiAvatarPickerBody( + state: EmojiPickerBodyState, + onHsvChange: (HsvColor) -> Unit, + callbacks: EmojiPickerCallbacks +) { + when { + state.showCustomColorPicker -> { + CustomColorSubScreen( + state = state, + onHsvChange = onHsvChange, + onBack = { callbacks.onShowCustomColorPickerChange(false) } + ) + } + state.isLandscape -> { + Row(modifier = Modifier.weight(1f).fillMaxWidth()) { + Column(modifier = Modifier.width(landscapeSidePanelWidth).fillMaxHeight()) { + EmojiAvatarPreview(emoji = state.emoji, color = state.color) + Spacer(modifier = Modifier.height(16.dp)) + ColorSwatchRow( + paletteColors = state.paletteColors, + selectedColor = state.color, + onColorSelected = callbacks.onColorSelected, + onCustomColorClick = { callbacks.onShowCustomColorPickerChange(true) }, + wrap = true + ) + } + Spacer(modifier = Modifier.width(16.dp)) + EmojiGrid( + onEmojiSelected = callbacks.onEmojiSelected, + modifier = Modifier.weight(1f).fillMaxHeight() + ) + } + } + else -> { + EmojiAvatarPreview(emoji = state.emoji, color = state.color) + Spacer(modifier = Modifier.height(16.dp)) + ColorSwatchRow( + paletteColors = state.paletteColors, + selectedColor = state.color, + onColorSelected = callbacks.onColorSelected, + onCustomColorClick = { callbacks.onShowCustomColorPickerChange(true) } + ) + Spacer(modifier = Modifier.height(16.dp)) + EmojiGrid(onEmojiSelected = callbacks.onEmojiSelected, modifier = Modifier.weight(1f)) + } + } +} + +@Composable +private fun EmojiAvatarPickerTopBar(onDiscard: () -> Unit, onSave: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = onDiscard) { + Icon(imageVector = Icons.Filled.Close, contentDescription = stringResource(id = R.string.nc_cancel)) + } + IconButton(onClick = onSave) { + Icon(imageVector = Icons.Filled.Check, contentDescription = stringResource(id = R.string.save)) + } + } +} + +@Composable +private fun EmojiAvatarPreview(emoji: String, color: Int?) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(previewSize) + .clip(CircleShape) + .background(color?.let { Color(it) } ?: MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center + ) { + Text(text = emoji, fontSize = previewEmojiFontSize) + } + } +} + +@Composable +private fun ColorSwatchRow( + paletteColors: List, + selectedColor: Int?, + onColorSelected: (Int?) -> Unit, + onCustomColorClick: () -> Unit, + wrap: Boolean = false +) { + val swatches: @Composable () -> Unit = { + NoColorSwatch(isSelected = selectedColor == null, onClick = { onColorSelected(null) }) + CustomColorSwatch(onClick = onCustomColorClick) + paletteColors.forEach { paletteColor -> + ColorSwatch( + color = paletteColor, + isSelected = selectedColor == paletteColor, + onClick = { onColorSelected(paletteColor) } + ) + } + } + + if (wrap) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(swatchSpacing), + verticalArrangement = Arrangement.spacedBy(swatchSpacing) + ) { + swatches() + } + } else { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(swatchSpacing) + ) { + swatches() + } + } +} + +@Composable +private fun ColorSwatch(color: Int, isSelected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(swatchSize) + .clip(CircleShape) + .background(Color(color)) + .border( + width = if (isSelected) 2.dp else 0.dp, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else Color.Transparent, + shape = CircleShape + ) + .clickable(onClick = onClick) + ) +} + +@Composable +private fun NoColorSwatch(isSelected: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .size(swatchSize) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant) + .border( + width = if (isSelected) 2.dp else 1.dp, + color = if (isSelected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.outline, + shape = CircleShape + ) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.FormatColorReset, + contentDescription = stringResource(id = R.string.nc_remove_color), + modifier = Modifier.size(18.dp) + ) + } +} + +@Composable +private fun CustomColorSwatch(onClick: () -> Unit) { + val rainbowColors = remember { hueGradientColors() } + Box( + modifier = Modifier + .size(swatchSize) + .clip(CircleShape) + .background(Brush.sweepGradient(rainbowColors)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Outlined.Palette, + contentDescription = stringResource(id = R.string.nc_custom_color), + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } +} + +@Composable +private fun EmojiGrid(onEmojiSelected: (String) -> Unit, modifier: Modifier = Modifier) { + val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow + val selectedTabColor = MaterialTheme.colorScheme.primary.toArgb() + val unselectedTabColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() + + AndroidView( + modifier = modifier.fillMaxWidth(), + factory = { ctx -> + EmojiPickerView(ContextThemeWrapper(ctx, R.style.ThemeOverlay_App_EmojiPicker)).apply { + setBackgroundColor(backgroundColor.toArgb()) + setOnEmojiPickedListener(Consumer { item -> onEmojiSelected(item.emoji) }) + themeEmojiPickerCategoryTabs(this, selectedTabColor, unselectedTabColor) + protectEmojiPickerScrollGesture(this) + } + } + ) +} + +@Composable +private fun CustomColorSubScreen(state: EmojiPickerBodyState, onHsvChange: (HsvColor) -> Unit, onBack: () -> Unit) { + val hsv = state.hsv + val saturationValuePicker: @Composable () -> Unit = { + SaturationValuePicker( + hue = hsv.hue, + saturation = hsv.saturation, + value = hsv.value, + onSaturationValueChange = { newSaturation, newValue -> + onHsvChange(hsv.copy(saturation = newSaturation, value = newValue)) + } + ) + } + val hueSlider: @Composable () -> Unit = { + HueSlider(hue = hsv.hue, onHueChange = { onHsvChange(hsv.copy(hue = it)) }) + } + val backButton: @Composable () -> Unit = { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.back_button)) + } + } + + if (state.isLandscape) { + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.width(landscapeSidePanelWidth)) { + backButton() + Spacer(modifier = Modifier.height(12.dp)) + EmojiAvatarPreview(emoji = state.emoji, color = state.color) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + saturationValuePicker() + Spacer(modifier = Modifier.height(12.dp)) + hueSlider() + } + } + } else { + backButton() + Spacer(modifier = Modifier.height(12.dp)) + EmojiAvatarPreview(emoji = state.emoji, color = state.color) + Spacer(modifier = Modifier.height(16.dp)) + saturationValuePicker() + Spacer(modifier = Modifier.height(12.dp)) + hueSlider() + } +} + +@Composable +private fun SaturationValuePicker( + hue: Float, + saturation: Float, + value: Float, + onSaturationValueChange: (Float, Float) -> Unit, + modifier: Modifier = Modifier +) { + val hueColor = remember(hue) { Color(android.graphics.Color.HSVToColor(floatArrayOf(hue, 1f, 1f))) } + val density = LocalDensity.current + val thumbRadiusPx = with(density) { (thumbSize / 2).toPx() } + var boxSize by remember { mutableStateOf(IntSize.Zero) } + + Box( + modifier = modifier + .fillMaxWidth() + .height(saturationValueHeight) + .clip(RoundedCornerShape(8.dp)) + .onSizeChanged { boxSize = it } + .background(hueColor) + .background(Brush.horizontalGradient(listOf(Color.White, Color.Transparent))) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black))) + .pointerInput(Unit) { + fun update(x: Float, y: Float) { + val clampedX = x.coerceIn(0f, size.width.toFloat()) + val clampedY = y.coerceIn(0f, size.height.toFloat()) + onSaturationValueChange(clampedX / size.width, 1f - clampedY / size.height) + } + awaitEachGesture { + val down = awaitFirstDown() + update(down.position.x, down.position.y) + drag(down.id) { change -> + change.consume() + update(change.position.x, change.position.y) + } + } + } + ) { + Box( + modifier = Modifier + .offset { + IntOffset( + (saturation * boxSize.width - thumbRadiusPx).roundToInt(), + ((1f - value) * boxSize.height - thumbRadiusPx).roundToInt() + ) + } + .size(thumbSize) + .clip(CircleShape) + .border(2.dp, Color.White, CircleShape) + ) + } +} + +@Composable +private fun HueSlider(hue: Float, onHueChange: (Float) -> Unit, modifier: Modifier = Modifier) { + val hueColors = remember { hueGradientColors() } + val density = LocalDensity.current + val thumbRadiusPx = with(density) { (thumbSize / 2).toPx() } + var sliderWidth by remember { mutableStateOf(0) } + + Box( + modifier = modifier + .fillMaxWidth() + .height(thumbSize) + .onSizeChanged { sliderWidth = it.width } + .clip(RoundedCornerShape(thumbSize / 2)) + .background(Brush.horizontalGradient(hueColors)) + .pointerInput(Unit) { + fun update(x: Float) { + val clampedX = x.coerceIn(0f, size.width.toFloat()) + onHueChange(clampedX / size.width * MAX_HUE) + } + awaitEachGesture { + val down = awaitFirstDown() + update(down.position.x) + drag(down.id) { change -> + change.consume() + update(change.position.x) + } + } + } + ) { + Box( + modifier = Modifier + .offset { IntOffset((hue / MAX_HUE * sliderWidth - thumbRadiusPx).roundToInt(), 0) } + .size(thumbSize) + .clip(CircleShape) + .border(2.dp, Color.White, CircleShape) + ) + } +} + +private data class HsvColor(val hue: Float, val saturation: Float, val value: Float) { + fun toColorInt(): Int = android.graphics.Color.HSVToColor(floatArrayOf(hue, saturation, value)) +} + +private fun hueGradientColors(): List = + (0..HUE_GRADIENT_STEPS).map { step -> + Color(android.graphics.Color.HSVToColor(floatArrayOf(step * MAX_HUE / HUE_GRADIENT_STEPS, 1f, 1f))) + } + +private fun predefinedPaletteColors(): List { + val allColors = ColorGenerator.paletteColors() + val step = allColors.size / PREDEFINED_COLOR_COUNT + return (0 until PREDEFINED_COLOR_COUNT).map { allColors[it * step] } +} + +private const val DEFAULT_EMOJI = "🙂" +private const val SHEET_HEIGHT_FRACTION = 0.85f +private const val MAX_HUE = 360f +private const val MIN_VALUE = 0.15f +private const val HUE_GRADIENT_STEPS = 6 +private const val HSV_COMPONENT_COUNT = 3 +private const val PREDEFINED_COLOR_COUNT = 6 +private val thumbSize = 24.dp +private val saturationValueHeight = 160.dp +private val swatchSize = 36.dp +private val swatchSpacing = 8.dp +private val previewSize = 88.dp +private val previewEmojiFontSize = 40.sp +private val landscapeSidePanelWidth = 160.dp diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt index 9a7e2f0d181..a197bec9e2f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt @@ -48,10 +48,12 @@ import androidx.compose.material.icons.outlined.VolumeUp import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -90,6 +92,9 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.components.AvatarEditPanel +import com.nextcloud.talk.components.AvatarEditPanelCallbacks +import com.nextcloud.talk.components.AvatarEditPanelState import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.contacts.ContactsActivity import com.nextcloud.talk.contacts.loadImage @@ -141,6 +146,8 @@ fun ConversationCreationScreen( pickImage: PickImage? = null ) { val selectedImageUri = conversationCreationViewModel.selectedImageUri.collectAsState().value + val selectedEmoji = conversationCreationViewModel.selectedEmoji.collectAsState().value + val selectedEmojiColor = conversationCreationViewModel.selectedEmojiColor.collectAsState().value val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() @@ -210,16 +217,23 @@ fun ConversationCreationScreen( .fillMaxSize() .verticalScroll(rememberScrollState()) ) { - DefaultUserAvatar(selectedImageUri) + DefaultUserAvatar(selectedImageUri, selectedEmoji, selectedEmojiColor) if (pickImage != null) { UploadAvatar( pickImage = pickImage, - onImageSelected = { uri -> conversationCreationViewModel.updateSelectedImageUri(uri) }, imagePickerLauncher = imagePickerLauncher, remoteFilePickerLauncher = remoteFilePickerLauncher, cameraLauncher = cameraLauncher, - onDeleteImage = { conversationCreationViewModel.updateSelectedImageUri(null) }, - selectedImageUri = selectedImageUri + selectedImageUri = selectedImageUri, + selectedEmoji = selectedEmoji, + selectedEmojiColor = selectedEmojiColor, + onEmojiAvatarConfirmed = { emoji, color -> + conversationCreationViewModel.updateSelectedEmojiAvatar(emoji, color) + }, + onDeleteAvatar = { + conversationCreationViewModel.updateSelectedImageUri(null) + conversationCreationViewModel.updateSelectedEmoji(null) + } ) } @@ -242,44 +256,65 @@ fun ConversationCreationScreen( } @Composable -fun DefaultUserAvatar(selectedImageUri: Uri?) { +fun DefaultUserAvatar(selectedImageUri: Uri?, selectedEmoji: String? = null, selectedEmojiColor: Int? = null) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { - if (selectedImageUri != null) { - AsyncImage( - model = selectedImageUri, - contentDescription = stringResource(id = R.string.user_avatar), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(84.dp) - .padding(top = 8.dp) - .clip(CircleShape) - ) - } else { - AsyncImage( - model = R.drawable.ic_circular_group, - contentDescription = stringResource(id = R.string.user_avatar), - contentScale = ContentScale.Crop, - modifier = Modifier - .size(84.dp) - .padding(top = 8.dp) - .clip(CircleShape) - ) + when { + selectedEmoji != null -> { + val backgroundColor = selectedEmojiColor?.let { Color(it) } ?: MaterialTheme.colorScheme.surfaceVariant + Box( + modifier = Modifier + .padding(top = 8.dp) + .size(84.dp) + .clip(CircleShape) + .background(backgroundColor), + contentAlignment = Alignment.Center + ) { + Text(text = selectedEmoji, fontSize = 36.sp) + } + } + + selectedImageUri != null -> { + AsyncImage( + model = selectedImageUri, + contentDescription = stringResource(id = R.string.user_avatar), + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(top = 8.dp) + .size(84.dp) + .clip(CircleShape) + ) + } + + else -> { + AsyncImage( + model = R.drawable.ic_circular_group, + contentDescription = stringResource(id = R.string.user_avatar), + contentScale = ContentScale.Crop, + modifier = Modifier + .padding(top = 8.dp) + .size(84.dp) + .clip(CircleShape) + ) + } } } } +@Suppress("LongParameterList") @Composable fun UploadAvatar( pickImage: PickImage, - onImageSelected: (Uri) -> Unit, imagePickerLauncher: ManagedActivityResultLauncher, remoteFilePickerLauncher: ManagedActivityResultLauncher, cameraLauncher: ManagedActivityResultLauncher, - onDeleteImage: () -> Unit, - selectedImageUri: Uri? + selectedImageUri: Uri?, + selectedEmoji: String?, + selectedEmojiColor: Int?, + onEmojiAvatarConfirmed: (emoji: String, color: Int?) -> Unit, + onDeleteAvatar: () -> Unit ) { Row( modifier = Modifier @@ -287,50 +322,20 @@ fun UploadAvatar( .padding(16.dp), horizontalArrangement = Arrangement.Center ) { - IconButton( - onClick = { - pickImage.takePicture(cameraLauncher) - } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_baseline_photo_camera_24), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - - IconButton(onClick = { - pickImage.selectLocal(imagePickerLauncher) - }) { - Icon( - painter = painterResource(id = R.drawable.upload), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - IconButton( - onClick = { - pickImage.selectRemote(remoteFilePickerLauncher) - } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_folder), - contentDescription = null, - modifier = Modifier.size(24.dp) + AvatarEditPanel( + state = AvatarEditPanelState( + selectedEmoji = selectedEmoji, + selectedEmojiColor = selectedEmojiColor, + showDeleteButton = selectedImageUri != null || selectedEmoji != null + ), + callbacks = AvatarEditPanelCallbacks( + onCameraClick = { pickImage.takePicture(cameraLauncher) }, + onUploadClick = { pickImage.selectLocal(imagePickerLauncher) }, + onChooseClick = { pickImage.selectRemote(remoteFilePickerLauncher) }, + onEmojiAvatarConfirmed = onEmojiAvatarConfirmed, + onDeleteClick = onDeleteAvatar ) - } - - if (selectedImageUri != null) { - IconButton(onClick = { - onDeleteImage() - }) { - Icon( - painter = painterResource(id = R.drawable.ic_delete_grey600_24dp), - contentDescription = null, - modifier = Modifier.size(24.dp) - ) - } - } + ) } } @@ -806,6 +811,7 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con @Composable fun CreateConversation(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { val selectedParticipants by conversationCreationViewModel.selectedParticipants.collectAsState() + val isCreatingRoom by conversationCreationViewModel.isCreatingRoom.collectAsState() Box( modifier = Modifier .fillMaxWidth() @@ -813,6 +819,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo contentAlignment = Alignment.Center ) { Button( + enabled = !isCreatingRoom, onClick = { conversationCreationViewModel.createRoomAndAddParticipants( roomType = CompanionClass.ROOM_TYPE_GROUP, @@ -829,6 +836,14 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo } } ) { + if (isCreatingRoom) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = LocalContentColor.current + ) + Spacer(modifier = Modifier.width(8.dp)) + } Text(text = stringResource(id = R.string.create_conversation)) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt index 1187394263a..25f93a8f71c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepository.kt @@ -34,5 +34,11 @@ interface ConversationCreationRepository { file: File, roomToken: String ): ConversationModel + suspend fun setConversationEmojiAvatar( + credentials: String?, + url: String, + emoji: String, + color: String? + ): RoomOverall suspend fun allowGuests(credentials: String?, url: String, token: String, allow: Boolean): GenericOverall } diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt index ce43cfe2a6f..7feec977a3b 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/data/ConversationCreationRepositoryImpl.kt @@ -107,6 +107,19 @@ class ConversationCreationRepositoryImpl @Inject constructor(private val ncApiCo return ConversationModel.mapToConversationModel(response.ocs?.data!!, user) } + override suspend fun setConversationEmojiAvatar( + credentials: String?, + url: String, + emoji: String, + color: String? + ): RoomOverall = + ncApiCoroutines.setConversationEmojiAvatar( + credentials!!, + url, + emoji, + color + ) + override suspend fun allowGuests(credentials: String?, url: String, token: String, allow: Boolean): GenericOverall { val result: GenericOverall = if (allow) { ncApiCoroutines.makeRoomPublic( diff --git a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt index cd2677acea4..550bb0d9ca3 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationcreation/viewmodel/ConversationCreationViewModel.kt @@ -40,6 +40,15 @@ class ConversationCreationViewModel @Inject constructor( private val _selectedImageUri = MutableStateFlow(null) val selectedImageUri: StateFlow = _selectedImageUri + private val _selectedEmoji = MutableStateFlow(null) + val selectedEmoji: StateFlow = _selectedEmoji + + private val _selectedEmojiColor = MutableStateFlow(null) + val selectedEmojiColor: StateFlow = _selectedEmojiColor + + private val _isCreatingRoom = MutableStateFlow(false) + val isCreatingRoom: StateFlow = _isCreatingRoom + private val _currentUser = currentUserProvider.currentUser.blockingGet() val currentUser: User = _currentUser @@ -56,6 +65,25 @@ class ConversationCreationViewModel @Inject constructor( fun updateSelectedImageUri(uri: Uri?) { _selectedImageUri.value = uri + if (uri != null) { + _selectedEmoji.value = null + _selectedEmojiColor.value = null + } + } + + fun updateSelectedEmoji(emoji: String?) { + _selectedEmoji.value = emoji + if (emoji != null) { + _selectedImageUri.value = null + } else { + _selectedEmojiColor.value = null + } + } + + fun updateSelectedEmojiAvatar(emoji: String, color: Int?) { + _selectedEmoji.value = emoji + _selectedEmojiColor.value = color + _selectedImageUri.value = null } private val _roomName = MutableStateFlow("") @@ -90,6 +118,11 @@ class ConversationCreationViewModel @Inject constructor( participants: Set, onRoomCreated: (String) -> Unit ) { + if (_isCreatingRoom.value) { + return + } + _isCreatingRoom.value = true + val credentials = ApiUtils.getCredentials(_currentUser.username, _currentUser.token) val scope = when { isConversationAvailableForRegisteredUsers.value && !openForGuestAppUsers.value -> 1 @@ -205,21 +238,7 @@ class ConversationCreationViewModel @Inject constructor( scope ) - val urlForConversationAvatar = ApiUtils.getUrlForConversationAvatar( - 1, - _currentUser.baseUrl!!, - token - ) - - selectedImageUri.value?.let { - repository.uploadConversationAvatar( - credentials, - _currentUser, - urlForConversationAvatar, - it.toFile(), - token - ) - } + saveAvatar(credentials, token) onRoomCreated(token) } catch (exception: Exception) { allowGuestsResult.value = AllowGuestsUiState.Error(exception.message ?: "") @@ -232,12 +251,42 @@ class ConversationCreationViewModel @Inject constructor( } catch (e: Exception) { roomViewState.value = RoomUIState.Error(e.message ?: "Unknown error") Log.e("ConversationCreationViewModel", "Error - ${e.message}") + } finally { + _isCreatingRoom.value = false } } } fun getImageUri(avatarId: String, requestBigSize: Boolean, isDarkMode: Boolean): String = ApiUtils.getUrlForAvatar(_currentUser.baseUrl, avatarId, requestBigSize, darkMode = isDarkMode) + + private suspend fun saveAvatar(credentials: String?, token: String) { + val emoji = _selectedEmoji.value + if (emoji != null) { + val urlForConversationEmojiAvatar = ApiUtils.getUrlForConversationEmojiAvatar( + 1, + _currentUser.baseUrl!!, + token + ) + val color = _selectedEmojiColor.value?.let { "%06X".format(COLOR_HEX_MASK and it) } + repository.setConversationEmojiAvatar(credentials, urlForConversationEmojiAvatar, emoji, color) + } else { + selectedImageUri.value?.let { + val urlForConversationAvatar = ApiUtils.getUrlForConversationAvatar(1, _currentUser.baseUrl!!, token) + repository.uploadConversationAvatar( + credentials, + _currentUser, + urlForConversationAvatar, + it.toFile(), + token + ) + } + } + } + + companion object { + private const val COLOR_HEX_MASK = 0xFFFFFF + } } sealed class AllowGuestsUiState { diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt index 008d2e5ca38..8375c531285 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ui/ConversationInfoScreen.kt @@ -53,13 +53,17 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +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.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -274,15 +278,24 @@ private fun HeaderAvatar(avatarUrl: String?) { ) } } else { + val context = LocalContext.current + val request = remember(avatarUrl) { + ImageRequest.Builder(context) + .data(avatarUrl) + .crossfade(true) + .build() + } + var lastPainter by remember { mutableStateOf(null) } AsyncImage( - model = avatarUrl, + model = request, contentDescription = stringResource(R.string.avatar), modifier = Modifier .size(80.dp) .clip(CircleShape), contentScale = ContentScale.Crop, error = painterResource(R.drawable.account_circle_48dp), - placeholder = painterResource(R.drawable.account_circle_48dp) + placeholder = lastPainter ?: painterResource(R.drawable.account_circle_48dp), + onSuccess = { lastPainter = it.painter } ) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt index a1e1bde1e9b..018a71ff102 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/viewmodel/ConversationInfoViewModel.kt @@ -297,7 +297,13 @@ class ConversationInfoViewModel @Inject constructor( } ConversationEnums.ConversationType.ROOM_GROUP_CALL, ConversationEnums.ConversationType.ROOM_PUBLIC_CALL -> - ApiUtils.getUrlForConversationAvatar(1, user.baseUrl, token) + ApiUtils.getUrlForConversationAvatarWithVersion( + version = 1, + baseUrl = user.baseUrl, + token = token, + isDark = false, + avatarVersion = conversationModel.avatarVersion.takeIf { it.isNotEmpty() } + ) else -> null } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt index b2a5195a497..d81af65aa61 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ConversationInfoEditActivity.kt @@ -141,6 +141,9 @@ class ConversationInfoEditActivity : BaseActivity() { pickImage?.takePicture(startTakePictureIntentForResult = startTakePictureIntentForResult) }, onAvatarDeleteClick = { conversationInfoEditViewModel.deleteAvatar() }, + onAvatarEmojiAvatarConfirmed = { emoji, color -> + conversationInfoEditViewModel.onEmojiAvatarConfirmed(emoji, color) + }, onNameChange = { conversationInfoEditViewModel.updateConversationName(it) }, onDescriptionChange = { conversationInfoEditViewModel.updateConversationDescription(it) } ), diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepository.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepository.kt index 1220b3fe615..c157fabb7a8 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepository.kt @@ -19,6 +19,13 @@ interface ConversationInfoEditRepository { suspend fun deleteConversationAvatar(user: User, roomToken: String): ConversationModel + suspend fun setConversationEmojiAvatar( + user: User, + roomToken: String, + emoji: String, + color: String? + ): ConversationModel + suspend fun renameConversation(user: User, roomToken: String, newRoomName: String): GenericOverall suspend fun setConversationDescription( diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepositoryImpl.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepositoryImpl.kt index 5726a320286..cd0bb28a54f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepositoryImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/data/ConversationInfoEditRepositoryImpl.kt @@ -60,6 +60,18 @@ class ConversationInfoEditRepositoryImpl(private val ncApiCoroutines: NcApiCorou return ConversationModel.mapToConversationModel(result.ocs?.data!!, user) } + override suspend fun setConversationEmojiAvatar( + user: User, + roomToken: String, + emoji: String, + color: String? + ): ConversationModel { + val credentials = ApiUtils.getCredentials(user.username, user.token) ?: "" + val url = ApiUtils.getUrlForConversationEmojiAvatar(1, user.baseUrl, roomToken) + val result = ncApiCoroutines.setConversationEmojiAvatar(credentials, url, emoji, color) + return ConversationModel.mapToConversationModel(result.ocs?.data!!, user) + } + override suspend fun renameConversation(user: User, roomToken: String, newRoomName: String): GenericOverall { val credentials = ApiUtils.getCredentials(user.username, user.token) val apiVersion = ApiUtils.getConversationApiVersion(user, intArrayOf(ApiUtils.API_V4, ApiUtils.API_V1)) diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt index 0a0d5c8e6cb..44987d3029a 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/ui/ConversationInfoEditScreen.kt @@ -13,7 +13,6 @@ import android.content.res.Configuration import android.widget.ImageView import androidx.compose.foundation.Image import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -28,11 +27,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,10 +43,14 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +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.draw.clip +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration @@ -68,6 +69,9 @@ import androidx.compose.ui.viewinterop.AndroidView import coil.compose.AsyncImage import coil.request.ImageRequest import com.nextcloud.talk.R +import com.nextcloud.talk.components.AvatarEditPanel +import com.nextcloud.talk.components.AvatarEditPanelCallbacks +import com.nextcloud.talk.components.AvatarEditPanelState import com.nextcloud.talk.conversationinfoedit.viewmodel.ConversationInfoEditUiState import com.nextcloud.talk.extensions.loadSystemAvatar import com.nextcloud.talk.models.domain.ConversationModel @@ -75,9 +79,7 @@ import com.nextcloud.talk.models.json.conversations.ConversationEnums import com.nextcloud.talk.models.json.participants.Participant private const val AVATAR_SIZE_DP = 96 -private const val AVATAR_BUTTON_SIZE_DP = 40 private const val CONVERSATION_NAME_MAX_LENGTH = 255 -private val avatarButtonShape = RoundedCornerShape(12.dp) data class ConversationInfoEditCallbacks( val onNavigateBack: () -> Unit = {}, @@ -86,6 +88,7 @@ data class ConversationInfoEditCallbacks( val onAvatarChooseClick: () -> Unit = {}, val onAvatarCameraClick: () -> Unit = {}, val onAvatarDeleteClick: () -> Unit = {}, + val onAvatarEmojiAvatarConfirmed: (emoji: String, color: Int?) -> Unit = { _, _ -> }, val onNameChange: (String) -> Unit = {}, val onDescriptionChange: (String) -> Unit = {} ) @@ -292,54 +295,21 @@ private fun AvatarButtonsRow(uiState: ConversationInfoEditUiState, callbacks: Co if (!showButtons) return - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilledTonalIconButton( - onClick = callbacks.onAvatarUploadClick, - enabled = uiState.avatarButtonsEnabled, - modifier = Modifier.size(AVATAR_BUTTON_SIZE_DP.dp), - shape = avatarButtonShape - ) { - Icon( - painter = painterResource(R.drawable.upload), - contentDescription = stringResource(R.string.upload_new_avatar_from_device) - ) - } - FilledTonalIconButton( - onClick = callbacks.onAvatarChooseClick, - enabled = uiState.avatarButtonsEnabled, - modifier = Modifier.size(AVATAR_BUTTON_SIZE_DP.dp), - shape = avatarButtonShape - ) { - Icon( - painter = painterResource(R.drawable.ic_folder), - contentDescription = stringResource(R.string.choose_avatar_from_cloud) - ) - } - FilledTonalIconButton( - onClick = callbacks.onAvatarCameraClick, - enabled = uiState.avatarButtonsEnabled, - modifier = Modifier.size(AVATAR_BUTTON_SIZE_DP.dp), - shape = avatarButtonShape - ) { - Icon( - painter = painterResource(R.drawable.ic_baseline_photo_camera_24), - contentDescription = stringResource(R.string.set_avatar_from_camera) - ) - } - if (uiState.conversation.hasCustomAvatar) { - FilledTonalIconButton( - onClick = callbacks.onAvatarDeleteClick, - enabled = uiState.avatarButtonsEnabled, - modifier = Modifier.size(AVATAR_BUTTON_SIZE_DP.dp), - shape = avatarButtonShape - ) { - Icon( - painter = painterResource(R.drawable.trashbin), - contentDescription = stringResource(R.string.delete_avatar) - ) - } - } - } + AvatarEditPanel( + state = AvatarEditPanelState( + selectedEmoji = uiState.selectedEmoji, + selectedEmojiColor = uiState.selectedEmojiColor, + showDeleteButton = uiState.conversation.hasCustomAvatar, + enabled = uiState.avatarButtonsEnabled + ), + callbacks = AvatarEditPanelCallbacks( + onCameraClick = callbacks.onAvatarCameraClick, + onUploadClick = callbacks.onAvatarUploadClick, + onChooseClick = callbacks.onAvatarChooseClick, + onEmojiAvatarConfirmed = callbacks.onAvatarEmojiAvatarConfirmed, + onDeleteClick = callbacks.onAvatarDeleteClick + ) + ) } @Suppress("CyclomaticComplexMethod") @@ -404,12 +374,14 @@ private fun OneToOneAvatarImage(params: AvatarImageParams, modifier: Modifier = .crossfade(true) .build() } + var lastPainter by remember { mutableStateOf(null) } AsyncImage( model = request, contentDescription = stringResource(R.string.avatar), contentScale = ContentScale.Crop, - placeholder = painterResource(R.drawable.account_circle_96dp), + placeholder = lastPainter ?: painterResource(R.drawable.account_circle_96dp), error = painterResource(R.drawable.account_circle_96dp), + onSuccess = { lastPainter = it.painter }, modifier = modifier ) } @@ -425,12 +397,14 @@ private fun GroupAvatarImage(params: AvatarImageParams, modifier: Modifier = Mod .crossfade(true) .build() } + var lastPainter by remember { mutableStateOf(null) } AsyncImage( model = request, contentDescription = stringResource(R.string.avatar), contentScale = ContentScale.Crop, - placeholder = painterResource(R.drawable.ic_circular_group), + placeholder = lastPainter ?: painterResource(R.drawable.ic_circular_group), error = painterResource(R.drawable.ic_circular_group), + onSuccess = { lastPainter = it.painter }, modifier = modifier ) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditUiState.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditUiState.kt index e9ac7d0831f..05c3b09f046 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditUiState.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditUiState.kt @@ -19,6 +19,8 @@ data class ConversationInfoEditUiState( val avatarUrl: String = "", val avatarUrlDark: String = "", val avatarRefreshKey: Int = 0, + val selectedEmoji: String? = null, + val selectedEmojiColor: Int? = null, val nameEnabled: Boolean = true, val descriptionEnabled: Boolean = true, val showSaveButton: Boolean = true, diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt index 3b83478edf4..5b4427d74ca 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfoedit/viewmodel/ConversationInfoEditViewModel.kt @@ -109,7 +109,9 @@ class ConversationInfoEditViewModel @Inject constructor( conversation = conversationModel, avatarUrl = avatarUrlLight, avatarUrlDark = avatarUrlDark, - avatarRefreshKey = it.avatarRefreshKey + 1 + avatarRefreshKey = it.avatarRefreshKey + 1, + selectedEmoji = null, + selectedEmojiColor = null ) } } catch (e: Exception) { @@ -131,7 +133,9 @@ class ConversationInfoEditViewModel @Inject constructor( conversation = conversationModel, avatarUrl = avatarUrlLight, avatarUrlDark = avatarUrlDark, - avatarRefreshKey = it.avatarRefreshKey + 1 + avatarRefreshKey = it.avatarRefreshKey + 1, + selectedEmoji = null, + selectedEmojiColor = null ) } } catch (e: Exception) { @@ -141,6 +145,36 @@ class ConversationInfoEditViewModel @Inject constructor( } } + @Suppress("Detekt.TooGenericExceptionCaught") + fun onEmojiAvatarConfirmed(emoji: String, color: Int?) { + viewModelScope.launch { + try { + val user = currentUser ?: return@launch + val colorHex = color?.let { "%06X".format(COLOR_HEX_MASK and it) } + val conversationModel = conversationInfoEditRepository.setConversationEmojiAvatar( + user, + roomToken, + emoji, + colorHex + ) + val (avatarUrlLight, avatarUrlDark) = buildAvatarUrls(user, conversationModel) + _uiState.update { + it.copy( + conversation = conversationModel, + avatarUrl = avatarUrlLight, + avatarUrlDark = avatarUrlDark, + avatarRefreshKey = it.avatarRefreshKey + 1, + selectedEmoji = emoji, + selectedEmojiColor = color + ) + } + } catch (e: Exception) { + Log.e(TAG, "Error when setting emoji avatar", e) + _uiState.update { it.copy(userMessage = R.string.nc_common_error_sorry) } + } + } + } + @Suppress("Detekt.TooGenericExceptionCaught") fun renameRoom(token: String, newName: String) { viewModelScope.launch { @@ -180,6 +214,7 @@ class ConversationInfoEditViewModel @Inject constructor( companion object { private val TAG = ConversationInfoEditViewModel::class.simpleName + private const val COLOR_HEX_MASK = 0xFFFFFF } private fun buildAvatarUrls(user: User, conversationModel: ConversationModel): Pair { diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/EmojiPickerCategoryTabs.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/EmojiPickerCategoryTabs.kt index d5d0d29ad15..1c2d3804fbb 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/EmojiPickerCategoryTabs.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/EmojiPickerCategoryTabs.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.ui.theme import android.content.res.ColorStateList +import android.view.MotionEvent import android.view.View import android.view.ViewTreeObserver import android.widget.ImageView @@ -85,3 +86,62 @@ fun themeEmojiPickerCategoryTabs(emojiPickerView: EmojiPickerView, selectedColor } ) } + +/** + * When the picker is embedded in a Compose `ModalBottomSheet` via `AndroidView`, a swipe that + * starts on a clickable/long-clickable emoji cell fails to scroll the grid, while a swipe over + * the recycler's own background (e.g. a section header) scrolls normally. Root cause: on such a + * touch, RecyclerView designates the clickable cell as the touch target and only later, from + * `onInterceptTouchEvent`, decides to intercept for scrolling - and in that path + * `RecyclerView.onTouchEvent()`, which is what calls `requestDisallowInterceptTouchEvent(true)` to + * protect the gesture from ancestors, never runs. Without that call, Compose's `AndroidView` + * interop (`PointerInteropFilter`) never marks the gesture as claimed by the embedded view, so the + * ModalBottomSheet's own drag-gesture detector is free to also claim it - which it does, sending + * ACTION_CANCEL down into the RecyclerView before it can visibly scroll. When the touch instead + * starts on a plain (non-interactive) child, no child claims the initial DOWN, so RecyclerView's + * own `onTouchEvent` runs from the very first event and calls `requestDisallowInterceptTouchEvent` + * promptly, which is why that case has always scrolled fine. + * + * Fix: claim `requestDisallowInterceptTouchEvent(true)` ourselves as soon as a gesture begins, + * exactly like a nested RecyclerView/ViewPager protecting its own scroll axis would - regardless + * of whether a clickable child ends up being the initial touch target. + */ +fun protectEmojiPickerScrollGesture(emojiPickerView: EmojiPickerView) { + fun protect(recyclerView: RecyclerView) { + recyclerView.addOnItemTouchListener( + object : RecyclerView.OnItemTouchListener { + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + if (e.actionMasked == MotionEvent.ACTION_DOWN) { + rv.parent?.requestDisallowInterceptTouchEvent(true) + } + return false + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) = Unit + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) = Unit + } + ) + } + + val header = emojiPickerView.findViewById(EmojiPickerR.id.emoji_picker_header) + val body = emojiPickerView.findViewById(EmojiPickerR.id.emoji_picker_body) + if (header != null && body != null) { + protect(header) + protect(body) + return + } + + emojiPickerView.viewTreeObserver.addOnGlobalLayoutListener( + object : ViewTreeObserver.OnGlobalLayoutListener { + override fun onGlobalLayout() { + val lateHeader = emojiPickerView.findViewById(EmojiPickerR.id.emoji_picker_header) + val lateBody = emojiPickerView.findViewById(EmojiPickerR.id.emoji_picker_body) + if (lateHeader == null || lateBody == null) return + emojiPickerView.viewTreeObserver.removeOnGlobalLayoutListener(this) + protect(lateHeader) + protect(lateBody) + } + } + ) +} diff --git a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt index 25cec08b176..4ec4104bd39 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ApiUtils.kt @@ -385,6 +385,9 @@ object ApiUtils { fun getUrlForConversationAvatar(version: Int, baseUrl: String?, token: String?): String = getUrlForRoom(version, baseUrl, token) + "/avatar" + fun getUrlForConversationEmojiAvatar(version: Int, baseUrl: String?, token: String?): String = + getUrlForRoom(version, baseUrl, token) + "/avatar/emoji" + fun getUrlForConversationAvatarWithVersion( version: Int, baseUrl: String?, diff --git a/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt b/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt index 57bec126793..b371b6c45d7 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/ColorGenerator.kt @@ -39,6 +39,8 @@ object ColorGenerator { return Color.rgb(rgb.r, rgb.g, rgb.b) } + fun paletteColors(): List = finalPalette.map { Color.rgb(it.r, it.g, it.b) } + private fun hashToInt(hash: String, maximum: Int): Int { val sum = hash.map { it.lowercaseChar().digitToInt(16) % 16 }.sum() return sum % maximum diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9d65e0423cb..96aa5e83ad4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -754,6 +754,9 @@ How to translate with transifex: Upload new avatar from device Choose avatar from cloud Delete avatar + Set emoji as conversation picture + Remove color + Custom color Private Only visible to people matched via phone number integration through Talk on mobile Lock symbol