mirror of
https://gitlab.linphone.org/BC/public/linphone-android.git
synced 2026-01-17 11:28:06 +00:00
Added contact image picker + favourite toggle
This commit is contained in:
parent
f4a53bee61
commit
4a98610b67
10 changed files with 233 additions and 16 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
93
app/src/main/java/org/linphone/utils/FileUtils.kt
Normal file
93
app/src/main/java/org/linphone/utils/FileUtils.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue