diff --git a/app/src/main/java/org/linphone/contacts/ContactSelectionData.kt b/app/src/main/java/org/linphone/contacts/ContactSelectionData.kt new file mode 100644 index 000000000..36cca5069 --- /dev/null +++ b/app/src/main/java/org/linphone/contacts/ContactSelectionData.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2010-2020 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 . + */ +package org.linphone.contacts + +import androidx.lifecycle.MutableLiveData +import org.linphone.core.* + +class ContactSelectionData(searchResult: SearchResult) { + val name = MutableLiveData() + + init { + name.value = searchResult.friend?.name ?: searchResult.toString() + } +} diff --git a/app/src/main/java/org/linphone/contacts/ContactsSelectionAdapter.kt b/app/src/main/java/org/linphone/contacts/ContactsSelectionAdapter.kt new file mode 100644 index 000000000..ed6055352 --- /dev/null +++ b/app/src/main/java/org/linphone/contacts/ContactsSelectionAdapter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2010-2020 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 . + */ +package org.linphone.contacts + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.LifecycleOwner +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.linphone.R +import org.linphone.core.SearchResult +import org.linphone.databinding.ContactSelectionCellBinding + +class ContactsSelectionAdapter( + private val viewLifecycleOwner: LifecycleOwner +) : ListAdapter(SearchResultDiffCallback()) { + init { + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding: ContactSelectionCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.contact_selection_cell, + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as ViewHolder).bind(getItem(position)) + } + + inner class ViewHolder( + private val binding: ContactSelectionCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(searchResult: SearchResult) { + with(binding) { + val searchResultViewModel = ContactSelectionData(searchResult) + data = searchResultViewModel + + lifecycleOwner = viewLifecycleOwner + + executePendingBindings() + } + } + } +} + +private class SearchResultDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame( + oldItem: SearchResult, + newItem: SearchResult + ): Boolean { + val oldAddress = oldItem.address + val newAddress = newItem.address + return if (oldAddress != null && newAddress != null) oldAddress.weakEqual(newAddress) else false + } + + override fun areContentsTheSame( + oldItem: SearchResult, + newItem: SearchResult + ): Boolean { + return newItem.friend != null + } +} diff --git a/app/src/main/java/org/linphone/ui/MainActivity.kt b/app/src/main/java/org/linphone/ui/MainActivity.kt index 59967e4b7..aafa2c423 100644 --- a/app/src/main/java/org/linphone/ui/MainActivity.kt +++ b/app/src/main/java/org/linphone/ui/MainActivity.kt @@ -24,11 +24,11 @@ import android.content.pm.PackageManager import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.databinding.DataBindingUtil import androidx.lifecycle.ViewModelProvider import androidx.loader.app.LoaderManager -import androidx.navigation.NavController import androidx.navigation.findNavController import androidx.navigation.ui.setupWithNavController import com.google.android.material.navigation.NavigationBarView @@ -45,15 +45,15 @@ class MainActivity : AppCompatActivity() { private lateinit var binding: ActivityMainBinding private lateinit var viewModel: MainViewModel - private val onNavDestinationChangedListener = - NavController.OnDestinationChangedListener { _, destination, _ -> - binding.mainNavView?.visibility = View.VISIBLE - } - override fun onCreate(savedInstanceState: Bundle?) { WindowCompat.setDecorFitsSystemWindows(window, true) super.onCreate(savedInstanceState) + window.statusBarColor = ContextCompat.getColor( + this, + R.color.primary_color + ) + if (checkSelfPermission(Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED) { val manager = LoaderManager.getInstance(this) manager.restartLoader(0, null, ContactLoader()) @@ -84,9 +84,6 @@ class MainActivity : AppCompatActivity() { override fun onPostCreate(savedInstanceState: Bundle?) { super.onPostCreate(savedInstanceState) - binding.mainNavHostFragment.findNavController() - .addOnDestinationChangedListener(onNavDestinationChangedListener) - getNavBar()?.setupWithNavController(binding.mainNavHostFragment.findNavController()) if (checkSelfPermission(Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { @@ -113,4 +110,12 @@ class MainActivity : AppCompatActivity() { private fun getNavBar(): NavigationBarView? { return binding.mainNavView ?: binding.mainNavRail } + + fun hideNavBar() { + binding.mainNavView?.visibility = View.GONE + } + + fun showNavBar() { + binding.mainNavView?.visibility = View.VISIBLE + } } diff --git a/app/src/main/java/org/linphone/ui/conversations/ConversationsFragment.kt b/app/src/main/java/org/linphone/ui/conversations/ConversationsFragment.kt index 7d407073b..80a6e5d10 100644 --- a/app/src/main/java/org/linphone/ui/conversations/ConversationsFragment.kt +++ b/app/src/main/java/org/linphone/ui/conversations/ConversationsFragment.kt @@ -23,13 +23,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.content.ContextCompat +import androidx.core.view.doOnPreDraw import androidx.fragment.app.Fragment +import androidx.navigation.fragment.findNavController import androidx.navigation.navGraphViewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import org.linphone.R import org.linphone.databinding.ConversationsFragmentBinding +import org.linphone.ui.MainActivity class ConversationsFragment : Fragment() { private lateinit var binding: ConversationsFragmentBinding @@ -42,11 +44,13 @@ class ConversationsFragment : Fragment() { override fun onChanged() { scrollToTop() } + override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { if (positionStart == 0 && itemCount == 1) { scrollToTop() } } + override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) { scrollToTop() } @@ -64,13 +68,6 @@ class ConversationsFragment : Fragment() { savedInstanceState: Bundle? ): View { binding = ConversationsFragmentBinding.inflate(layoutInflater) - - val window = requireActivity().window - window.statusBarColor = ContextCompat.getColor( - requireContext(), - R.color.gray_1 - ) - return binding.root } @@ -78,6 +75,9 @@ class ConversationsFragment : Fragment() { super.onViewCreated(view, savedInstanceState) binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = listViewModel + + postponeEnterTransition() adapter = ConversationsListAdapter(viewLifecycleOwner) adapter.registerAdapterDataObserver(observer) @@ -104,6 +104,11 @@ class ConversationsFragment : Fragment() { viewLifecycleOwner ) { adapter.submitList(it) + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + (requireActivity() as MainActivity).showNavBar() + } } listViewModel.notifyItemChangedEvent.observe(viewLifecycleOwner) { @@ -111,6 +116,16 @@ class ConversationsFragment : Fragment() { adapter.notifyItemChanged(index) } } + + binding.setOnNewConversationClicked { + goToNewConversation() + } + } + + private fun goToNewConversation() { + findNavController().navigate( + R.id.action_conversationsFragment_to_newConversationFragment + ) } private fun scrollToTop() { diff --git a/app/src/main/java/org/linphone/ui/conversations/NewConversationFragment.kt b/app/src/main/java/org/linphone/ui/conversations/NewConversationFragment.kt new file mode 100644 index 000000000..b608fee6c --- /dev/null +++ b/app/src/main/java/org/linphone/ui/conversations/NewConversationFragment.kt @@ -0,0 +1,87 @@ +/* + * 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 . + */ +package org.linphone.ui.conversations + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.doOnPreDraw +import androidx.fragment.app.Fragment +import androidx.navigation.navGraphViewModels +import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.R +import org.linphone.contacts.ContactsSelectionAdapter +import org.linphone.databinding.NewConversationFragmentBinding +import org.linphone.ui.MainActivity + +class NewConversationFragment : Fragment() { + private lateinit var binding: NewConversationFragmentBinding + private lateinit var adapter: ContactsSelectionAdapter + private val viewModel: NewConversationViewModel by navGraphViewModels( + R.id.conversationsFragment + ) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = NewConversationFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = viewModel + + postponeEnterTransition() + + adapter = ContactsSelectionAdapter(viewLifecycleOwner) + binding.contactsList.adapter = adapter + binding.contactsList.setHasFixedSize(true) + + val layoutManager = LinearLayoutManager(requireContext()) + binding.contactsList.layoutManager = layoutManager + + viewModel.contactsList.observe( + viewLifecycleOwner + ) { + adapter.submitList(it) + + (view.parent as? ViewGroup)?.doOnPreDraw { + startPostponedEnterTransition() + (requireActivity() as MainActivity).hideNavBar() + } + } + + viewModel.filter.observe( + viewLifecycleOwner + ) { + viewModel.applyFilter(it.orEmpty().trim()) + } + + binding.setCancelClickListener { + requireActivity().onBackPressedDispatcher.onBackPressed() + } + } +} diff --git a/app/src/main/java/org/linphone/ui/conversations/NewConversationViewModel.kt b/app/src/main/java/org/linphone/ui/conversations/NewConversationViewModel.kt new file mode 100644 index 000000000..77b3ab612 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/conversations/NewConversationViewModel.kt @@ -0,0 +1,87 @@ +/* + * 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 . + */ +package org.linphone.ui.conversations + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.MagicSearch +import org.linphone.core.MagicSearchListenerStub +import org.linphone.core.SearchResult +import org.linphone.core.tools.Log + +class NewConversationViewModel : ViewModel() { + val contactsList = MutableLiveData>() + + val filter = MutableLiveData() + private var previousFilter = "NotSet" + + private val magicSearch: MagicSearch by lazy { + val magicSearch = coreContext.core.createMagicSearch() + magicSearch.limitedSearch = false + magicSearch + } + + private val magicSearchListener = object : MagicSearchListenerStub() { + override fun onSearchResultsReceived(magicSearch: MagicSearch) { + processMagicSearchResults(magicSearch.lastSearch) + } + } + + init { + magicSearch.addListener(magicSearchListener) + applyFilter("") + } + + override fun onCleared() { + magicSearch.removeListener(magicSearchListener) + super.onCleared() + } + + fun applyFilter(filterValue: String) { + Log.i("[New Conversation ViewModel] Filtering contacts using [$filterValue]") + if (previousFilter.isNotEmpty() && ( + previousFilter.length > filterValue.length || + (previousFilter.length == filterValue.length && previousFilter != filterValue) + ) + ) { + coreContext.postOnCoreThread { core -> + magicSearch.resetSearchCache() + } + } + previousFilter = filterValue + + coreContext.postOnCoreThread { core -> + magicSearch.getContactsListAsync( + filterValue, + "", + MagicSearch.Source.Friends.toInt(), + MagicSearch.Aggregation.Friend + ) + } + } + + private fun processMagicSearchResults(results: Array) { + Log.i("[New Conversation ViewModel] [${results.size}] matching results") + val list = arrayListOf() + list.addAll(results) + contactsList.postValue(list) + } +} diff --git a/app/src/main/res/drawable/group_chat.xml b/app/src/main/res/drawable/group_chat.xml new file mode 100644 index 000000000..fbfe18ea9 --- /dev/null +++ b/app/src/main/res/drawable/group_chat.xml @@ -0,0 +1,20 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/next.xml b/app/src/main/res/drawable/next.xml new file mode 100644 index 000000000..6393d14b8 --- /dev/null +++ b/app/src/main/res/drawable/next.xml @@ -0,0 +1,16 @@ + + + diff --git a/app/src/main/res/drawable/shape_gray_background.xml b/app/src/main/res/drawable/shape_gray_background.xml new file mode 100644 index 000000000..2cb2df90e --- /dev/null +++ b/app/src/main/res/drawable/shape_gray_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_orange_round.xml b/app/src/main/res/drawable/shape_orange_round.xml new file mode 100644 index 000000000..23a0d6ebc --- /dev/null +++ b/app/src/main/res/drawable/shape_orange_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shape_search_square_background.xml b/app/src/main/res/drawable/shape_search_square_background.xml new file mode 100644 index 000000000..5b5623ce0 --- /dev/null +++ b/app/src/main/res/drawable/shape_search_square_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 22d53a3d7..99c2ce2fd 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -14,6 +14,7 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversations_fragment.xml b/app/src/main/res/layout/conversations_fragment.xml index 4f7994bd4..33d18c03b 100644 --- a/app/src/main/res/layout/conversations_fragment.xml +++ b/app/src/main/res/layout/conversations_fragment.xml @@ -9,10 +9,7 @@ name="viewModel" type="org.linphone.ui.conversations.ConversationsListViewModel" /> - @@ -22,8 +19,7 @@ + android:layout_height="match_parent"> @@ -116,6 +113,7 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/main_nav_graph.xml b/app/src/main/res/navigation/main_nav_graph.xml index 810928820..c619056d8 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -9,6 +9,16 @@ android:id="@+id/conversationsFragment" android:name="org.linphone.ui.conversations.ConversationsFragment" android:label="ConversationsFragment" - tools:layout="@layout/conversations_fragment"/> + tools:layout="@layout/conversations_fragment"> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 00d856972..8b71c3a1f 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -11,5 +11,6 @@ #EEF6F8 #949494 #4E4E4E + #EDEDED #E5E5EA \ No newline at end of file