Bläddra i källkod

Merge pull request #4196 from nextcloud/user_avatar_selection

User avatar selection
Marcel Hibbe 8 månader sedan
förälder
incheckning
946aa60409

+ 1 - 1
app/build.gradle

@@ -15,7 +15,7 @@ import com.github.spotbugs.snom.SpotBugsTask
 plugins {
     id "org.jetbrains.kotlin.plugin.compose" version "2.0.20"
     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'

+ 14 - 0
app/src/main/java/com/nextcloud/talk/api/NcApiCoroutines.kt

@@ -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.generic.GenericOverall
 import com.nextcloud.talk.models.json.participants.AddParticipantOverall
+import okhttp3.MultipartBody
 import retrofit2.http.DELETE
 import retrofit2.http.Field
 import retrofit2.http.FormUrlEncoded
 import retrofit2.http.GET
 import retrofit2.http.Header
+import retrofit2.http.Multipart
 import retrofit2.http.POST
 import retrofit2.http.PUT
+import retrofit2.http.Part
 import retrofit2.http.Query
 import retrofit2.http.QueryMap
 import retrofit2.http.Url
@@ -96,4 +99,15 @@ interface NcApiCoroutines {
         @Url url: String?,
         @Field("password") password: String?
     ): 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
 }

+ 109 - 27
app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationActivity.kt

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

+ 4 - 0
app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepository.kt

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

+ 33 - 0
app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationRepositoryImpl.kt

@@ -10,6 +10,7 @@ package com.nextcloud.talk.conversationcreation
 import com.nextcloud.talk.api.NcApiCoroutines
 import com.nextcloud.talk.data.user.model.User
 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.generic.GenericOverall
 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.getRetrofitBucketForAddParticipant
 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(
     private val ncApiCoroutines: NcApiCoroutines,
@@ -126,6 +132,33 @@ class ConversationCreationRepositoryImpl(
         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 {
         val url = ApiUtils.getUrlForRoomPublic(
             apiVersion,

+ 13 - 1
app/src/main/java/com/nextcloud/talk/conversationcreation/ConversationCreationViewModel.kt

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