mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-26 20:55:10 +00:00
Add initial implementation of calls tab behind a feature flag.
This commit is contained in:
committed by
Greyson Parrelli
parent
d1373d2767
commit
88de0f21e7
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
|
||||
class CallLogActionMode(
|
||||
private val fragment: CallLogFragment,
|
||||
private val onResetSelectionState: () -> Unit
|
||||
) : ActionMode.Callback {
|
||||
|
||||
private var actionMode: ActionMode? = null
|
||||
|
||||
override fun onCreateActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
mode?.title = getTitle(1)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareActionMode(mode: ActionMode?, menu: Menu?): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onActionItemClicked(mode: ActionMode?, item: MenuItem?): Boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onDestroyActionMode(mode: ActionMode?) {
|
||||
onResetSelectionState()
|
||||
endIfActive()
|
||||
}
|
||||
|
||||
fun setCount(count: Int) {
|
||||
actionMode?.title = getTitle(count)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
actionMode = (fragment.requireActivity() as AppCompatActivity).startSupportActionMode(this)
|
||||
fragment.requireListener<CallLogFragment.Callback>().onMultiSelectStarted()
|
||||
}
|
||||
|
||||
fun end() {
|
||||
fragment.requireListener<CallLogFragment.Callback>().onMultiSelectFinished()
|
||||
actionMode?.finish()
|
||||
actionMode = null
|
||||
}
|
||||
|
||||
private fun getTitle(callLogsSelected: Int): String {
|
||||
return fragment.requireContext().resources.getQuantityString(R.plurals.ConversationListFragment_s_selected, callLogsSelected, callLogsSelected)
|
||||
}
|
||||
|
||||
private fun endIfActive() {
|
||||
if (actionMode != null) {
|
||||
end()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.content.res.ColorStateList
|
||||
import android.view.View
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.TextViewCompat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.databinding.CallLogAdapterItemBinding
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListItemClearFilterBinding
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingFactory
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.BindingViewHolder
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
|
||||
import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter
|
||||
import org.thoughtcrime.securesms.util.setRelativeDrawables
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* RecyclerView Adapter for the Call Log screen
|
||||
*/
|
||||
class CallLogAdapter(
|
||||
callbacks: Callbacks
|
||||
) : PagingMappingAdapter<CallLogRow.Id>() {
|
||||
|
||||
init {
|
||||
registerFactory(
|
||||
CallModel::class.java,
|
||||
BindingFactory(
|
||||
creator = {
|
||||
CallModelViewHolder(
|
||||
it,
|
||||
callbacks::onCallClicked,
|
||||
callbacks::onCallLongClicked
|
||||
)
|
||||
},
|
||||
inflater = CallLogAdapterItemBinding::inflate
|
||||
)
|
||||
)
|
||||
registerFactory(
|
||||
ClearFilterModel::class.java,
|
||||
BindingFactory(
|
||||
creator = { ClearFilterViewHolder(it, callbacks::onClearFilterClicked) },
|
||||
inflater = ConversationListItemClearFilterBinding::inflate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun submitCallRows(rows: List<CallLogRow>, selectionState: CallLogSelectionState) {
|
||||
submitList(
|
||||
rows.filterNotNull().map {
|
||||
when (it) {
|
||||
is CallLogRow.Call -> CallModel(it, selectionState, itemCount)
|
||||
is CallLogRow.ClearFilter -> ClearFilterModel()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private class CallModel(
|
||||
val call: CallLogRow.Call,
|
||||
val selectionState: CallLogSelectionState,
|
||||
val itemCount: Int
|
||||
) : MappingModel<CallModel> {
|
||||
companion object {
|
||||
const val PAYLOAD_SELECTION_STATE = "PAYLOAD_SELECTION_STATE"
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: CallModel): Boolean = call.id == newItem.call.id
|
||||
override fun areContentsTheSame(newItem: CallModel): Boolean {
|
||||
return call == newItem.call &&
|
||||
isSelectionStateTheSame(newItem) &&
|
||||
isItemCountTheSame(newItem)
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: CallModel): Any? {
|
||||
return if (call == newItem.call && (!isSelectionStateTheSame(newItem) || !isItemCountTheSame(newItem))) {
|
||||
PAYLOAD_SELECTION_STATE
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSelectionStateTheSame(newItem: CallModel): Boolean {
|
||||
return selectionState.contains(call.id) == newItem.selectionState.contains(newItem.call.id) &&
|
||||
selectionState.isNotEmpty(itemCount) == newItem.selectionState.isNotEmpty(newItem.itemCount)
|
||||
}
|
||||
|
||||
private fun isItemCountTheSame(newItem: CallModel): Boolean {
|
||||
return itemCount == newItem.itemCount
|
||||
}
|
||||
}
|
||||
|
||||
private class ClearFilterModel : MappingModel<ClearFilterModel> {
|
||||
override fun areItemsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
override fun areContentsTheSame(newItem: ClearFilterModel): Boolean = true
|
||||
}
|
||||
|
||||
private class CallModelViewHolder(
|
||||
binding: CallLogAdapterItemBinding,
|
||||
private val onCallClicked: (CallLogRow.Call) -> Unit,
|
||||
private val onCallLongClicked: (View, CallLogRow.Call) -> Boolean
|
||||
) : BindingViewHolder<CallModel, CallLogAdapterItemBinding>(binding) {
|
||||
override fun bind(model: CallModel) {
|
||||
itemView.setOnClickListener {
|
||||
onCallClicked(model.call)
|
||||
}
|
||||
|
||||
itemView.setOnLongClickListener {
|
||||
onCallLongClicked(itemView, model.call)
|
||||
}
|
||||
|
||||
itemView.isSelected = model.selectionState.contains(model.call.id)
|
||||
binding.callSelected.isChecked = model.selectionState.contains(model.call.id)
|
||||
binding.callSelected.visible = model.selectionState.isNotEmpty(model.itemCount)
|
||||
|
||||
if (payload.contains(CallModel.PAYLOAD_SELECTION_STATE)) {
|
||||
return
|
||||
}
|
||||
|
||||
val event = model.call.call.event
|
||||
val direction = model.call.call.direction
|
||||
val type = model.call.call.type
|
||||
|
||||
binding.callRecipientAvatar.setAvatar(GlideApp.with(binding.callRecipientAvatar), model.call.peer, true)
|
||||
binding.callRecipientBadge.setBadgeFromRecipient(model.call.peer)
|
||||
binding.callRecipientName.text = model.call.peer.getDisplayName(context)
|
||||
presentCallInfo(event, direction, model.call.date)
|
||||
presentCallType(type)
|
||||
}
|
||||
|
||||
private fun presentCallInfo(event: CallTable.Event, direction: CallTable.Direction, date: Long) {
|
||||
binding.callInfo.text = context.getString(
|
||||
R.string.CallLogAdapter__s_dot_s,
|
||||
context.getString(getCallStateStringRes(event, direction)),
|
||||
DateUtils.getBriefRelativeTimeSpanString(context, Locale.getDefault(), date)
|
||||
)
|
||||
|
||||
binding.callInfo.setRelativeDrawables(
|
||||
start = getCallStateDrawableRes(event, direction)
|
||||
)
|
||||
|
||||
val color = ContextCompat.getColor(
|
||||
context,
|
||||
if (event == CallTable.Event.MISSED) {
|
||||
R.color.signal_colorError
|
||||
} else {
|
||||
R.color.signal_colorOnSurface
|
||||
}
|
||||
)
|
||||
|
||||
TextViewCompat.setCompoundDrawableTintList(
|
||||
binding.callInfo,
|
||||
ColorStateList.valueOf(color)
|
||||
)
|
||||
|
||||
binding.callInfo.setTextColor(color)
|
||||
}
|
||||
|
||||
private fun presentCallType(callType: CallTable.Type) {
|
||||
binding.callType.setImageResource(
|
||||
when (callType) {
|
||||
CallTable.Type.AUDIO_CALL -> R.drawable.symbol_phone_24
|
||||
CallTable.Type.VIDEO_CALL -> R.drawable.ic_video_call_24
|
||||
}
|
||||
)
|
||||
binding.callType.visible = true
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getCallStateDrawableRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
|
||||
if (callEvent == CallTable.Event.MISSED) {
|
||||
return R.drawable.ic_update_audio_call_missed_16
|
||||
}
|
||||
|
||||
return if (callDirection == CallTable.Direction.INCOMING) {
|
||||
R.drawable.ic_update_audio_call_incoming_16
|
||||
} else {
|
||||
R.drawable.ic_update_audio_call_outgoing_16
|
||||
}
|
||||
}
|
||||
|
||||
@StringRes
|
||||
private fun getCallStateStringRes(callEvent: CallTable.Event, callDirection: CallTable.Direction): Int {
|
||||
if (callEvent == CallTable.Event.MISSED) {
|
||||
return R.string.CallLogAdapter__missed
|
||||
}
|
||||
|
||||
return if (callDirection == CallTable.Direction.INCOMING) {
|
||||
R.string.CallLogAdapter__incoming
|
||||
} else {
|
||||
R.string.CallLogAdapter__outgoing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ClearFilterViewHolder(
|
||||
binding: ConversationListItemClearFilterBinding,
|
||||
onClearFilterClicked: () -> Unit
|
||||
) : BindingViewHolder<ClearFilterModel, ConversationListItemClearFilterBinding>(binding) {
|
||||
|
||||
init {
|
||||
binding.clearFilter.setOnClickListener { onClearFilterClicked() }
|
||||
}
|
||||
|
||||
override fun bind(model: ClearFilterModel) = Unit
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
/**
|
||||
* Invoked when a call row is clicked
|
||||
*/
|
||||
fun onCallClicked(callLogRow: CallLogRow.Call)
|
||||
|
||||
/**
|
||||
* Invoked when a call row is long-clicked
|
||||
*/
|
||||
fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean
|
||||
|
||||
/**
|
||||
* Invoked when the clear filter button is pressed
|
||||
*/
|
||||
fun onClearFilterClicked()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.conversation.ConversationIntents
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
|
||||
/**
|
||||
* Context menu for row items on the Call Log screen.
|
||||
*/
|
||||
class CallLogContextMenu(
|
||||
private val fragment: Fragment,
|
||||
private val callbacks: Callbacks
|
||||
) {
|
||||
fun show(anchor: View, call: CallLogRow.Call) {
|
||||
SignalContextMenu.Builder(anchor, anchor.parent as ViewGroup)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.BELOW)
|
||||
.show(
|
||||
listOfNotNull(
|
||||
getVideoCallActionItem(call),
|
||||
getAudioCallActionItem(call),
|
||||
getGoToChatActionItem(call),
|
||||
getInfoActionItem(call),
|
||||
getSelectActionItem(call),
|
||||
getDeleteActionItem(call)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun getVideoCallActionItem(call: CallLogRow.Call): ActionItem {
|
||||
// TODO [alex] -- Need group calling disposition to make this correct
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.ic_video_call_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__video_call)
|
||||
) {
|
||||
CommunicationActions.startVideoCall(fragment, call.peer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getAudioCallActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.peer.isGroup) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_phone_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__audio_call)
|
||||
) {
|
||||
CommunicationActions.startVoiceCall(fragment, call.peer)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGoToChatActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_open_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__go_to_chat)
|
||||
) {
|
||||
fragment.startActivity(ConversationIntents.createBuilder(fragment.requireContext(), call.peer.id, -1L).build())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getInfoActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_info_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__info)
|
||||
) {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectActionItem(call: CallLogRow.Call): ActionItem {
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_check_circle_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__select)
|
||||
) {
|
||||
callbacks.startSelection(call)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDeleteActionItem(call: CallLogRow.Call): ActionItem? {
|
||||
if (call.call.event == CallTable.Event.ONGOING) {
|
||||
return null
|
||||
}
|
||||
|
||||
return ActionItem(
|
||||
iconRes = R.drawable.symbol_trash_24,
|
||||
title = fragment.getString(R.string.CallContextMenu__delete)
|
||||
) {
|
||||
// TODO [alex] what does this actually delete
|
||||
}
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun startSelection(call: CallLogRow.Call)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
/**
|
||||
* Allows user to only display certain classes of calls.
|
||||
*/
|
||||
enum class CallLogFilter {
|
||||
/**
|
||||
* All call logs will be displayed
|
||||
*/
|
||||
ALL,
|
||||
|
||||
/**
|
||||
* Only missed calls will be displayed
|
||||
*/
|
||||
MISSED
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.viewModels
|
||||
import com.google.android.material.appbar.AppBarLayout
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.Observables
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationFilterBehavior
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnCloseClicked
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationListFilterPullView.OnFilterStateChanged
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
||||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
import org.thoughtcrime.securesms.main.SearchBinder
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import java.util.Objects
|
||||
|
||||
/**
|
||||
* Call Log tab.
|
||||
*/
|
||||
@SuppressLint("DiscouragedApi")
|
||||
class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Callbacks, CallLogContextMenu.Callbacks {
|
||||
|
||||
private val viewModel: CallLogViewModel by viewModels()
|
||||
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind)
|
||||
private val disposables = LifecycleDisposable()
|
||||
private val callLogContextMenu = CallLogContextMenu(this, this)
|
||||
private val callLogActionMode = CallLogActionMode(
|
||||
fragment = this,
|
||||
onResetSelectionState = {
|
||||
viewModel.clearSelected()
|
||||
}
|
||||
)
|
||||
|
||||
private val menuProvider = object : MenuProvider {
|
||||
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||
menuInflater.inflate(R.menu.calls_tab_menu, menu)
|
||||
}
|
||||
|
||||
override fun onPrepareMenu(menu: Menu) {
|
||||
val isFiltered = viewModel.filterSnapshot == CallLogFilter.MISSED
|
||||
menu.findItem(R.id.action_clear_missed_call_filter).isVisible = isFiltered
|
||||
menu.findItem(R.id.action_filter_missed_calls).isVisible = !isFiltered
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_settings -> startActivity(AppSettingsActivity.home(requireContext()))
|
||||
R.id.action_notification_profile -> NotificationProfileSelectionFragment.show(parentFragmentManager)
|
||||
R.id.action_filter_missed_calls -> filterMissedCalls()
|
||||
R.id.action_clear_missed_call_filter -> onClearFilterClicked()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
|
||||
|
||||
val adapter = CallLogAdapter(this)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
disposables += viewModel.controller
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe {
|
||||
adapter.setPagingController(it)
|
||||
}
|
||||
|
||||
disposables += Observables.combineLatest(viewModel.data, viewModel.selected)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { (data, selected) ->
|
||||
adapter.submitCallRows(data, selected)
|
||||
}
|
||||
|
||||
disposables += viewModel.selected
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.distinctUntilChanged()
|
||||
.subscribe {
|
||||
if (!it.isNotEmpty(adapter.itemCount)) {
|
||||
callLogActionMode.end()
|
||||
} else {
|
||||
callLogActionMode.setCount(it.count(adapter.itemCount))
|
||||
}
|
||||
}
|
||||
|
||||
binding.recycler.adapter = adapter
|
||||
|
||||
initializePullToFilter()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
initializeSearchAction()
|
||||
}
|
||||
|
||||
private fun initializeSearchAction() {
|
||||
val searchBinder = requireListener<SearchBinder>()
|
||||
searchBinder.getSearchAction().setOnClickListener {
|
||||
searchBinder.onSearchOpened()
|
||||
searchBinder.getSearchToolbar().get().setSearchInputHint(R.string.SearchToolbar_search)
|
||||
|
||||
searchBinder.getSearchToolbar().get().listener = object : Material3SearchToolbar.Listener {
|
||||
override fun onSearchTextChange(text: String) {
|
||||
viewModel.setSearchQuery(text.trim())
|
||||
}
|
||||
|
||||
override fun onSearchClosed() {
|
||||
viewModel.setSearchQuery("")
|
||||
searchBinder.onSearchClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializePullToFilter() {
|
||||
val collapsingToolbarLayout = binding.collapsingToolbar
|
||||
val openHeight = DimensionUnit.DP.toPixels(FilterLerp.FILTER_OPEN_HEIGHT).toInt()
|
||||
|
||||
binding.pullView.onFilterStateChanged = OnFilterStateChanged { state: FilterPullState?, source: ConversationFilterSource ->
|
||||
when (state) {
|
||||
FilterPullState.CLOSING -> viewModel.setFilter(CallLogFilter.ALL)
|
||||
FilterPullState.OPENING -> {
|
||||
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
|
||||
viewModel.setFilter(CallLogFilter.MISSED)
|
||||
}
|
||||
|
||||
FilterPullState.OPEN_APEX -> if (source === ConversationFilterSource.DRAG) {
|
||||
// TODO[alex] -- hint here? SignalStore.uiHints().incrementNeverDisplayPullToFilterTip()
|
||||
}
|
||||
|
||||
FilterPullState.CLOSE_APEX -> ViewUtil.setMinimumHeight(collapsingToolbarLayout, 0)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
binding.pullView.onCloseClicked = OnCloseClicked {
|
||||
onClearFilterClicked()
|
||||
}
|
||||
|
||||
val conversationFilterBehavior = Objects.requireNonNull<ConversationFilterBehavior?>((binding.recyclerCoordinatorAppBar.layoutParams as CoordinatorLayout.LayoutParams).behavior as ConversationFilterBehavior?)
|
||||
conversationFilterBehavior.callback = object : ConversationFilterBehavior.Callback {
|
||||
override fun onStopNestedScroll() {
|
||||
binding.pullView.onUserDragFinished()
|
||||
}
|
||||
|
||||
override fun canStartNestedScroll(): Boolean {
|
||||
return !isSearchOpen() || binding.pullView.isCloseable()
|
||||
}
|
||||
}
|
||||
|
||||
binding.recyclerCoordinatorAppBar.addOnOffsetChangedListener { layout: AppBarLayout, verticalOffset: Int ->
|
||||
val progress = 1 - verticalOffset.toFloat() / -layout.height
|
||||
binding.pullView.onUserDrag(progress)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCallClicked(callLogRow: CallLogRow.Call) {
|
||||
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
|
||||
viewModel.toggleSelected(callLogRow.id)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCallLongClicked(itemView: View, callLogRow: CallLogRow.Call): Boolean {
|
||||
callLogContextMenu.show(itemView, callLogRow)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onClearFilterClicked() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
override fun startSelection(call: CallLogRow.Call) {
|
||||
callLogActionMode.start()
|
||||
viewModel.toggleSelected(call.id)
|
||||
}
|
||||
|
||||
private fun filterMissedCalls() {
|
||||
binding.pullView.toggle()
|
||||
binding.recyclerCoordinatorAppBar.setExpanded(false, true)
|
||||
}
|
||||
|
||||
private fun isSearchOpen(): Boolean {
|
||||
return isSearchVisible() || viewModel.hasSearchQuery
|
||||
}
|
||||
|
||||
private fun isSearchVisible(): Boolean {
|
||||
return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
|
||||
requireListener<SearchBinder>().getSearchToolbar().get().getVisibility() == View.VISIBLE
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMultiSelectStarted()
|
||||
fun onMultiSelectFinished()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.signal.paging.PagedDataSource
|
||||
|
||||
class CallLogPagedDataSource(
|
||||
private val query: String?,
|
||||
private val filter: CallLogFilter,
|
||||
private val repository: CallRepository
|
||||
) : PagedDataSource<CallLogRow.Id, CallLogRow> {
|
||||
|
||||
private val hasFilter = filter == CallLogFilter.MISSED
|
||||
|
||||
override fun size(): Int {
|
||||
return repository.getCallsCount(query, filter) + (if (hasFilter) 1 else 0)
|
||||
}
|
||||
|
||||
override fun load(start: Int, length: Int, cancellationSignal: PagedDataSource.CancellationSignal): MutableList<CallLogRow> {
|
||||
val calls: MutableList<CallLogRow> = repository.getCalls(query, filter, start, length).toMutableList()
|
||||
|
||||
if (calls.size < length && hasFilter) {
|
||||
calls.add(CallLogRow.ClearFilter)
|
||||
}
|
||||
|
||||
return calls
|
||||
}
|
||||
|
||||
override fun getKey(data: CallLogRow): CallLogRow.Id = data.id
|
||||
|
||||
override fun load(key: CallLogRow.Id?): CallLogRow = error("Not supported")
|
||||
|
||||
interface CallRepository {
|
||||
fun getCallsCount(query: String?, filter: CallLogFilter): Int
|
||||
fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
|
||||
class CallLogRepository : CallLogPagedDataSource.CallRepository {
|
||||
override fun getCallsCount(query: String?, filter: CallLogFilter): Int {
|
||||
return SignalDatabase.calls.getCallsCount(query, filter)
|
||||
}
|
||||
|
||||
override fun getCalls(query: String?, filter: CallLogFilter, start: Int, length: Int): List<CallLogRow> {
|
||||
return SignalDatabase.calls.getCalls(start, length, query, filter)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* A row to be displayed in the call log
|
||||
*/
|
||||
sealed class CallLogRow {
|
||||
|
||||
abstract val id: Id
|
||||
|
||||
/**
|
||||
* An incoming, outgoing, or missed call.
|
||||
*/
|
||||
data class Call(
|
||||
val call: CallTable.Call,
|
||||
val peer: Recipient,
|
||||
val date: Long,
|
||||
override val id: Id = Id.Call(call.callId)
|
||||
) : CallLogRow()
|
||||
|
||||
/**
|
||||
* A row which can be used to clear the current filter.
|
||||
*/
|
||||
object ClearFilter : CallLogRow() {
|
||||
override val id: Id = Id.ClearFilter
|
||||
}
|
||||
|
||||
sealed class Id {
|
||||
data class Call(val callId: Long) : Id()
|
||||
object ClearFilter : Id()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
/**
|
||||
* Selection state object for call logs.
|
||||
*/
|
||||
sealed class CallLogSelectionState {
|
||||
abstract fun contains(callId: CallLogRow.Id): Boolean
|
||||
abstract fun isNotEmpty(totalCount: Int): Boolean
|
||||
|
||||
abstract fun count(totalCount: Int): Int
|
||||
|
||||
protected abstract fun select(callId: CallLogRow.Id): CallLogSelectionState
|
||||
protected abstract fun deselect(callId: CallLogRow.Id): CallLogSelectionState
|
||||
|
||||
fun toggle(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return if (contains(callId)) {
|
||||
deselect(callId)
|
||||
} else {
|
||||
select(callId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes contains an opt-in list of call logs.
|
||||
*/
|
||||
data class Includes(private val includes: Set<CallLogRow.Id>) : CallLogSelectionState() {
|
||||
override fun contains(callId: CallLogRow.Id): Boolean {
|
||||
return includes.contains(callId)
|
||||
}
|
||||
|
||||
override fun isNotEmpty(totalCount: Int): Boolean {
|
||||
return includes.isNotEmpty()
|
||||
}
|
||||
|
||||
override fun count(totalCount: Int): Int {
|
||||
return includes.size
|
||||
}
|
||||
|
||||
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Includes(includes + callId)
|
||||
}
|
||||
|
||||
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Includes(includes - callId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Excludes contains an opt-out list of call logs.
|
||||
*/
|
||||
data class Excludes(private val excluded: Set<CallLogRow.Id>) : CallLogSelectionState() {
|
||||
override fun contains(callId: CallLogRow.Id): Boolean = !excluded.contains(callId)
|
||||
override fun isNotEmpty(totalCount: Int): Boolean = excluded.size < totalCount
|
||||
|
||||
override fun count(totalCount: Int): Int {
|
||||
return totalCount - excluded.size
|
||||
}
|
||||
|
||||
override fun select(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Excludes(excluded - callId)
|
||||
}
|
||||
|
||||
override fun deselect(callId: CallLogRow.Id): CallLogSelectionState {
|
||||
return Excludes(excluded + callId)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun empty(): CallLogSelectionState = Includes(emptySet())
|
||||
fun selectAll(): CallLogSelectionState = Excludes(emptySet())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package org.thoughtcrime.securesms.calls.log
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.paging.ObservablePagedData
|
||||
import org.signal.paging.PagedData
|
||||
import org.signal.paging.PagingConfig
|
||||
import org.signal.paging.PagingController
|
||||
import org.thoughtcrime.securesms.util.rx.RxStore
|
||||
|
||||
/**
|
||||
* ViewModel for call log management.
|
||||
*/
|
||||
class CallLogViewModel(
|
||||
private val callLogRepository: CallLogRepository = CallLogRepository()
|
||||
) : ViewModel() {
|
||||
private val callLogStore = RxStore(CallLogState())
|
||||
private val pagedData: Observable<ObservablePagedData<CallLogRow.Id, CallLogRow>> = callLogStore
|
||||
.stateFlowable
|
||||
.toObservable()
|
||||
.map { (query, filter) ->
|
||||
PagedData.createForObservable(
|
||||
CallLogPagedDataSource(query, filter, callLogRepository),
|
||||
pagingConfig
|
||||
)
|
||||
}
|
||||
|
||||
val controller: Observable<PagingController<CallLogRow.Id>> = pagedData.map { it.controller }
|
||||
val data: Observable<MutableList<CallLogRow>> = pagedData.switchMap { it.data }
|
||||
val selected: Observable<CallLogSelectionState> = callLogStore
|
||||
.stateFlowable
|
||||
.toObservable()
|
||||
.map { it.selectionState }
|
||||
|
||||
val selectionStateSnapshot: CallLogSelectionState
|
||||
get() = callLogStore.state.selectionState
|
||||
val filterSnapshot: CallLogFilter
|
||||
get() = callLogStore.state.filter
|
||||
|
||||
val hasSearchQuery: Boolean
|
||||
get() = !callLogStore.state.query.isNullOrBlank()
|
||||
|
||||
private val pagingConfig = PagingConfig.Builder()
|
||||
.setBufferPages(1)
|
||||
.setPageSize(20)
|
||||
.setStartIndex(0)
|
||||
.build()
|
||||
|
||||
fun toggleSelected(callId: CallLogRow.Id) {
|
||||
callLogStore.update {
|
||||
val selectionState = it.selectionState.toggle(callId)
|
||||
it.copy(selectionState = selectionState)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelected() {
|
||||
callLogStore.update {
|
||||
it.copy(selectionState = CallLogSelectionState.empty())
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
callLogStore.update { it.copy(query = query) }
|
||||
}
|
||||
|
||||
fun setFilter(filter: CallLogFilter) {
|
||||
callLogStore.update { it.copy(filter = filter) }
|
||||
}
|
||||
|
||||
private data class CallLogState(
|
||||
val query: String? = null,
|
||||
val filter: CallLogFilter = CallLogFilter.ALL,
|
||||
val selectionState: CallLogSelectionState = CallLogSelectionState.empty()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.calls.new
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.fragment.app.Fragment
|
||||
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
|
||||
|
||||
class NewCallActivity : FragmentWrapperActivity() {
|
||||
@SuppressLint("DiscouragedApi")
|
||||
override fun getFragment(): Fragment = NewCallFragment()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.calls.new
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
|
||||
@SuppressLint("DiscouragedApi")
|
||||
class NewCallFragment : DSLSettingsFragment()
|
||||
@@ -275,11 +275,11 @@ class ConversationListFilterPullView @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
interface OnFilterStateChanged {
|
||||
fun interface OnFilterStateChanged {
|
||||
fun newState(state: FilterPullState, source: ConversationFilterSource)
|
||||
}
|
||||
|
||||
interface OnCloseClicked {
|
||||
fun interface OnCloseClicked {
|
||||
fun onCloseClicked()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import org.signal.core.util.requireObject
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogRow
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.SyncMessage.CallEvent
|
||||
|
||||
@@ -136,6 +139,86 @@ class CallTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTabl
|
||||
return calls
|
||||
}
|
||||
|
||||
private fun getCallsCursor(isCount: Boolean, offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): Cursor {
|
||||
val filterClause = when (filter) {
|
||||
CallLogFilter.ALL -> SqlUtil.buildQuery("")
|
||||
CallLogFilter.MISSED -> SqlUtil.buildQuery("$EVENT == ${Event.serialize(Event.MISSED)}")
|
||||
}
|
||||
|
||||
val queryClause = if (!searchTerm.isNullOrEmpty()) {
|
||||
val glob = SqlUtil.buildCaseInsensitiveGlobPattern(searchTerm)
|
||||
val selection =
|
||||
"""
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.BLOCKED} = ? AND ${RecipientTable.TABLE_NAME}.${RecipientTable.HIDDEN} = ? AND
|
||||
(
|
||||
sort_name GLOB ? OR
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME} GLOB ? OR
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.PHONE} GLOB ? OR
|
||||
${RecipientTable.TABLE_NAME}.${RecipientTable.EMAIL} GLOB ?
|
||||
)
|
||||
""".trimIndent()
|
||||
SqlUtil.buildQuery(selection, 0, 0, glob, glob, glob, glob)
|
||||
} else {
|
||||
SqlUtil.buildQuery("")
|
||||
}
|
||||
|
||||
val whereClause = filterClause and queryClause
|
||||
val where = if (whereClause.where.isNotEmpty()) {
|
||||
"WHERE ${whereClause.where}"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
val offsetLimit = if (limit > 0) {
|
||||
"LIMIT $offset,$limit"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
//language=sql
|
||||
val statement = """
|
||||
SELECT
|
||||
${if (isCount) "COUNT(*)," else "$TABLE_NAME.*, ${MessageTable.DATE_RECEIVED},"}
|
||||
LOWER(
|
||||
COALESCE(
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_JOINED_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.SYSTEM_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_JOINED_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.PROFILE_GIVEN_NAME}, ''),
|
||||
NULLIF(${RecipientTable.TABLE_NAME}.${RecipientTable.USERNAME}, '')
|
||||
)
|
||||
) AS sort_name
|
||||
FROM $TABLE_NAME
|
||||
INNER JOIN ${RecipientTable.TABLE_NAME} ON ${RecipientTable.TABLE_NAME}.${RecipientTable.ID} = $TABLE_NAME.$PEER
|
||||
INNER JOIN ${MessageTable.TABLE_NAME} ON ${MessageTable.TABLE_NAME}.${MessageTable.ID} = $TABLE_NAME.$MESSAGE_ID
|
||||
$where
|
||||
ORDER BY ${MessageTable.TABLE_NAME}.${MessageTable.DATE_RECEIVED} DESC
|
||||
$offsetLimit
|
||||
""".trimIndent()
|
||||
|
||||
return readableDatabase.query(statement, whereClause.whereArgs)
|
||||
}
|
||||
|
||||
fun getCallsCount(searchTerm: String?, filter: CallLogFilter): Int {
|
||||
return getCallsCursor(true, 0, 0, searchTerm, filter).use {
|
||||
it.moveToFirst()
|
||||
it.getInt(0)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCalls(offset: Int, limit: Int, searchTerm: String?, filter: CallLogFilter): List<CallLogRow.Call> {
|
||||
return getCallsCursor(false, offset, limit, searchTerm, filter).readToList {
|
||||
val call = Call.deserialize(it)
|
||||
val recipient = Recipient.resolved(call.peer)
|
||||
val date = it.requireLong(MessageTable.DATE_RECEIVED)
|
||||
CallLogRow.Call(
|
||||
call = call,
|
||||
peer = recipient,
|
||||
date = date
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
|
||||
@@ -131,7 +131,7 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
const val ID = "_id"
|
||||
const val SERVICE_ID = "uuid"
|
||||
const val PNI_COLUMN = "pni"
|
||||
private const val USERNAME = "username"
|
||||
const val USERNAME = "username"
|
||||
const val PHONE = "phone"
|
||||
const val EMAIL = "email"
|
||||
const val GROUP_ID = "group_id"
|
||||
@@ -167,9 +167,9 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
const val FORCE_SMS_SELECTION = "force_sms_selection"
|
||||
private const val CAPABILITIES = "capabilities"
|
||||
const val STORAGE_SERVICE_ID = "storage_service_key"
|
||||
private const val PROFILE_GIVEN_NAME = "signal_profile_name"
|
||||
const val PROFILE_GIVEN_NAME = "signal_profile_name"
|
||||
private const val PROFILE_FAMILY_NAME = "profile_family_name"
|
||||
private const val PROFILE_JOINED_NAME = "profile_joined_name"
|
||||
const val PROFILE_JOINED_NAME = "profile_joined_name"
|
||||
private const val MENTION_SETTING = "mention_setting"
|
||||
private const val STORAGE_PROTO = "storage_proto"
|
||||
private const val LAST_SESSION_RESET = "last_session_reset"
|
||||
@@ -183,12 +183,12 @@ open class RecipientTable(context: Context, databaseHelper: SignalDatabase) : Da
|
||||
private const val CUSTOM_CHAT_COLORS_ID = "custom_chat_colors_id"
|
||||
private const val BADGES = "badges"
|
||||
const val SEARCH_PROFILE_NAME = "search_signal_profile"
|
||||
private const val SORT_NAME = "sort_name"
|
||||
const val SORT_NAME = "sort_name"
|
||||
private const val IDENTITY_STATUS = "identity_status"
|
||||
private const val IDENTITY_KEY = "identity_key"
|
||||
private const val NEEDS_PNI_SIGNATURE = "needs_pni_signature"
|
||||
private const val UNREGISTERED_TIMESTAMP = "unregistered_timestamp"
|
||||
private const val HIDDEN = "hidden"
|
||||
const val HIDDEN = "hidden"
|
||||
const val REPORTING_TOKEN = "reporting_token"
|
||||
|
||||
@JvmField
|
||||
|
||||
@@ -24,6 +24,7 @@ import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFragment
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.TooltipPopup
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
@@ -48,7 +49,7 @@ import org.thoughtcrime.securesms.util.views.Stub
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
|
||||
class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder {
|
||||
class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_fragment), ConversationListFragment.Callback, Material3OnScrollHelperBinder, CallLogFragment.Callback {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(MainActivityListHostFragment::class.java)
|
||||
@@ -98,6 +99,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
R.id.conversationListFragment -> goToStateFromConversationList(state, controller)
|
||||
R.id.conversationListArchiveFragment -> Unit
|
||||
R.id.storiesLandingFragment -> goToStateFromStories(state, controller)
|
||||
R.id.callLogFragment -> goToStateFromCalling(state, controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +107,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
private fun goToStateFromConversationList(state: ConversationListTabsState, navController: NavController) {
|
||||
if (state.tab == ConversationListTab.CHATS) {
|
||||
return
|
||||
} else {
|
||||
} else if (state.tab == ConversationListTab.STORIES) {
|
||||
val cameraFab = requireView().findViewById<View>(R.id.camera_fab)
|
||||
val newConvoFab = requireView().findViewById<View>(R.id.fab)
|
||||
|
||||
@@ -127,14 +129,35 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
null,
|
||||
extras
|
||||
)
|
||||
} else {
|
||||
navController.navigate(
|
||||
R.id.action_conversationListFragment_to_callLogFragment,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToStateFromCalling(state: ConversationListTabsState, navController: NavController) {
|
||||
when (state.tab) {
|
||||
ConversationListTab.CALLS -> return
|
||||
ConversationListTab.CHATS -> navController.popBackStack()
|
||||
ConversationListTab.STORIES -> {
|
||||
navController.popBackStack()
|
||||
goToStateFromConversationList(state, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun goToStateFromStories(state: ConversationListTabsState, navController: NavController) {
|
||||
if (state.tab == ConversationListTab.STORIES) {
|
||||
return
|
||||
} else {
|
||||
navController.popBackStack()
|
||||
when (state.tab) {
|
||||
ConversationListTab.STORIES -> return
|
||||
ConversationListTab.CHATS -> navController.popBackStack()
|
||||
ConversationListTab.CALLS -> {
|
||||
navController.popBackStack()
|
||||
goToStateFromConversationList(state, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +205,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentToolbarForCallLogFragment() {
|
||||
presentToolbarForConversationListFragment()
|
||||
}
|
||||
|
||||
private fun presentToolbarForMultiselect() {
|
||||
_toolbar.visible = false
|
||||
if (_basicToolbar.resolved()) {
|
||||
@@ -332,6 +359,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
conversationListTabsViewModel.isShowingArchived(false)
|
||||
presentToolbarForStoriesLandingFragment()
|
||||
}
|
||||
R.id.callLogFragment -> {
|
||||
conversationListTabsViewModel.isShowingArchived(false)
|
||||
presentToolbarForCallLogFragment()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@ package org.thoughtcrime.securesms.stories.tabs
|
||||
|
||||
enum class ConversationListTab {
|
||||
CHATS,
|
||||
CALLS,
|
||||
STORIES
|
||||
}
|
||||
|
||||
@@ -46,4 +46,8 @@ class ConversationListTabRepository {
|
||||
refresh()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getNumberOfUnseenCalls(): Observable<Long> {
|
||||
return Observable.just(99)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import android.animation.ValueAnimator
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.animation.PathInterpolatorCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
@@ -16,6 +15,9 @@ import com.airbnb.lottie.LottieProperty
|
||||
import com.airbnb.lottie.model.KeyPath
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.ConversationListTabsBinding
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.text.NumberFormat
|
||||
|
||||
@@ -25,33 +27,24 @@ import java.text.NumberFormat
|
||||
class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
||||
|
||||
private val viewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
private lateinit var chatsUnreadIndicator: TextView
|
||||
private lateinit var storiesUnreadIndicator: TextView
|
||||
private lateinit var chatsIcon: LottieAnimationView
|
||||
private lateinit var storiesIcon: LottieAnimationView
|
||||
private lateinit var chatsPill: ImageView
|
||||
private lateinit var storiesPill: ImageView
|
||||
|
||||
private val binding by ViewBinderDelegate(ConversationListTabsBinding::bind)
|
||||
private var shouldBeImmediate = true
|
||||
private var pillAnimator: Animator? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
chatsUnreadIndicator = view.findViewById(R.id.chats_unread_indicator)
|
||||
storiesUnreadIndicator = view.findViewById(R.id.stories_unread_indicator)
|
||||
chatsIcon = view.findViewById(R.id.chats_tab_icon)
|
||||
storiesIcon = view.findViewById(R.id.stories_tab_icon)
|
||||
chatsPill = view.findViewById(R.id.chats_pill)
|
||||
storiesPill = view.findViewById(R.id.stories_pill)
|
||||
|
||||
val iconTint = ContextCompat.getColor(requireContext(), R.color.signal_colorOnSecondaryContainer)
|
||||
|
||||
chatsIcon.addValueCallback(
|
||||
binding.chatsTabIcon.addValueCallback(
|
||||
KeyPath("**"),
|
||||
LottieProperty.COLOR
|
||||
) { iconTint }
|
||||
|
||||
storiesIcon.addValueCallback(
|
||||
binding.callsTabIcon.addValueCallback(
|
||||
KeyPath("**"),
|
||||
LottieProperty.COLOR
|
||||
) { iconTint }
|
||||
|
||||
binding.storiesTabIcon.addValueCallback(
|
||||
KeyPath("**"),
|
||||
LottieProperty.COLOR
|
||||
) { iconTint }
|
||||
@@ -60,10 +53,16 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
||||
viewModel.onChatsSelected()
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.calls_tab_touch_point).setOnClickListener {
|
||||
viewModel.onCallsSelected()
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.stories_tab_touch_point).setOnClickListener {
|
||||
viewModel.onStoriesSelected()
|
||||
}
|
||||
|
||||
binding.callsTabGroup.visible = FeatureFlags.callsTab()
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) {
|
||||
update(it, shouldBeImmediate)
|
||||
shouldBeImmediate = false
|
||||
@@ -71,31 +70,39 @@ class ConversationListTabsFragment : Fragment(R.layout.conversation_list_tabs) {
|
||||
}
|
||||
|
||||
private fun update(state: ConversationListTabsState, immediate: Boolean) {
|
||||
chatsIcon.isSelected = state.tab == ConversationListTab.CHATS
|
||||
chatsPill.isSelected = state.tab == ConversationListTab.CHATS
|
||||
binding.chatsTabIcon.isSelected = state.tab == ConversationListTab.CHATS
|
||||
binding.chatsPill.isSelected = state.tab == ConversationListTab.CHATS
|
||||
|
||||
storiesIcon.isSelected = state.tab == ConversationListTab.STORIES
|
||||
storiesPill.isSelected = state.tab == ConversationListTab.STORIES
|
||||
binding.callsTabIcon.isSelected = state.tab == ConversationListTab.CALLS
|
||||
binding.callsPill.isSelected = state.tab == ConversationListTab.CALLS
|
||||
|
||||
binding.storiesTabIcon.isSelected = state.tab == ConversationListTab.STORIES
|
||||
binding.storiesPill.isSelected = state.tab == ConversationListTab.STORIES
|
||||
|
||||
val hasStateChange = state.tab != state.prevTab
|
||||
if (immediate) {
|
||||
chatsIcon.pauseAnimation()
|
||||
storiesIcon.pauseAnimation()
|
||||
binding.chatsTabIcon.pauseAnimation()
|
||||
binding.callsTabIcon.pauseAnimation()
|
||||
binding.storiesTabIcon.pauseAnimation()
|
||||
|
||||
chatsIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f
|
||||
storiesIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f
|
||||
binding.chatsTabIcon.progress = if (state.tab == ConversationListTab.CHATS) 1f else 0f
|
||||
binding.callsTabIcon.progress = if (state.tab == ConversationListTab.CALLS) 1f else 0f
|
||||
binding.storiesTabIcon.progress = if (state.tab == ConversationListTab.STORIES) 1f else 0f
|
||||
|
||||
runPillAnimation(0, chatsPill, storiesPill)
|
||||
runPillAnimation(0, binding.chatsPill, binding.callsPill, binding.storiesPill)
|
||||
} else if (hasStateChange) {
|
||||
runLottieAnimations(chatsIcon, storiesIcon)
|
||||
runPillAnimation(150, chatsPill, storiesPill)
|
||||
runLottieAnimations(binding.chatsTabIcon, binding.callsTabIcon, binding.storiesTabIcon)
|
||||
runPillAnimation(150, binding.chatsPill, binding.callsPill, binding.storiesPill)
|
||||
}
|
||||
|
||||
chatsUnreadIndicator.visible = state.unreadMessagesCount > 0
|
||||
chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount)
|
||||
binding.chatsUnreadIndicator.alpha = if (state.unreadMessagesCount > 0) 1f else 0f
|
||||
binding.chatsUnreadIndicator.text = formatCount(state.unreadMessagesCount)
|
||||
|
||||
storiesUnreadIndicator.visible = state.unreadStoriesCount > 0
|
||||
storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount)
|
||||
binding.callsUnreadIndicator.alpha = if (state.unreadCallsCount > 0) 1f else 0f
|
||||
binding.callsUnreadIndicator.text = formatCount(state.unreadCallsCount)
|
||||
|
||||
binding.storiesUnreadIndicator.alpha = if (state.unreadStoriesCount > 0) 1f else 0f
|
||||
binding.storiesUnreadIndicator.text = formatCount(state.unreadStoriesCount)
|
||||
|
||||
requireView().visible = state.visibilityState.isVisible()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ data class ConversationListTabsState(
|
||||
val tab: ConversationListTab = ConversationListTab.CHATS,
|
||||
val prevTab: ConversationListTab = ConversationListTab.STORIES,
|
||||
val unreadMessagesCount: Long = 0L,
|
||||
val unreadCallsCount: Long = 0L,
|
||||
val unreadStoriesCount: Long = 0L,
|
||||
val visibilityState: VisibilityState = VisibilityState()
|
||||
) {
|
||||
|
||||
@@ -27,6 +27,10 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) :
|
||||
store.update { it.copy(unreadMessagesCount = unreadChats) }
|
||||
}
|
||||
|
||||
disposables += repository.getNumberOfUnseenCalls().subscribe { unseenCalls ->
|
||||
store.update { it.copy(unreadCallsCount = unseenCalls) }
|
||||
}
|
||||
|
||||
disposables += repository.getNumberOfUnseenStories().subscribe { unseenStories ->
|
||||
store.update { it.copy(unreadStoriesCount = unseenStories) }
|
||||
}
|
||||
@@ -41,6 +45,11 @@ class ConversationListTabsViewModel(repository: ConversationListTabRepository) :
|
||||
store.update { it.copy(tab = ConversationListTab.CHATS, prevTab = it.tab) }
|
||||
}
|
||||
|
||||
fun onCallsSelected() {
|
||||
internalTabClickEvents.onNext(ConversationListTab.CALLS)
|
||||
store.update { it.copy(tab = ConversationListTab.CALLS, prevTab = it.tab) }
|
||||
}
|
||||
|
||||
fun onStoriesSelected() {
|
||||
internalTabClickEvents.onNext(ConversationListTab.STORIES)
|
||||
store.update { it.copy(tab = ConversationListTab.STORIES, prevTab = it.tab) }
|
||||
|
||||
@@ -106,6 +106,7 @@ public final class FeatureFlags {
|
||||
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.3";
|
||||
private static final String TEXT_FORMATTING = "android.textFormatting";
|
||||
private static final String ANY_ADDRESS_PORTS_KILL_SWITCH = "android.calling.fieldTrial.anyAddressPortsKillSwitch";
|
||||
private static final String CALLS_TAB = "android.calls.tab";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
@@ -162,7 +163,8 @@ public final class FeatureFlags {
|
||||
PAYPAL_ONE_TIME_DONATIONS,
|
||||
PAYPAL_RECURRING_DONATIONS,
|
||||
TEXT_FORMATTING,
|
||||
ANY_ADDRESS_PORTS_KILL_SWITCH
|
||||
ANY_ADDRESS_PORTS_KILL_SWITCH,
|
||||
CALLS_TAB
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -584,6 +586,13 @@ public final class FeatureFlags {
|
||||
return getBoolean(ANY_ADDRESS_PORTS_KILL_SWITCH, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not the calls tab is enabled
|
||||
*/
|
||||
public static boolean callsTab() {
|
||||
return getBoolean(CALLS_TAB, false);
|
||||
}
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.constraintlayout.widget.ConstraintSet
|
||||
|
||||
@@ -28,3 +30,17 @@ inline fun View.doOnEachLayout(crossinline action: (view: View) -> Unit): View.O
|
||||
addOnLayoutChangeListener(listener)
|
||||
return listener
|
||||
}
|
||||
|
||||
fun TextView.setRelativeDrawables(
|
||||
@DrawableRes start: Int = 0,
|
||||
@DrawableRes top: Int = 0,
|
||||
@DrawableRes bottom: Int = 0,
|
||||
@DrawableRes end: Int = 0
|
||||
) {
|
||||
setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
start,
|
||||
top,
|
||||
end,
|
||||
bottom
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user