From 38730282097cb6a0b5a1ee4244bc58f287363f12 Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Mon, 9 Oct 2023 15:19:41 +0200 Subject: [PATCH] Started meetings list --- .../java/org/linphone/ui/main/MainActivity.kt | 26 ++-- .../chat/fragment/ConversationFragment.kt | 3 + .../chat/fragment/ConversationsFragment.kt | 12 ++ .../viewmodel/ConversationsListViewModel.kt | 20 ++- .../main/contacts/fragment/ContactFragment.kt | 3 + .../contacts/fragment/ContactsFragment.kt | 12 ++ .../contacts/fragment/EditContactFragment.kt | 3 + .../ui/main/fragment/BottomNavBarFragment.kt | 5 +- .../ui/main/fragment/GenericFragment.kt | 5 +- .../fragment/HistoryContactFragment.kt | 3 + .../main/history/fragment/HistoryFragment.kt | 12 ++ .../meetings/adapter/MeetingsListAdapter.kt | 113 ++++++++++++++ .../main/meetings/fragment/MeetingFragment.kt | 62 ++++++++ .../meetings/fragment/MeetingsFragment.kt | 145 ++++++++++++++++++ .../meetings/fragment/MeetingsListFragment.kt | 134 ++++++++++++++++ .../fragment/ScheduleMeetingFragment.kt | 58 +++++++ .../ui/main/meetings/model/MeetingModel.kt | 64 ++++++++ .../viewmodel/MeetingsListViewModel.kt | 91 +++++++++++ .../ui/main/viewmodel/SharedMainViewModel.kt | 10 ++ .../java/org/linphone/utils/TimestampUtils.kt | 45 ++++++ app/src/main/res/drawable/calendar.xml | 9 ++ .../shape_circle_primary_background.xml | 5 + app/src/main/res/drawable/slideshow.xml | 9 ++ .../res/layout-land/meetings_fragment.xml | 32 ++++ .../layout-land/meetings_list_fragment.xml | 137 +++++++++++++++++ app/src/main/res/layout/meeting_fragment.xml | 26 ++++ app/src/main/res/layout/meeting_list_cell.xml | 130 ++++++++++++++++ .../res/layout/meeting_schedule_fragment.xml | 26 ++++ app/src/main/res/layout/meetings_fragment.xml | 32 ++++ .../res/layout/meetings_list_decoration.xml | 22 +++ .../res/layout/meetings_list_fragment.xml | 137 +++++++++++++++++ .../main/res/navigation/main_nav_graph.xml | 56 +++++++ .../res/navigation/meetings_nav_graph.xml | 24 +++ app/src/main/res/values/dimen.xml | 2 + app/src/main/res/values/strings.xml | 2 + 35 files changed, 1458 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingsListAdapter.kt create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/fragment/ScheduleMeetingFragment.kt create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt create mode 100644 app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt create mode 100644 app/src/main/res/drawable/calendar.xml create mode 100644 app/src/main/res/drawable/shape_circle_primary_background.xml create mode 100644 app/src/main/res/drawable/slideshow.xml create mode 100644 app/src/main/res/layout-land/meetings_fragment.xml create mode 100644 app/src/main/res/layout-land/meetings_list_fragment.xml create mode 100644 app/src/main/res/layout/meeting_fragment.xml create mode 100644 app/src/main/res/layout/meeting_list_cell.xml create mode 100644 app/src/main/res/layout/meeting_schedule_fragment.xml create mode 100644 app/src/main/res/layout/meetings_fragment.xml create mode 100644 app/src/main/res/layout/meetings_list_decoration.xml create mode 100644 app/src/main/res/layout/meetings_list_fragment.xml create mode 100644 app/src/main/res/navigation/meetings_nav_graph.xml diff --git a/app/src/main/java/org/linphone/ui/main/MainActivity.kt b/app/src/main/java/org/linphone/ui/main/MainActivity.kt index cdf78051c..506680069 100644 --- a/app/src/main/java/org/linphone/ui/main/MainActivity.kt +++ b/app/src/main/java/org/linphone/ui/main/MainActivity.kt @@ -145,6 +145,16 @@ class MainActivity : AppCompatActivity() { // TODO FIXME: uncomment // startActivity(Intent(this, WelcomeActivity::class.java)) + coreContext.greenToastToShowEvent.observe(this) { + it.consume { pair -> + val message = pair.first + val icon = pair.second + showGreenToast(message, icon) + } + } + } + + override fun onStart() { coreContext.postOnCoreThread { val startDestination = when (corePreferences.defaultFragment) { CONTACTS_FRAGMENT_ID -> { @@ -163,10 +173,10 @@ class MainActivity : AppCompatActivity() { ) R.id.conversationsFragment } - /*MEETINGS_FRAGMENT_ID -> { + MEETINGS_FRAGMENT_ID -> { Log.i("$TAG Latest visited page is meetings, setting it as start destination") R.id.meetingsFragment - }*/ + } else -> { // Default Log.i("$TAG No latest visited page stored, using default one (call history)") R.id.historyFragment @@ -179,13 +189,7 @@ class MainActivity : AppCompatActivity() { } } - coreContext.greenToastToShowEvent.observe(this) { - it.consume { pair -> - val message = pair.first - val icon = pair.second - showGreenToast(message, icon) - } - } + super.onStart() } override fun onPause() { @@ -199,9 +203,9 @@ class MainActivity : AppCompatActivity() { R.id.conversationsFragment -> { CHAT_FRAGMENT_ID } - /*R.id.meetingsFragment -> { + R.id.meetingsFragment -> { MEETINGS_FRAGMENT_ID - }*/ + } else -> { // Default HISTORY_FRAGMENT_ID } diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt index 2f48bfae0..a9af10398 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationFragment.kt @@ -65,6 +65,9 @@ class ConversationFragment : GenericFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // This fragment is displayed in a SlidingPane "child" area + isSlidingPaneChild = true + super.onViewCreated(view, savedInstanceState) postponeEnterTransition() diff --git a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt index adebf3294..f243c8f48 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/fragment/ConversationsFragment.kt @@ -139,6 +139,18 @@ class ConversationsFragment : GenericFragment() { } } } + + sharedViewModel.navigateToMeetingsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.conversationsFragment) { + // To prevent any previously seen conversation to show up when navigating back to here later + binding.chatNavContainer.findNavController().popBackStack() + + val action = ConversationsFragmentDirections.actionConversationsFragmentToMeetingsFragment() + findNavController().navigate(action) + } + } + } } override fun onResume() { diff --git a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt index faf19ef50..948fe4b67 100644 --- a/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/chat/viewmodel/ConversationsListViewModel.kt @@ -24,7 +24,7 @@ import androidx.annotation.WorkerThread import androidx.lifecycle.MutableLiveData import org.linphone.LinphoneApplication.Companion.coreContext import org.linphone.contacts.ContactsManager -import org.linphone.core.CallLog +import org.linphone.core.ChatRoom import org.linphone.core.ChatRoom.Capabilities import org.linphone.core.Core import org.linphone.core.CoreListenerStub @@ -46,8 +46,22 @@ class ConversationsListViewModel @UiThread constructor() : AbstractTopBarViewMod private var currentFilter = "" private val coreListener = object : CoreListenerStub() { - override fun onCallLogUpdated(core: Core, callLog: CallLog) { - computeChatRoomsList(currentFilter) + @WorkerThread + override fun onChatRoomStateChanged( + core: Core, + chatRoom: ChatRoom, + state: ChatRoom.State? + ) { + Log.i( + "$TAG Chat room [${LinphoneUtils.getChatRoomId(chatRoom)}] state changed [$state]" + ) + + when (state) { + ChatRoom.State.Created, ChatRoom.State.Instantiated, ChatRoom.State.Deleted -> { + computeChatRoomsList(currentFilter) + } + else -> {} + } } } diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt index 1ca382072..44338c1ee 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactFragment.kt @@ -79,6 +79,9 @@ class ContactFragment : GenericFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // This fragment is displayed in a SlidingPane "child" area + isSlidingPaneChild = true + super.onViewCreated(view, savedInstanceState) postponeEnterTransition() diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt index 8235ddb7a..d0101a86e 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/ContactsFragment.kt @@ -142,6 +142,18 @@ class ContactsFragment : GenericFragment() { } } } + + sharedViewModel.navigateToMeetingsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.contactsFragment) { + // To prevent any previously seen contact to show up when navigating back to here later + binding.contactsNavContainer.findNavController().popBackStack() + + val action = ContactsFragmentDirections.actionContactsFragmentToMeetingsFragment() + findNavController().navigate(action) + } + } + } } override fun onResume() { diff --git a/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt b/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt index eaa36a9bd..dc3e64007 100644 --- a/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/contacts/fragment/EditContactFragment.kt @@ -97,6 +97,9 @@ class EditContactFragment : GenericFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // This fragment is displayed in a SlidingPane "child" area + isSlidingPaneChild = true + super.onViewCreated(view, savedInstanceState) postponeEnterTransition() diff --git a/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt index cb13b4b20..2bf7eceae 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/BottomNavBarFragment.kt @@ -89,13 +89,16 @@ class BottomNavBarFragment : Fragment() { } binding.setOnMeetingsClicked { - // TODO: meeting feature + if (sharedViewModel.currentlyDisplayedFragment.value != R.id.meetingsFragment) { + sharedViewModel.navigateToMeetingsEvent.value = Event(true) + } } sharedViewModel.currentlyDisplayedFragment.observe(viewLifecycleOwner) { viewModel.contactsSelected.value = it == R.id.contactsFragment viewModel.callsSelected.value = it == R.id.historyFragment viewModel.conversationsSelected.value = it == R.id.conversationsFragment + viewModel.meetingsSelected.value = it == R.id.meetingsFragment } sharedViewModel.resetMissedCallsCountEvent.observe(viewLifecycleOwner) { diff --git a/app/src/main/java/org/linphone/ui/main/fragment/GenericFragment.kt b/app/src/main/java/org/linphone/ui/main/fragment/GenericFragment.kt index 188631bfd..c6fa55921 100644 --- a/app/src/main/java/org/linphone/ui/main/fragment/GenericFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/fragment/GenericFragment.kt @@ -26,7 +26,6 @@ import androidx.annotation.UiThread import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController -import org.linphone.R import org.linphone.core.tools.Log import org.linphone.ui.main.viewmodel.SharedMainViewModel @@ -38,6 +37,8 @@ abstract class GenericFragment : Fragment() { protected lateinit var sharedViewModel: SharedMainViewModel + protected var isSlidingPaneChild: Boolean = false + private val onBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { Log.d("$TAG ${getFragmentRealClassName()} handleOnBackPressed") @@ -114,7 +115,7 @@ abstract class GenericFragment : Fragment() { // This allow to navigate a SlidingPane child nav graph. // This only concerns fragments for which the nav graph is inside a SlidingPane layout. // In our case it's all graphs except the main one. - if (findNavController().graph.id == R.id.main_nav_graph) return false + if (!isSlidingPaneChild) return false val isSlidingPaneFlat = sharedViewModel.isSlidingPaneSlideable.value == false Log.d( diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryContactFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryContactFragment.kt index 4610c1ca9..05bf72037 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryContactFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryContactFragment.kt @@ -75,6 +75,9 @@ class HistoryContactFragment : GenericFragment() { } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // This fragment is displayed in a SlidingPane "child" area + isSlidingPaneChild = true + super.onViewCreated(view, savedInstanceState) postponeEnterTransition() diff --git a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt index c72e5b42b..22f625e96 100644 --- a/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt +++ b/app/src/main/java/org/linphone/ui/main/history/fragment/HistoryFragment.kt @@ -139,6 +139,18 @@ class HistoryFragment : GenericFragment() { } } } + + sharedViewModel.navigateToMeetingsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.historyFragment) { + // To prevent any previously seen call log to show up when navigating back to here later + binding.historyNavContainer.findNavController().popBackStack() + + val action = HistoryFragmentDirections.actionHistoryFragmentToMeetingsFragment() + findNavController().navigate(action) + } + } + } } override fun onResume() { diff --git a/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingsListAdapter.kt b/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingsListAdapter.kt new file mode 100644 index 000000000..e6391d8be --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/adapter/MeetingsListAdapter.kt @@ -0,0 +1,113 @@ +package org.linphone.ui.main.meetings.adapter + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.UiThread +import androidx.databinding.DataBindingUtil +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.MutableLiveData +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import org.linphone.R +import org.linphone.databinding.MeetingListCellBinding +import org.linphone.databinding.MeetingsListDecorationBinding +import org.linphone.ui.main.meetings.model.MeetingModel +import org.linphone.utils.Event +import org.linphone.utils.HeaderAdapter + +class MeetingsListAdapter( + private val viewLifecycleOwner: LifecycleOwner +) : ListAdapter(MeetingDiffCallback()), HeaderAdapter { + var selectedAdapterPosition = -1 + + val meetingClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + val meetingLongClickedEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + + override fun displayHeaderForPosition(position: Int): Boolean { + if (position == 0) return true + + val previous = getItem(position - 1) + val item = getItem(position) + return previous.month != item.month + } + + override fun getHeaderViewForPosition(context: Context, position: Int): View { + val binding = MeetingsListDecorationBinding.inflate(LayoutInflater.from(context)) + val item = getItem(position) + binding.header.text = item.month + return binding.root + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val binding: MeetingListCellBinding = DataBindingUtil.inflate( + LayoutInflater.from(parent.context), + R.layout.meeting_list_cell, + parent, + false + ) + return ViewHolder(binding) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + (holder as ViewHolder).bind(getItem(position)) + } + + fun resetSelection() { + notifyItemChanged(selectedAdapterPosition) + selectedAdapterPosition = -1 + } + + inner class ViewHolder( + val binding: MeetingListCellBinding + ) : RecyclerView.ViewHolder(binding.root) { + @UiThread + fun bind(meetingModel: MeetingModel) { + with(binding) { + model = meetingModel + + val hasPrevious = bindingAdapterPosition > 0 + firstMeetingOfTheDay = if (hasPrevious) { + val previous = getItem(bindingAdapterPosition - 1) + previous.day != meetingModel.day || previous.dayNumber != meetingModel.dayNumber + } else { + true + } + + lifecycleOwner = viewLifecycleOwner + + binding.root.isSelected = bindingAdapterPosition == selectedAdapterPosition + + binding.setOnClickListener { + meetingClickedEvent.value = Event(meetingModel) + } + + binding.setOnLongClickListener { + selectedAdapterPosition = bindingAdapterPosition + binding.root.isSelected = true + meetingLongClickedEvent.value = Event(meetingModel) + true + } + + executePendingBindings() + } + } + } + + private class MeetingDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: MeetingModel, newItem: MeetingModel): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: MeetingModel, newItem: MeetingModel): Boolean { + return oldItem.subject.value == newItem.subject.value && oldItem.time == newItem.time + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt new file mode 100644 index 000000000..8c46b8919 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingFragment.kt @@ -0,0 +1,62 @@ +/* + * 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.main.meetings.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import org.linphone.databinding.MeetingFragmentBinding +import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.utils.Event + +class MeetingFragment : GenericFragment() { + companion object { + private const val TAG = "[Meeting Fragment]" + } + + private lateinit var binding: MeetingFragmentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = MeetingFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun goBack(): Boolean { + sharedViewModel.closeSlidingPaneEvent.value = Event(true) + // If not done, when going back to ConversationsFragment this fragment will be created again + return findNavController().popBackStack() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + // This fragment is displayed in a SlidingPane "child" area + isSlidingPaneChild = true + + super.onViewCreated(view, savedInstanceState) + postponeEnterTransition() + + binding.lifecycleOwner = viewLifecycleOwner + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsFragment.kt new file mode 100644 index 000000000..2042ca3c9 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsFragment.kt @@ -0,0 +1,145 @@ +/* + * 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.main.meetings.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.animation.Animation +import android.view.animation.AnimationUtils +import androidx.core.view.doOnPreDraw +import androidx.navigation.findNavController +import androidx.navigation.fragment.findNavController +import androidx.slidingpanelayout.widget.SlidingPaneLayout +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.MeetingsFragmentBinding +import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.utils.SlidingPaneBackPressedCallback + +class MeetingsFragment : GenericFragment() { + companion object { + private const val TAG = "[Meetings Fragment]" + } + + private lateinit var binding: MeetingsFragmentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = MeetingsFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun onCreateAnimation(transit: Int, enter: Boolean, nextAnim: Int): Animation? { + if (findNavController().currentDestination?.id == R.id.scheduleMeetingFragment) { + // Holds fragment in place while new contact fragment slides over it + return AnimationUtils.loadAnimation(activity, R.anim.hold) + } + return super.onCreateAnimation(transit, enter, nextAnim) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + + binding.root.doOnPreDraw { + val slidingPane = binding.slidingPaneLayout + slidingPane.lockMode = SlidingPaneLayout.LOCK_MODE_LOCKED + + sharedViewModel.isSlidingPaneSlideable.value = slidingPane.isSlideable + + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + SlidingPaneBackPressedCallback(slidingPane) + ) + } + + sharedViewModel.closeSlidingPaneEvent.observe( + viewLifecycleOwner + ) { + it.consume { + Log.i("$TAG Closing sliding pane") + binding.slidingPaneLayout.closePane() + } + } + + sharedViewModel.openSlidingPaneEvent.observe( + viewLifecycleOwner + ) { + it.consume { + Log.i("$TAG Opening sliding pane") + binding.slidingPaneLayout.openPane() + } + } + + sharedViewModel.showScheduleMeetingEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i("$TAG Navigating to schedule meeting fragment") + findNavController().navigate(R.id.action_global_scheduleMeetingFragment) + } + } + + sharedViewModel.navigateToContactsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.meetingsFragment) { + // To prevent any previously seen meeting to show up when navigating back to here later + binding.meetingsNavContainer.findNavController().popBackStack() + + val action = MeetingsFragmentDirections.actionMeetingsFragmentToContactsFragment() + findNavController().navigate(action) + } + } + } + + sharedViewModel.navigateToCallsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.meetingsFragment) { + // To prevent any previously seen meeting to show up when navigating back to here later + binding.meetingsNavContainer.findNavController().popBackStack() + + val action = MeetingsFragmentDirections.actionMeetingsFragmentToHistoryFragment() + findNavController().navigate(action) + } + } + } + + sharedViewModel.navigateToConversationsEvent.observe(viewLifecycleOwner) { + it.consume { + if (findNavController().currentDestination?.id == R.id.meetingsFragment) { + // To prevent any previously seen meeting to show up when navigating back to here later + binding.meetingsNavContainer.findNavController().popBackStack() + + val action = MeetingsFragmentDirections.actionMeetingsFragmentToConversationsFragment() + findNavController().navigate(action) + } + } + } + } + + override fun onResume() { + super.onResume() + sharedViewModel.currentlyDisplayedFragment.value = R.id.meetingsFragment + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt new file mode 100644 index 000000000..98c0c0cc8 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/MeetingsListFragment.kt @@ -0,0 +1,134 @@ +/* + * 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.main.meetings.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.LinearLayoutManager +import org.linphone.R +import org.linphone.core.tools.Log +import org.linphone.databinding.MeetingsListFragmentBinding +import org.linphone.ui.main.fragment.AbstractTopBarFragment +import org.linphone.ui.main.meetings.adapter.MeetingsListAdapter +import org.linphone.ui.main.meetings.viewmodel.MeetingsListViewModel +import org.linphone.utils.Event +import org.linphone.utils.RecyclerViewHeaderDecoration +import org.linphone.utils.hideKeyboard +import org.linphone.utils.showKeyboard + +class MeetingsListFragment : AbstractTopBarFragment() { + companion object { + private const val TAG = "[Meetings List Fragment]" + } + + private lateinit var binding: MeetingsListFragmentBinding + + private lateinit var listViewModel: MeetingsListViewModel + + private lateinit var adapter: MeetingsListAdapter + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = MeetingsListFragmentBinding.inflate(layoutInflater) + return binding.root + } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + listViewModel = requireActivity().run { + ViewModelProvider(this)[MeetingsListViewModel::class.java] + } + + binding.lifecycleOwner = viewLifecycleOwner + binding.viewModel = listViewModel + + adapter = MeetingsListAdapter(viewLifecycleOwner) + binding.meetingsList.setHasFixedSize(true) + binding.meetingsList.adapter = adapter + + val headerItemDecoration = RecyclerViewHeaderDecoration(requireContext(), adapter, true) + binding.meetingsList.addItemDecoration(headerItemDecoration) + + val layoutManager = LinearLayoutManager(requireContext()) + binding.meetingsList.layoutManager = layoutManager + + binding.setNewMeetingClicked { + sharedViewModel.showScheduleMeetingEvent.value = Event(true) + } + + binding.setTodayClickListener { + val todayMeeting = listViewModel.meetings.value.orEmpty().find { + it.isToday + } + val position = if (todayMeeting != null) { + listViewModel.meetings.value.orEmpty().indexOf(todayMeeting) + } else { + 0 // TODO FIXME: improve by getting closest meeting + } + binding.meetingsList.smoothScrollToPosition(position) + } + + listViewModel.meetings.observe(viewLifecycleOwner) { + val currentCount = adapter.itemCount + adapter.submitList(it) + Log.i("$TAG Meetings list ready with [${it.size}] items") + + if (currentCount < it.size) { + binding.meetingsList.scrollToPosition(0) + } + } + + sharedViewModel.defaultAccountChangedEvent.observe(viewLifecycleOwner) { + it.consume { + Log.i( + "$TAG Default account changed, updating avatar in top bar & re-computing meetings list" + ) + } + } + + // TopBarFragment related + + setViewModelAndTitle( + listViewModel, + getString(R.string.bottom_navigation_meetings_label) + ) + + listViewModel.searchFilter.observe(viewLifecycleOwner) { filter -> + listViewModel.applyFilter(filter.trim()) + } + + listViewModel.focusSearchBarEvent.observe(viewLifecycleOwner) { + it.consume { show -> + if (show) { + // To automatically open keyboard + binding.topBar.search.showKeyboard() + } else { + binding.topBar.search.hideKeyboard() + } + } + } + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/fragment/ScheduleMeetingFragment.kt b/app/src/main/java/org/linphone/ui/main/meetings/fragment/ScheduleMeetingFragment.kt new file mode 100644 index 000000000..e68272a1c --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/fragment/ScheduleMeetingFragment.kt @@ -0,0 +1,58 @@ +/* + * 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.main.meetings.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.navigation.fragment.findNavController +import org.linphone.databinding.MeetingScheduleFragmentBinding +import org.linphone.ui.main.fragment.GenericFragment +import org.linphone.utils.Event + +class ScheduleMeetingFragment : GenericFragment() { + companion object { + private const val TAG = "[Schedule Meeting Fragment]" + } + + private lateinit var binding: MeetingScheduleFragmentBinding + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + binding = MeetingScheduleFragmentBinding.inflate(layoutInflater) + return binding.root + } + + override fun goBack(): Boolean { + sharedViewModel.closeSlidingPaneEvent.value = Event(true) + // If not done, when going back to ConversationsFragment this fragment will be created again + return findNavController().popBackStack() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.lifecycleOwner = viewLifecycleOwner + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt new file mode 100644 index 000000000..5969f3dce --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/model/MeetingModel.kt @@ -0,0 +1,64 @@ +/* + * 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.main.meetings.model + +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.core.ConferenceInfo +import org.linphone.core.Participant +import org.linphone.utils.TimestampUtils + +class MeetingModel @WorkerThread constructor(conferenceInfo: ConferenceInfo) { + val id = conferenceInfo.uri?.asStringUriOnly() + + private val timestamp = conferenceInfo.dateTime + + val day = TimestampUtils.dayOfWeek(timestamp) + + val dayNumber = TimestampUtils.dayOfMonth(timestamp) + + val month = TimestampUtils.month(timestamp) + + val isToday = TimestampUtils.isToday(timestamp) + + private val startTime = TimestampUtils.timeToString(timestamp) + + private val endTime = TimestampUtils.timeToString(timestamp + (conferenceInfo.duration * 60)) + + val time = "$startTime - $endTime" + + val isBroadcast = MutableLiveData() + + val subject = MutableLiveData() + + init { + subject.postValue(conferenceInfo.subject) + + var allSpeaker = true + for (participant in conferenceInfo.participantInfos) { + if (participant.role == Participant.Role.Listener) { + allSpeaker = false + break + } + } + + isBroadcast.postValue(!allSpeaker) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt new file mode 100644 index 000000000..32eac75e8 --- /dev/null +++ b/app/src/main/java/org/linphone/ui/main/meetings/viewmodel/MeetingsListViewModel.kt @@ -0,0 +1,91 @@ +/* + * 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.main.meetings.viewmodel + +import androidx.annotation.UiThread +import androidx.annotation.WorkerThread +import androidx.lifecycle.MutableLiveData +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.core.ConferenceInfo +import org.linphone.core.Core +import org.linphone.core.CoreListenerStub +import org.linphone.core.tools.Log +import org.linphone.ui.main.meetings.model.MeetingModel +import org.linphone.ui.main.viewmodel.AbstractTopBarViewModel + +class MeetingsListViewModel @UiThread constructor() : AbstractTopBarViewModel() { + companion object { + private const val TAG = "[Meetings List ViewModel]" + } + + val meetings = MutableLiveData>() + + val fetchInProgress = MutableLiveData() + + private var currentFilter = "" + + private val coreListener = object : CoreListenerStub() { + @WorkerThread + override fun onConferenceInfoReceived(core: Core, conferenceInfo: ConferenceInfo) { + Log.i("$TAG Conference info received [${conferenceInfo.uri?.asStringUriOnly()}]") + computeMeetingsList(currentFilter) + } + } + + init { + coreContext.postOnCoreThread { core -> + core.addListener(coreListener) + + computeMeetingsList(currentFilter) + } + } + + @UiThread + override fun onCleared() { + super.onCleared() + + coreContext.postOnCoreThread { core -> + core.removeListener(coreListener) + } + } + + @UiThread + fun applyFilter(filter: String = currentFilter) { + currentFilter = filter + + coreContext.postOnCoreThread { + computeMeetingsList(filter) + } + } + + @WorkerThread + private fun computeMeetingsList(filter: String) { + val list = arrayListOf() + + // TODO FIXME: get list from default account + for (conferenceInfo in coreContext.core.conferenceInformationList) { + val model = MeetingModel(conferenceInfo) + // TODO FIXME: apply filter + list.add(model) + } + + meetings.postValue(list) + } +} diff --git a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt index f4165e544..ce8260b56 100644 --- a/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewmodel/SharedMainViewModel.kt @@ -49,6 +49,10 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { MutableLiveData>() } + val navigateToMeetingsEvent: MutableLiveData> by lazy { + MutableLiveData>() + } + var currentlyDisplayedFragment = MutableLiveData() /* Top bar related */ @@ -104,4 +108,10 @@ class SharedMainViewModel @UiThread constructor() : ViewModel() { val showConversationEvent: MutableLiveData>> by lazy { MutableLiveData>>() } + + /* Meetings related */ + + val showScheduleMeetingEvent: MutableLiveData> by lazy { + MutableLiveData>() + } } diff --git a/app/src/main/java/org/linphone/utils/TimestampUtils.kt b/app/src/main/java/org/linphone/utils/TimestampUtils.kt index f6f1747e6..3c2db2e5f 100644 --- a/app/src/main/java/org/linphone/utils/TimestampUtils.kt +++ b/app/src/main/java/org/linphone/utils/TimestampUtils.kt @@ -23,11 +23,14 @@ import androidx.annotation.AnyThread import java.text.DateFormat import java.text.Format import java.text.SimpleDateFormat +import java.time.format.TextStyle import java.util.* import org.linphone.LinphoneApplication.Companion.coreContext class TimestampUtils { companion object { + private const val TAG = "[Timestamp Utils]" + @AnyThread fun isToday(timestamp: Long, timestampInSecs: Boolean = true): Boolean { val cal = Calendar.getInstance() @@ -60,6 +63,48 @@ class TimestampUtils { return dateFormatter.format(calendar.time) } + @AnyThread + fun dayOfWeek(timestamp: Long, timestampInSecs: Boolean = true): String { + val calendar = Calendar.getInstance() + calendar.timeInMillis = if (timestampInSecs) timestamp * 1000 else timestamp + val dayName = calendar.getDisplayName( + Calendar.DAY_OF_WEEK, + TextStyle.SHORT.ordinal, + Locale.getDefault() + ) + val upperCased = dayName?.replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase( + Locale.getDefault() + ) + } else { + it.toString() + } + } ?: "?" + val shorten = upperCased.substring(0, 3) + return "$shorten." + } + + @AnyThread + fun dayOfMonth(timestamp: Long, timestampInSecs: Boolean = true): String { + val calendar = Calendar.getInstance() + calendar.timeInMillis = if (timestampInSecs) timestamp * 1000 else timestamp + return calendar.get(Calendar.DAY_OF_MONTH).toString() + } + + @AnyThread + fun month(timestamp: Long, timestampInSecs: Boolean = true): String { + val calendar = Calendar.getInstance() + calendar.timeInMillis = if (timestampInSecs) timestamp * 1000 else timestamp + return calendar.getDisplayName( + Calendar.MONTH, + TextStyle.SHORT.ordinal, + Locale.getDefault() + ) + ?.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + ?: "?" + } + @AnyThread fun timeToString(time: Long, timestampInSecs: Boolean = true): String { val use24hFormat = android.text.format.DateFormat.is24HourFormat( diff --git a/app/src/main/res/drawable/calendar.xml b/app/src/main/res/drawable/calendar.xml new file mode 100644 index 000000000..576da7d16 --- /dev/null +++ b/app/src/main/res/drawable/calendar.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shape_circle_primary_background.xml b/app/src/main/res/drawable/shape_circle_primary_background.xml new file mode 100644 index 000000000..6ae983625 --- /dev/null +++ b/app/src/main/res/drawable/shape_circle_primary_background.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/slideshow.xml b/app/src/main/res/drawable/slideshow.xml new file mode 100644 index 000000000..84c35cf29 --- /dev/null +++ b/app/src/main/res/drawable/slideshow.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout-land/meetings_fragment.xml b/app/src/main/res/layout-land/meetings_fragment.xml new file mode 100644 index 000000000..cbcfda647 --- /dev/null +++ b/app/src/main/res/layout-land/meetings_fragment.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout-land/meetings_list_fragment.xml b/app/src/main/res/layout-land/meetings_list_fragment.xml new file mode 100644 index 000000000..78857e34d --- /dev/null +++ b/app/src/main/res/layout-land/meetings_list_fragment.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/meeting_fragment.xml b/app/src/main/res/layout/meeting_fragment.xml new file mode 100644 index 000000000..7b54e177a --- /dev/null +++ b/app/src/main/res/layout/meeting_fragment.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/meeting_list_cell.xml b/app/src/main/res/layout/meeting_list_cell.xml new file mode 100644 index 000000000..04850d318 --- /dev/null +++ b/app/src/main/res/layout/meeting_list_cell.xml @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/meeting_schedule_fragment.xml b/app/src/main/res/layout/meeting_schedule_fragment.xml new file mode 100644 index 000000000..7b54e177a --- /dev/null +++ b/app/src/main/res/layout/meeting_schedule_fragment.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/meetings_fragment.xml b/app/src/main/res/layout/meetings_fragment.xml new file mode 100644 index 000000000..9ce57120b --- /dev/null +++ b/app/src/main/res/layout/meetings_fragment.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/meetings_list_decoration.xml b/app/src/main/res/layout/meetings_list_decoration.xml new file mode 100644 index 000000000..90eced8a6 --- /dev/null +++ b/app/src/main/res/layout/meetings_list_decoration.xml @@ -0,0 +1,22 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/meetings_list_fragment.xml b/app/src/main/res/layout/meetings_list_fragment.xml new file mode 100644 index 000000000..0ee610894 --- /dev/null +++ b/app/src/main/res/layout/meetings_list_fragment.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 1f296d441..c0870c5ee 100644 --- a/app/src/main/res/navigation/main_nav_graph.xml +++ b/app/src/main/res/navigation/main_nav_graph.xml @@ -23,6 +23,12 @@ app:launchSingleTop="true" app:popUpTo="@id/contactsFragment" app:popUpToInclusive="true" /> + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/meetings_nav_graph.xml b/app/src/main/res/navigation/meetings_nav_graph.xml new file mode 100644 index 000000000..132f007f3 --- /dev/null +++ b/app/src/main/res/navigation/meetings_nav_graph.xml @@ -0,0 +1,24 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/dimen.xml b/app/src/main/res/values/dimen.xml index b2b796838..81f19e451 100644 --- a/app/src/main/res/values/dimen.xml +++ b/app/src/main/res/values/dimen.xml @@ -50,4 +50,6 @@ 400dp 400dp 400dp + + 8dp \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f89dcf53..e56533d70 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -331,6 +331,8 @@ No contact for the moment… Say something… + No meeting for the moment… + Operation in progress, please wait Transfer