Started to allow for SIP address & phone number in new/edit contact form

This commit is contained in:
Sylvain Berfini 2023-08-17 17:38:47 +02:00
parent cb1774a678
commit f4a53bee61
13 changed files with 337 additions and 49 deletions

View file

@ -88,10 +88,14 @@ class ContactsManager {
// UI thread // UI thread
coreContext.postOnCoreThread { coreContext.postOnCoreThread {
updateLocalContacts() updateLocalContacts()
notifyContactsListChanged()
}
}
for (listener in listeners) { fun notifyContactsListChanged() {
listener.onContactsLoaded() // Core thread
} for (listener in listeners) {
listener.onContactsLoaded()
} }
} }

View file

@ -75,13 +75,12 @@ class EditContactFragment : GenericFragment() {
} }
viewModel.saveChangesEvent.observe(viewLifecycleOwner) { viewModel.saveChangesEvent.observe(viewLifecycleOwner) {
it.consume { ok -> it.consume { refKey ->
if (ok) { if (refKey.isNotEmpty()) {
Log.i("$TAG Changes were applied, going back to details page") Log.i("$TAG Changes were applied, going back to details page")
goBack() goBack()
} else { } else {
Log.e("$TAG Changes couldn't be applied!") // TODO : show error
// TODO FIXME : show error
} }
} }
} }

View file

@ -29,6 +29,7 @@ import org.linphone.R
import org.linphone.databinding.ContactNewOrEditFragmentBinding import org.linphone.databinding.ContactNewOrEditFragmentBinding
import org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel import org.linphone.ui.main.contacts.viewmodel.ContactNewOrEditViewModel
import org.linphone.ui.main.fragment.GenericFragment import org.linphone.ui.main.fragment.GenericFragment
import org.linphone.utils.Event
class NewContactFragment : GenericFragment() { class NewContactFragment : GenericFragment() {
private lateinit var binding: ContactNewOrEditFragmentBinding private lateinit var binding: ContactNewOrEditFragmentBinding
@ -56,14 +57,17 @@ class NewContactFragment : GenericFragment() {
binding.lifecycleOwner = viewLifecycleOwner binding.lifecycleOwner = viewLifecycleOwner
binding.viewModel = viewModel binding.viewModel = viewModel
viewModel.findFriendByRefKey("")
binding.setCancelClickListener { binding.setCancelClickListener {
goBack() goBack()
} }
viewModel.saveChangesEvent.observe(viewLifecycleOwner) { viewModel.saveChangesEvent.observe(viewLifecycleOwner) {
it.consume { ok -> it.consume { refKey ->
if (ok) { if (refKey.isNotEmpty()) {
goBack() // TODO FIXME : go to contact detail view goBack()
sharedViewModel.showContactEvent.value = Event(refKey)
} else { } else {
// TODO : show error // TODO : show error
} }

View file

@ -83,14 +83,18 @@ class ContactAvatarModel(val friend: Friend) {
val refKey = friend.refKey val refKey = friend.refKey
if (refKey != null) { if (refKey != null) {
val lookupUri = ContentUris.withAppendedId( try {
ContactsContract.Contacts.CONTENT_URI, val lookupUri = ContentUris.withAppendedId(
refKey.toLong() ContactsContract.Contacts.CONTENT_URI,
) refKey.toLong()
return Uri.withAppendedPath( )
lookupUri, return Uri.withAppendedPath(
ContactsContract.Contacts.Photo.CONTENT_DIRECTORY lookupUri,
) ContactsContract.Contacts.Photo.CONTENT_DIRECTORY
)
} catch (numberFormatException: NumberFormatException) {
// Expected for contacts created by Linphone
}
} }
return null return null

View file

@ -0,0 +1,52 @@
/*
* 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.ui.main.contacts.model
import androidx.lifecycle.MutableLiveData
class NewOrEditNumberOrAddressModel(
defaultValue: String,
val isSip: Boolean,
private val onValueNoLongerEmpty: (() -> Unit)? = null,
private val onRemove: ((model: NewOrEditNumberOrAddressModel) -> Unit)? = null
) {
val value = MutableLiveData<String>()
val showRemoveButton = MutableLiveData<Boolean>()
init {
// Core thread
value.postValue(defaultValue)
showRemoveButton.postValue(defaultValue.isNotEmpty())
}
fun onValueChanged(newValue: String) {
// UI thread
if (newValue.isNotEmpty() && showRemoveButton.value == false) {
onValueNoLongerEmpty?.invoke()
showRemoveButton.value = true
}
}
fun remove() {
// Core thread
onRemove?.invoke(this)
}
}

View file

@ -25,6 +25,7 @@ import org.linphone.LinphoneApplication.Companion.coreContext
import org.linphone.core.Friend import org.linphone.core.Friend
import org.linphone.core.FriendList.Status import org.linphone.core.FriendList.Status
import org.linphone.core.tools.Log import org.linphone.core.tools.Log
import org.linphone.ui.main.contacts.model.NewOrEditNumberOrAddressModel
import org.linphone.utils.Event import org.linphone.utils.Event
class ContactNewOrEditViewModel() : ViewModel() { class ContactNewOrEditViewModel() : ViewModel() {
@ -40,12 +41,16 @@ class ContactNewOrEditViewModel() : ViewModel() {
val lastName = MutableLiveData<String>() val lastName = MutableLiveData<String>()
val sipAddresses = MutableLiveData<ArrayList<NewOrEditNumberOrAddressModel>>()
val phoneNumbers = MutableLiveData<ArrayList<NewOrEditNumberOrAddressModel>>()
val company = MutableLiveData<String>() val company = MutableLiveData<String>()
val jobTitle = MutableLiveData<String>() val jobTitle = MutableLiveData<String>()
val saveChangesEvent: MutableLiveData<Event<Boolean>> by lazy { val saveChangesEvent: MutableLiveData<Event<String>> by lazy {
MutableLiveData<Event<Boolean>>() MutableLiveData<Event<String>>()
} }
val friendFoundEvent = MutableLiveData<Event<Boolean>>() val friendFoundEvent = MutableLiveData<Event<Boolean>>()
@ -61,6 +66,9 @@ class ContactNewOrEditViewModel() : ViewModel() {
val exists = !friend.refKey.isNullOrEmpty() val exists = !friend.refKey.isNullOrEmpty()
isEdit.postValue(exists) isEdit.postValue(exists)
val addresses = arrayListOf<NewOrEditNumberOrAddressModel>()
val numbers = arrayListOf<NewOrEditNumberOrAddressModel>()
if (exists) { if (exists) {
Log.i("$TAG Found friend [$friend] using ref key [$refKey]") Log.i("$TAG Found friend [$friend] using ref key [$refKey]")
val vCard = friend.vcard val vCard = friend.vcard
@ -68,17 +76,49 @@ class ContactNewOrEditViewModel() : ViewModel() {
firstName.postValue(vCard.givenName) firstName.postValue(vCard.givenName)
lastName.postValue(vCard.familyName) lastName.postValue(vCard.familyName)
} else { } else {
// TODO // TODO ?
}
for (address in friend.addresses) {
addresses.add(
NewOrEditNumberOrAddressModel(address.asStringUriOnly(), true, { }, { model ->
removeModel(model)
})
)
}
for (number in friend.phoneNumbers) {
numbers.add(
NewOrEditNumberOrAddressModel(number, false, { }, { model ->
removeModel(model)
})
)
} }
company.postValue(friend.organization) company.postValue(friend.organization)
jobTitle.postValue(friend.jobTitle) jobTitle.postValue(friend.jobTitle)
friendFoundEvent.postValue(Event(true)) friendFoundEvent.postValue(Event(true))
} else { } else if (refKey.orEmpty().isNotEmpty()) {
Log.e("$TAG No friend found using ref key [$refKey]") Log.e("$TAG No friend found using ref key [$refKey]")
// TODO : generate unique ref key
} }
addresses.add(
NewOrEditNumberOrAddressModel("", true, {
addNewModel(true)
}, { model ->
removeModel(model)
})
)
numbers.add(
NewOrEditNumberOrAddressModel("", false, {
addNewModel(false)
}, { model ->
removeModel(model)
})
)
sipAddresses.postValue(addresses)
phoneNumbers.postValue(numbers)
} }
} }
@ -87,26 +127,107 @@ class ContactNewOrEditViewModel() : ViewModel() {
coreContext.postOnCoreThread { core -> coreContext.postOnCoreThread { core ->
var status = Status.OK var status = Status.OK
if (::friend.isInitialized) { if (!::friend.isInitialized) {
friend.name = "${firstName.value.orEmpty()} ${lastName.value.orEmpty()}" friend = core.createFriend()
val vCard = friend.vcard
if (vCard != null) {
vCard.familyName = lastName.value
vCard.givenName = firstName.value
}
friend.organization = company.value.orEmpty()
friend.jobTitle = jobTitle.value.orEmpty()
if (isEdit.value == false) {
status = core.defaultFriendList?.addFriend(friend) ?: Status.InvalidFriend
}
} else {
status = Status.NonExistentFriend
} }
saveChangesEvent.postValue(Event(status == Status.OK)) if (isEdit.value == true) {
friend.edit()
}
friend.name = "${firstName.value.orEmpty()} ${lastName.value.orEmpty()}"
val vCard = friend.vcard
if (vCard != null) {
vCard.familyName = lastName.value
vCard.givenName = firstName.value
}
friend.organization = company.value.orEmpty()
friend.jobTitle = jobTitle.value.orEmpty()
for (address in friend.addresses) {
friend.removeAddress(address)
}
for (address in sipAddresses.value.orEmpty()) {
val data = address.value.value
if (!data.isNullOrEmpty()) {
val parsedAddress = core.interpretUrl(data, true)
if (parsedAddress != null) {
friend.addAddress(parsedAddress)
}
}
}
for (number in friend.phoneNumbers) {
friend.removePhoneNumber(number)
}
for (number in phoneNumbers.value.orEmpty()) {
val data = number.value.value
if (!data.isNullOrEmpty()) {
friend.addPhoneNumber(data)
}
}
if (isEdit.value == false) {
if (friend.vcard?.generateUniqueId() == true) {
friend.refKey = friend.vcard?.uid
Log.i(
"$TAG Newly created friend will have generated ref key [${friend.refKey}]"
)
} else {
Log.e("$TAG Failed to generate a ref key using vCard's generateUniqueId()")
// TODO : generate unique ref key
}
status = core.defaultFriendList?.addFriend(friend) ?: Status.InvalidFriend
} else {
friend.done()
}
coreContext.contactsManager.notifyContactsListChanged()
saveChangesEvent.postValue(
Event(if (status == Status.OK) friend.refKey.orEmpty() else "")
)
}
}
private fun addNewModel(isSip: Boolean) {
// UI thread
// TODO FIXME: causes focus issues
val list = arrayListOf<NewOrEditNumberOrAddressModel>()
val source = if (isSip) sipAddresses.value.orEmpty() else phoneNumbers.value.orEmpty()
list.addAll(source)
list.add(
NewOrEditNumberOrAddressModel("", isSip, {
addNewModel(isSip)
}, { model ->
removeModel(model)
})
)
if (isSip) {
sipAddresses.value = list
} else {
phoneNumbers.value = list
}
}
private fun removeModel(model: NewOrEditNumberOrAddressModel) {
// UI thread
val list = arrayListOf<NewOrEditNumberOrAddressModel>()
val source = if (model.isSip) sipAddresses.value.orEmpty() else phoneNumbers.value.orEmpty()
for (item in source) {
if (item != model) {
list.add(item)
}
}
if (model.isSip) {
sipAddresses.value = list
} else {
phoneNumbers.value = list
} }
} }
} }

View file

@ -20,12 +20,15 @@
package org.linphone.utils package org.linphone.utils
import android.content.Context import android.content.Context
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.Window import android.view.Window
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.ImageView import android.widget.ImageView
import androidx.appcompat.widget.AppCompatEditText
import androidx.appcompat.widget.AppCompatTextView import androidx.appcompat.widget.AppCompatTextView
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@ -174,3 +177,16 @@ fun AvatarView.loadContactPicture(contact: ContactAvatarModel?) {
) )
} }
} }
@BindingAdapter("onValueChanged")
fun AppCompatEditText.editTextSetting(lambda: () -> Unit) {
addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
lambda()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
}

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle"> <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="63dp" /> <corners android:radius="63dp" />
<solid android:color="@color/gray_7"/> <solid android:color="@color/white"/>
<stroke android:width="1dp" android:color="@color/gray_6" /> <stroke android:width="1dp" android:color="@color/gray_6" />
</shape> </shape>

View file

@ -110,7 +110,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:layout_marginTop="8dp" android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_nav_bar" app:layout_constraintStart_toEndOf="@id/bottom_nav_bar"
app:layout_constraintTop_toBottomOf="@id/favourites_label" /> app:layout_constraintTop_toBottomOf="@id/favourites_label" />

View file

@ -20,10 +20,9 @@
<androidx.constraintlayout.widget.ConstraintLayout <androidx.constraintlayout.widget.ConstraintLayout
android:onClick="@{onClickListener}" android:onClick="@{onClickListener}"
android:onLongClick="@{onLongClickListener}" android:onLongClick="@{onLongClickListener}"
android:layout_width="65dp" android:layout_width="75dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:padding="5dp"
android:layout_marginEnd="4dp"
android:background="@drawable/cell_background"> android:background="@drawable/cell_background">
<io.getstream.avatarview.AvatarView <io.getstream.avatarview.AvatarView

View file

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<import type="android.graphics.Typeface" />
<variable
name="model"
type="org.linphone.ui.main.contacts.model.NewOrEditNumberOrAddressModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_700"
android:id="@+id/label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:text="@{model.isSip ? `SIP address` : `Phone number`, default=`SIP address`}"
android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
<androidx.appcompat.widget.AppCompatEditText
onValueChanged="@{() -> model.onValueChanged(field.getText().toString())}"
style="@style/default_text_style"
android:id="@+id/field"
android:layout_width="0dp"
android:layout_height="50dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="5dp"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="@={model.value}"
android:textSize="14sp"
android:textColor="@color/gray_9"
android:background="@drawable/shape_edit_text_background"
app:layout_constraintTop_toBottomOf="@id/label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/remove"/>
<ImageView
android:onClick="@{() -> model.remove()}"
android:id="@+id/remove"
android:visibility="@{model.showRemoveButton ? View.VISIBLE : View.INVISIBLE, default=invisible}"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp"
android:src="@drawable/close"
app:tint="@color/gray_8"
app:layout_constraintStart_toEndOf="@id/field"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/field"
app:layout_constraintBottom_toBottomOf="@id/field"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

View file

@ -164,7 +164,29 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/> app:layout_constraintEnd_toEndOf="parent"/>
<!-- TODO SIP address & phone numbers --> <LinearLayout
android:id="@+id/sip_addresses"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/last_name"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
entries="@{viewModel.sipAddresses}"
layout="@{@layout/contact_new_or_edit_cell}"/>
<LinearLayout
android:id="@+id/phone_numbers"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="40dp"
android:orientation="vertical"
app:layout_constraintTop_toBottomOf="@id/sip_addresses"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
entries="@{viewModel.phoneNumbers}"
layout="@{@layout/contact_new_or_edit_cell}"/>
<androidx.appcompat.widget.AppCompatTextView <androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_700" style="@style/default_text_style_700"
@ -178,7 +200,7 @@
android:textSize="13sp" android:textSize="13sp"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/last_name"/> app:layout_constraintTop_toBottomOf="@id/phone_numbers"/>
<androidx.appcompat.widget.AppCompatEditText <androidx.appcompat.widget.AppCompatEditText
style="@style/default_text_style" style="@style/default_text_style"

View file

@ -101,7 +101,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:layout_marginTop="8dp" android:layout_marginTop="4dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/favourites_label" /> app:layout_constraintTop_toBottomOf="@id/favourites_label" />