Merge pull request #4196 from nextcloud/user_avatar_selection

User avatar selection
This commit is contained in:
Marcel Hibbe 2024-09-12 17:30:24 +02:00 committed by GitHub
commit 946aa60409
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 179 additions and 34 deletions

View File

@ -15,7 +15,7 @@ import com.github.spotbugs.snom.SpotBugsTask
plugins { plugins {
id "org.jetbrains.kotlin.plugin.compose" version "2.0.20" id "org.jetbrains.kotlin.plugin.compose" version "2.0.20"
id "org.jetbrains.kotlin.kapt" id "org.jetbrains.kotlin.kapt"
id 'com.google.devtools.ksp' version '2.0.20-1.0.25' id 'com.google.devtools.ksp' version '2.0.20-1.0.24'
} }
apply plugin: 'com.android.application' apply plugin: 'com.android.application'

View File

@ -11,13 +11,16 @@ import com.nextcloud.talk.models.json.autocomplete.AutocompleteOverall
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall
import okhttp3.MultipartBody
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.Field import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Query import retrofit2.http.Query
import retrofit2.http.QueryMap import retrofit2.http.QueryMap
import retrofit2.http.Url import retrofit2.http.Url
@ -96,4 +99,15 @@ interface NcApiCoroutines {
@Url url: String?, @Url url: String?,
@Field("password") password: String? @Field("password") password: String?
): GenericOverall ): GenericOverall
@Multipart
@POST
suspend fun uploadConversationAvatar(
@Header("Authorization") authorization: String,
@Url url: String,
@Part attachment: MultipartBody.Part
): RoomOverall
@DELETE
suspend fun deleteConversationAvatar(@Header("Authorization") authorization: String, @Url url: String): RoomOverall
} }

View File

@ -13,6 +13,7 @@ import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.compose.ManagedActivityResultLauncher import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
@ -57,7 +59,9 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
@ -77,6 +81,7 @@ import com.nextcloud.talk.chat.ChatActivity
import com.nextcloud.talk.contacts.ContactsActivityCompose import com.nextcloud.talk.contacts.ContactsActivityCompose
import com.nextcloud.talk.contacts.loadImage import com.nextcloud.talk.contacts.loadImage
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
import com.nextcloud.talk.utils.PickImage
import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys
import javax.inject.Inject import javax.inject.Inject
@ -84,7 +89,7 @@ import javax.inject.Inject
class ConversationCreationActivity : BaseActivity() { class ConversationCreationActivity : BaseActivity() {
@Inject @Inject
lateinit var viewModelFactory: ViewModelProvider.Factory lateinit var viewModelFactory: ViewModelProvider.Factory
private lateinit var pickImage: PickImage
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -93,13 +98,16 @@ class ConversationCreationActivity : BaseActivity() {
this, this,
viewModelFactory viewModelFactory
)[ConversationCreationViewModel::class.java] )[ConversationCreationViewModel::class.java]
val conversationUser = conversationCreationViewModel.currentUser
pickImage = PickImage(this, conversationUser)
setContent { setContent {
val colorScheme = viewThemeUtils.getColorScheme(this) val colorScheme = viewThemeUtils.getColorScheme(this)
val context = LocalContext.current val context = LocalContext.current
MaterialTheme( MaterialTheme(
colorScheme = colorScheme colorScheme = colorScheme
) { ) {
ConversationCreationScreen(conversationCreationViewModel, context) ConversationCreationScreen(conversationCreationViewModel, context, pickImage)
} }
SetStatusBarColor() SetStatusBarColor()
} }
@ -125,15 +133,47 @@ private fun SetStatusBarColor() {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConversationCreationScreen(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { fun ConversationCreationScreen(
conversationCreationViewModel: ConversationCreationViewModel,
context: Context,
pickImage: PickImage
) {
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
pickImage.onImagePickerResult(result.data) { uri ->
selectedImageUri = uri
}
}
}
val remoteFilePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
pickImage.onSelectRemoteFilesResult(imagePickerLauncher, result.data)
}
}
val cameraLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
pickImage.onTakePictureResult(imagePickerLauncher, result.data)
}
}
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult(), contract = ActivityResultContracts.StartActivityForResult(),
onResult = { result -> onResult = { result ->
if (result.resultCode == Activity.RESULT_OK) { if (result.resultCode == Activity.RESULT_OK) {
val data = result.data val data = result.data
val selectedParticipants = data?.getParcelableArrayListExtra<AutocompleteUser>("selectedParticipants") val selectedParticipants =
?: emptyList() data?.getParcelableArrayListExtra<AutocompleteUser>("selectedParticipants")
?: emptyList()
val participants = selectedParticipants.toMutableList() val participants = selectedParticipants.toMutableList()
conversationCreationViewModel.updateSelectedParticipants(participants) conversationCreationViewModel.updateSelectedParticipants(participants)
} }
@ -162,43 +202,75 @@ fun ConversationCreationScreen(conversationCreationViewModel: ConversationCreati
.padding(paddingValues) .padding(paddingValues)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
DefaultUserAvatar() DefaultUserAvatar(selectedImageUri)
UploadAvatar() UploadAvatar(
pickImage = pickImage,
onImageSelected = { uri -> selectedImageUri = uri },
imagePickerLauncher = imagePickerLauncher,
remoteFilePickerLauncher = remoteFilePickerLauncher,
cameraLauncher = cameraLauncher,
onDeleteImage = { selectedImageUri = null }
)
ConversationNameAndDescription(conversationCreationViewModel) ConversationNameAndDescription(conversationCreationViewModel)
AddParticipants(launcher, context, conversationCreationViewModel) AddParticipants(launcher, context, conversationCreationViewModel)
RoomCreationOptions(conversationCreationViewModel) RoomCreationOptions(conversationCreationViewModel)
CreateConversation(conversationCreationViewModel, context) CreateConversation(conversationCreationViewModel, context, selectedImageUri)
} }
} }
) )
} }
@Composable @Composable
fun DefaultUserAvatar() { fun DefaultUserAvatar(selectedImageUri: Uri?) {
Box( Box(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AsyncImage( if (selectedImageUri != null) {
model = R.drawable.ic_circular_group, AsyncImage(
contentDescription = stringResource(id = R.string.user_avatar), model = selectedImageUri,
modifier = Modifier contentDescription = stringResource(id = R.string.user_avatar),
.size(width = 84.dp, height = 84.dp) contentScale = ContentScale.Crop,
.padding(top = 8.dp) 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)
)
}
} }
} }
@Composable @Composable
fun UploadAvatar() { fun UploadAvatar(
pickImage: PickImage,
onImageSelected: (Uri) -> Unit,
imagePickerLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
remoteFilePickerLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
cameraLauncher: ManagedActivityResultLauncher<Intent, ActivityResult>,
onDeleteImage: () -> Unit
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
IconButton(onClick = { IconButton(
}) { onClick = {
pickImage.takePicture(cameraLauncher)
}
) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_baseline_photo_camera_24), painter = painterResource(id = R.drawable.ic_baseline_photo_camera_24),
contentDescription = null, contentDescription = null,
@ -207,24 +279,28 @@ fun UploadAvatar() {
} }
IconButton(onClick = { IconButton(onClick = {
pickImage.selectLocal(imagePickerLauncher)
}) { }) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_folder_multiple_image), painter = painterResource(id = R.drawable.upload),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
IconButton(
IconButton(onClick = { onClick = {
}) { pickImage.selectRemote(remoteFilePickerLauncher)
Icon( }
painter = painterResource(id = R.drawable.baseline_tag_faces_24), ) {
Icon(
painter = painterResource(id = R.drawable.ic_mimetype_folder),
contentDescription = null, contentDescription = null,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
IconButton(onClick = { IconButton(onClick = {
onDeleteImage()
}) { }) {
Icon( Icon(
painter = painterResource(id = R.drawable.ic_delete_grey600_24dp), painter = painterResource(id = R.drawable.ic_delete_grey600_24dp),
@ -502,7 +578,11 @@ fun ShowPasswordDialog(onDismiss: () -> Unit, conversationCreationViewModel: Con
} }
@Composable @Composable
fun CreateConversation(conversationCreationViewModel: ConversationCreationViewModel, context: Context) { fun CreateConversation(
conversationCreationViewModel: ConversationCreationViewModel,
context: Context,
selectedImageUri: Uri?
) {
val selectedParticipants by conversationCreationViewModel.selectedParticipants.collectAsState() val selectedParticipants by conversationCreationViewModel.selectedParticipants.collectAsState()
Box( Box(
modifier = Modifier modifier = Modifier
@ -515,7 +595,8 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo
conversationCreationViewModel.createRoomAndAddParticipants( conversationCreationViewModel.createRoomAndAddParticipants(
roomType = CompanionClass.ROOM_TYPE_GROUP, roomType = CompanionClass.ROOM_TYPE_GROUP,
conversationName = conversationCreationViewModel.roomName.value, conversationName = conversationCreationViewModel.roomName.value,
participants = selectedParticipants.toSet() participants = selectedParticipants.toSet(),
selectedImageUri = selectedImageUri
) { roomToken -> ) { roomToken ->
val bundle = Bundle() val bundle = Bundle()
bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken)
@ -530,6 +611,7 @@ fun CreateConversation(conversationCreationViewModel: ConversationCreationViewMo
} }
} }
} }
class CompanionClass { class CompanionClass {
companion object { companion object {
internal val TAG = ConversationCreationActivity::class.simpleName internal val TAG = ConversationCreationActivity::class.simpleName

View File

@ -7,9 +7,11 @@
package com.nextcloud.talk.conversationcreation package com.nextcloud.talk.conversationcreation
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall
import java.io.File
interface ConversationCreationRepository { interface ConversationCreationRepository {
@ -21,4 +23,6 @@ interface ConversationCreationRepository {
suspend fun createRoom(roomType: String, conversationName: String?): RoomOverall suspend fun createRoom(roomType: String, conversationName: String?): RoomOverall
fun getImageUri(avatarId: String, requestBigSize: Boolean): String fun getImageUri(avatarId: String, requestBigSize: Boolean): String
suspend fun setPassword(roomToken: String, password: String): GenericOverall suspend fun setPassword(roomToken: String, password: String): GenericOverall
suspend fun uploadConversationAvatar(file: File, roomToken: String): ConversationModel
suspend fun deleteConversationAvatar(roomToken: String): ConversationModel
} }

View File

@ -10,6 +10,7 @@ package com.nextcloud.talk.conversationcreation
import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.api.NcApiCoroutines
import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.RetrofitBucket import com.nextcloud.talk.models.RetrofitBucket
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.participants.AddParticipantOverall import com.nextcloud.talk.models.json.participants.AddParticipantOverall
@ -17,6 +18,11 @@ import com.nextcloud.talk.users.UserManager
import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.ApiUtils.getRetrofitBucketForAddParticipant import com.nextcloud.talk.utils.ApiUtils.getRetrofitBucketForAddParticipant
import com.nextcloud.talk.utils.ApiUtils.getRetrofitBucketForAddParticipantWithSource import com.nextcloud.talk.utils.ApiUtils.getRetrofitBucketForAddParticipantWithSource
import com.nextcloud.talk.utils.Mimetype
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
class ConversationCreationRepositoryImpl( class ConversationCreationRepositoryImpl(
private val ncApiCoroutines: NcApiCoroutines, private val ncApiCoroutines: NcApiCoroutines,
@ -126,6 +132,33 @@ class ConversationCreationRepositoryImpl(
return result return result
} }
override suspend fun uploadConversationAvatar(file: File, roomToken: String): ConversationModel {
val builder = MultipartBody.Builder()
builder.setType(MultipartBody.FORM)
builder.addFormDataPart(
"file",
file.name,
file.asRequestBody(Mimetype.IMAGE_PREFIX_GENERIC.toMediaTypeOrNull())
)
val filePart: MultipartBody.Part = MultipartBody.Part.createFormData(
"file",
file.name,
file.asRequestBody(Mimetype.IMAGE_JPG.toMediaTypeOrNull())
)
val response = ncApiCoroutines.uploadConversationAvatar(
credentials!!,
ApiUtils.getUrlForConversationAvatar(1, _currentUser.baseUrl!!, roomToken),
filePart
)
return ConversationModel.mapToConversationModel(response.ocs?.data!!, _currentUser)
}
override suspend fun deleteConversationAvatar(roomToken: String): ConversationModel {
val url = ApiUtils.getUrlForConversationAvatar(1, _currentUser.baseUrl!!, roomToken)
val response = ncApiCoroutines.deleteConversationAvatar(credentials!!, url)
return ConversationModel.mapToConversationModel(response.ocs?.data!!, _currentUser)
}
override suspend fun allowGuests(token: String, allow: Boolean): GenericOverall { override suspend fun allowGuests(token: String, allow: Boolean): GenericOverall {
val url = ApiUtils.getUrlForRoomPublic( val url = ApiUtils.getUrlForRoomPublic(
apiVersion, apiVersion,

View File

@ -7,26 +7,34 @@
package com.nextcloud.talk.conversationcreation package com.nextcloud.talk.conversationcreation
import android.net.Uri
import android.util.Log import android.util.Log
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.net.toFile
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser import com.nextcloud.talk.models.json.autocomplete.AutocompleteUser
import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.Conversation
import com.nextcloud.talk.models.json.generic.GenericMeta import com.nextcloud.talk.models.json.generic.GenericMeta
import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl.Companion.STATUS_CODE_OK import com.nextcloud.talk.repositories.conversations.ConversationsRepositoryImpl.Companion.STATUS_CODE_OK
import com.nextcloud.talk.users.UserManager
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class ConversationCreationViewModel @Inject constructor( class ConversationCreationViewModel @Inject constructor(
private val repository: ConversationCreationRepository private val repository: ConversationCreationRepository,
private val userManager: UserManager
) : ViewModel() { ) : ViewModel() {
private val _selectedParticipants = MutableStateFlow<List<AutocompleteUser>>(emptyList()) private val _selectedParticipants = MutableStateFlow<List<AutocompleteUser>>(emptyList())
val selectedParticipants: StateFlow<List<AutocompleteUser>> = _selectedParticipants val selectedParticipants: StateFlow<List<AutocompleteUser>> = _selectedParticipants
private val roomViewState = MutableStateFlow<RoomUIState>(RoomUIState.None) private val roomViewState = MutableStateFlow<RoomUIState>(RoomUIState.None)
private val _currentUser = userManager.currentUser.blockingGet()
val currentUser: User = _currentUser
fun updateSelectedParticipants(participants: List<AutocompleteUser>) { fun updateSelectedParticipants(participants: List<AutocompleteUser>) {
_selectedParticipants.value = participants _selectedParticipants.value = participants
} }
@ -58,6 +66,7 @@ class ConversationCreationViewModel @Inject constructor(
roomType: String, roomType: String,
conversationName: String, conversationName: String,
participants: Set<AutocompleteUser>, participants: Set<AutocompleteUser>,
selectedImageUri: Uri?,
onRoomCreated: (String) -> Unit onRoomCreated: (String) -> Unit
) { ) {
val scope = when { val scope = when {
@ -100,6 +109,9 @@ class ConversationCreationViewModel @Inject constructor(
repository.setPassword(token, _password.value) repository.setPassword(token, _password.value)
} }
repository.openConversation(token, scope) repository.openConversation(token, scope)
if (selectedImageUri != null) {
repository.uploadConversationAvatar(selectedImageUri.toFile(), token)
}
onRoomCreated(token) onRoomCreated(token)
} catch (exception: Exception) { } catch (exception: Exception) {
allowGuestsResult.value = AllowGuestsUiState.Error(exception.message ?: "") allowGuestsResult.value = AllowGuestsUiState.Error(exception.message ?: "")