Added contact image picker + favourite toggle

This commit is contained in:
Sylvain Berfini 2023-08-18 10:28:56 +02:00
parent f4a53bee61
commit 4a98610b67
10 changed files with 233 additions and 16 deletions

View file

@ -53,16 +53,16 @@ dependencies {
implementation 'androidx.core:core-ktx:1.10.1'
implementation 'androidx.appcompat:appcompat:1.7.0-alpha03'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-ui-ktx:2.6.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.7.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0"
def nav_version = "2.6.0"
def nav_version = "2.7.0"
implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
def emoji_version = "1.4.0-rc01"
def emoji_version = "1.4.0"
implementation "androidx.emoji2:emoji2:$emoji_version"
implementation "androidx.emoji2:emoji2-emojipicker:$emoji_version"

View file

@ -23,18 +23,23 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import androidx.navigation.navGraphViewModels
import kotlinx.coroutines.launch
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.ContactNewOrEditFragmentBinding
import org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel
import org.linphone.ui.main.fragment.GenericFragment
import org.linphone.utils.FileUtils
class EditContactFragment : GenericFragment() {
companion object {
const val TAG = "[Contact Edit Fragment]"
const val TAG = "[Edit Contact Fragment]"
}
private lateinit var binding: ContactNewOrEditFragmentBinding
@ -45,6 +50,25 @@ class EditContactFragment : GenericFragment() {
private val args: EditContactFragmentArgs by navArgs()
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
Log.i("$TAG Picture picked [$uri]")
// TODO FIXME: use a better file name
val localFileName = FileUtils.getFileStoragePath("temp", true)
lifecycleScope.launch {
if (FileUtils.copyFile(uri, localFileName)) {
viewModel.picturePath.postValue(localFileName.absolutePath)
} else {
Log.e(
"$TAG Failed to copy file from [$uri] to [${localFileName.absolutePath}]"
)
}
}
} else {
Log.w("$TAG No picture picked")
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -74,6 +98,10 @@ class EditContactFragment : GenericFragment() {
goBack()
}
binding.setPickImageClickListener {
pickImage()
}
viewModel.saveChangesEvent.observe(viewLifecycleOwner) {
it.consume { refKey ->
if (refKey.isNotEmpty()) {
@ -91,4 +119,8 @@ class EditContactFragment : GenericFragment() {
}
}
}
private fun pickImage() {
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}

View file

@ -23,21 +23,50 @@ import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.navGraphViewModels
import kotlinx.coroutines.launch
import org.linphone.R
import org.linphone.core.tools.Log
import org.linphone.databinding.ContactNewOrEditFragmentBinding
import org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel
import org.linphone.ui.main.fragment.GenericFragment
import org.linphone.utils.Event
import org.linphone.utils.FileUtils
class NewContactFragment : GenericFragment() {
companion object {
const val TAG = "[New Contact Fragment]"
}
private lateinit var binding: ContactNewOrEditFragmentBinding
private val viewModel: ContactNewOrEditViewModel by navGraphViewModels(
R.id.newContactFragment
)
private val pickMedia = registerForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
Log.i("$TAG Picture picked [$uri]")
// TODO FIXME: use a better file name
val localFileName = FileUtils.getFileStoragePath("temp", true)
lifecycleScope.launch {
if (FileUtils.copyFile(uri, localFileName)) {
viewModel.picturePath.postValue(localFileName.absolutePath)
} else {
Log.e(
"$TAG Failed to copy file from [$uri] to [${localFileName.absolutePath}]"
)
}
}
} else {
Log.w("$TAG No picture picked")
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@ -63,6 +92,10 @@ class NewContactFragment : GenericFragment() {
goBack()
}
binding.setPickImageClickListener {
pickImage()
}
viewModel.saveChangesEvent.observe(viewLifecycleOwner) {
it.consume { refKey ->
if (refKey.isNotEmpty()) {
@ -74,4 +107,8 @@ class NewContactFragment : GenericFragment() {
}
}
}
private fun pickImage() {
pickMedia.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
}

View file

@ -44,9 +44,7 @@ class ContactAvatarModel(val friend: Friend) {
val name = MutableLiveData<String>()
val firstLetter: String by lazy {
LinphoneUtils.getFirstLetter(friend.name.orEmpty())
}
val firstLetter: String = LinphoneUtils.getFirstLetter(friend.name.orEmpty())
val showFirstLetter = MutableLiveData<Boolean>()

View file

@ -37,6 +37,8 @@ class ContactNewOrEditViewModel() : ViewModel() {
val isEdit = MutableLiveData<Boolean>()
val picturePath = MutableLiveData<String>()
val firstName = MutableLiveData<String>()
val lastName = MutableLiveData<String>()
@ -79,6 +81,8 @@ class ContactNewOrEditViewModel() : ViewModel() {
// TODO ?
}
picturePath.postValue(friend.photo)
for (address in friend.addresses) {
addresses.add(
NewOrEditNumberOrAddressModel(address.asStringUriOnly(), true, { }, { model ->
@ -141,6 +145,12 @@ class ContactNewOrEditViewModel() : ViewModel() {
if (vCard != null) {
vCard.familyName = lastName.value
vCard.givenName = firstName.value
// TODO FIXME : doesn't work for newly created contact
val picture = picturePath.value.orEmpty()
if (picture.isNotEmpty()) {
friend.photo = picture
}
}
friend.organization = company.value.orEmpty()

View file

@ -23,6 +23,7 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Address
import org.linphone.core.Friend
import org.linphone.ui.main.contacts.model.ContactAvatarModel
import org.linphone.ui.main.contacts.model.ContactDeviceModel
import org.linphone.ui.main.contacts.model.ContactNumberOrAddressClickListener
@ -40,6 +41,8 @@ class ContactViewModel : ViewModel() {
val title = MutableLiveData<String>()
val isFavourite = MutableLiveData<Boolean>()
val showBackButton = MutableLiveData<Boolean>()
val showNumbersAndAddresses = MutableLiveData<Boolean>()
@ -64,7 +67,7 @@ class ContactViewModel : ViewModel() {
MutableLiveData<Event<String>>()
}
val listener = object : ContactNumberOrAddressClickListener {
private val listener = object : ContactNumberOrAddressClickListener {
override fun onClicked(address: Address?) {
// UI thread
if (address != null) {
@ -80,6 +83,8 @@ class ContactViewModel : ViewModel() {
}
}
private lateinit var friend: Friend
init {
showNumbersAndAddresses.value = true
showDevicesTrust.value = false // TODO FIXME: set it to true when it will work for real
@ -90,6 +95,9 @@ class ContactViewModel : ViewModel() {
coreContext.postOnCoreThread { core ->
val friend = coreContext.contactsManager.findContactById(refKey)
if (friend != null) {
this.friend = friend
isFavourite.postValue(friend.starred)
contact.postValue(ContactAvatarModel(friend))
val organization = friend.organization
@ -111,6 +119,8 @@ class ContactViewModel : ViewModel() {
)
addressesAndNumbers.add(data)
}
val indexOfLastSipAddress = addressesAndNumbers.count()
for (number in friend.phoneNumbersWithLabel) {
val presenceModel = friend.getPresenceModelForUriOrTel(number.phoneNumber)
if (presenceModel != null && !presenceModel.contact.isNullOrEmpty()) {
@ -120,15 +130,16 @@ class ContactViewModel : ViewModel() {
it.displayedValue == contact
}
if (!contact.isNullOrEmpty() && found == null) {
val address = core.interpretUrl(contact, true)
val address = core.interpretUrl(contact, false)
if (address != null) {
address.clean() // To remove ;user=phone
val data = ContactNumberOrAddressModel(
address,
contact,
address.asStringUriOnly(),
listener,
true
)
addressesAndNumbers.add(data)
addressesAndNumbers.add(indexOfLastSipAddress, data)
}
}
}
@ -179,6 +190,17 @@ class ContactViewModel : ViewModel() {
}
}
fun toggleFavourite() {
// UI thread
coreContext.postOnCoreThread {
friend.edit()
friend.starred = !friend.starred
friend.done()
isFavourite.postValue(friend.starred)
coreContext.contactsManager.notifyContactsListChanged()
}
}
fun startAudioCall() {
// UI thread
val numbersAndAddresses = sipAddressesAndPhoneNumbers.value.orEmpty()

View file

@ -132,6 +132,16 @@ fun AppCompatTextView.setDrawableTint(color: Int) {
}
}
@BindingAdapter("coil")
fun loadPictureWithCoil(imageView: ImageView, file: String?) {
// UI thread
if (file != null) {
imageView.load(file) {
transformations(CircleCropTransformation())
}
}
}
@BindingAdapter("coilContact")
fun loadContactPictureWithCoil2(imageView: ImageView, contact: ContactData?) {
// UI thread
@ -158,7 +168,7 @@ fun ImageView.setPresenceIcon(presence: ConsolidatedPresence?) {
}
@BindingAdapter("contactAvatar")
fun AvatarView.loadContactPicture(contact: ContactAvatarModel?) {
fun AvatarView.loadContactAvatar(contact: ContactAvatarModel?) {
// UI thread
if (contact == null) {
loadImage(R.drawable.contact_avatar)

View file

@ -0,0 +1,93 @@
/*
* Copyright (c) 2010-2023 Belledonne Communications SARL.
*
* This file is part of linphone-android
* (see https://www.linphone.org).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.linphone.utils
import android.net.Uri
import android.os.Environment
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.tools.Log
class FileUtils {
companion object {
const val TAG = "[File Utils]"
fun getFileStoragePath(fileName: String, isImage: Boolean = false): File {
val path = getFileStorageDir(isImage)
var file = File(path, fileName)
var prefix = 1
while (file.exists()) {
file = File(path, prefix.toString() + "_" + fileName)
Log.w("$TAG File with that name already exists, renamed to ${file.name}")
prefix += 1
}
return file
}
suspend fun copyFile(from: Uri, to: File): Boolean {
try {
withContext(Dispatchers.IO) {
FileOutputStream(to).use { outputStream ->
val inputStream = FileInputStream(
coreContext.context.contentResolver.openFileDescriptor(from, "r")?.fileDescriptor
)
val buffer = ByteArray(4096)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } >= 0) {
outputStream.write(buffer, 0, bytesRead)
}
}
}
return true
} catch (e: IOException) {
Log.e("$TAG copyFile [$from] to [$to] exception: $e")
}
return false
}
private fun getFileStorageDir(isPicture: Boolean = false): File {
var path: File? = null
if (Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED) {
Log.w("$TAG External storage is mounted")
var directory = Environment.DIRECTORY_DOWNLOADS
if (isPicture) {
Log.w("$TAG Using pictures directory instead of downloads")
directory = Environment.DIRECTORY_PICTURES
}
path = coreContext.context.getExternalFilesDir(directory)
}
val returnPath: File = path ?: coreContext.context.filesDir
if (path == null) {
Log.w(
"$TAG Couldn't get external storage path, using internal"
)
}
return returnPath
}
}
}

View file

@ -484,14 +484,16 @@
app:layout_constraintTop_toBottomOf="@+id/action_edit"/>
<androidx.appcompat.widget.AppCompatTextView
android:onClick="@{() -> viewModel.toggleFavourite()}"
style="@style/context_menu_action_label_style"
android:id="@+id/action_favorite"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="17dp"
android:layout_marginEnd="17dp"
android:text="Add to favourites"
android:text="@{viewModel.isFavourite() ? `Remove from favourites` : `Add to favourites`, default=`Add to favourites`}"
android:drawableStart="@drawable/favorite"
android:drawableTint="@{viewModel.isFavourite() ? @color/red_danger : @color/gray_1, default=@color/gray_1}"
app:layout_constraintTop_toBottomOf="@id/action_edit"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>

View file

@ -8,6 +8,9 @@
<variable
name="cancelClickListener"
type="View.OnClickListener" />
<variable
name="pickImageClickListener"
type="View.OnClickListener" />
<variable
name="viewModel"
type="org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel" />
@ -76,18 +79,28 @@
android:layout_height="wrap_content">
<ImageView
android:onClick="@{pickImageClickListener}"
android:id="@+id/avatar"
android:layout_width="@dimen/avatar_big_size"
android:layout_height="@dimen/avatar_big_size"
android:layout_marginTop="8dp"
android:adjustViewBounds="true"
android:padding="20dp"
android:src="@drawable/pick_picture"
android:background="@drawable/shape_button_round"
coil="@{viewModel.picturePath}"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/overlay"
android:layout_width="0dp"
android:layout_height="0dp"
android:padding="20dp"
android:src="@drawable/pick_picture"
app:layout_constraintEnd_toEndOf="@id/avatar"
app:layout_constraintStart_toStartOf="@id/avatar"
app:layout_constraintTop_toTopOf="@id/avatar"
app:layout_constraintBottom_toBottomOf="@id/avatar" />
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style"
android:id="@+id/add_picture_label"