mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Reimplement main activity toolbars in compose.
This commit is contained in:
committed by
Greyson Parrelli
parent
5f7ce0d96d
commit
f1985cf506
@@ -96,6 +96,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
AppStartup.getInstance().onCriticalRenderEventStart()
|
||||
super.onCreate(savedInstanceState, ready)
|
||||
conversationListTabsViewModel
|
||||
|
||||
setContent {
|
||||
val navState = rememberFragmentState()
|
||||
@@ -147,7 +148,6 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
|
||||
lifecycleDisposable.bindTo(this)
|
||||
|
||||
mediaController = VoiceNoteMediaController(this, true)
|
||||
conversationListTabsViewModel
|
||||
|
||||
handleDeepLinkIntent(intent)
|
||||
CachedInflater.from(this).clear()
|
||||
|
||||
@@ -47,7 +47,11 @@ class SelectFeaturedBadgeFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper? {
|
||||
return Material3OnScrollHelper(requireActivity(), scrollShadow, viewLifecycleOwner)
|
||||
return Material3OnScrollHelper(
|
||||
activity = requireActivity(),
|
||||
views = listOf(scrollShadow),
|
||||
lifecycleOwner = viewLifecycleOwner
|
||||
)
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: MappingAdapter) {
|
||||
|
||||
@@ -3,9 +3,6 @@ package org.thoughtcrime.securesms.calls.log
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.res.Resources
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -14,7 +11,6 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.view.ActionMode
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.app.SharedElementCallback
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
@@ -38,13 +34,10 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar
|
||||
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
|
||||
import org.thoughtcrime.securesms.calls.new.NewCallActivity
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.ProgressCardDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ScrollToPositionDelegate
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity
|
||||
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick
|
||||
import org.thoughtcrime.securesms.conversation.SignalBottomActionBarController
|
||||
@@ -57,8 +50,9 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
|
||||
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
|
||||
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SearchBinder
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
|
||||
@@ -82,7 +76,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
private var filterViewOffsetChangeListener: AppBarLayout.OnOffsetChangedListener? = null
|
||||
|
||||
private val viewModel: CallLogViewModel by activityViewModels()
|
||||
private val binding: CallLogFragmentBinding by ViewBinderDelegate(CallLogFragmentBinding::bind) {
|
||||
binding.recyclerCoordinatorAppBar.removeOnOffsetChangedListener(filterViewOffsetChangeListener)
|
||||
}
|
||||
@@ -95,35 +88,11 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
|
||||
|
||||
private val viewModel: CallLogViewModel by activityViewModels()
|
||||
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
|
||||
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
|
||||
menu.findItem(R.id.action_clear_call_history).isVisible = !viewModel.isEmpty
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
when (menuItem.itemId) {
|
||||
R.id.action_clear_call_history -> clearCallHistory()
|
||||
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
|
||||
}
|
||||
}
|
||||
private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
requireActivity().addMenuProvider(menuProvider, viewLifecycleOwner)
|
||||
initializeSharedElementTransition()
|
||||
|
||||
viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick)
|
||||
@@ -131,6 +100,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
val callLogAdapter = CallLogAdapter(this)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
disposables += mainToolbarViewModel.getCallLogEventsFlowable().subscribeBy {
|
||||
when (it) {
|
||||
MainToolbarViewModel.Event.CallLog.ApplyFilter -> filterMissedCalls()
|
||||
MainToolbarViewModel.Event.CallLog.ClearFilter -> onClearFilterClicked()
|
||||
MainToolbarViewModel.Event.CallLog.ClearHistory -> clearCallHistory()
|
||||
}
|
||||
}
|
||||
|
||||
callLogAdapter.setPagingController(viewModel.controller)
|
||||
callLogAdapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() {
|
||||
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
|
||||
@@ -163,7 +141,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
.subscribe { (selected, totalCount) ->
|
||||
if (selected.isNotEmpty(totalCount)) {
|
||||
callLogActionMode.setCount(selected.count(totalCount))
|
||||
} else {
|
||||
} else if (callLogActionMode.isInActionMode()) {
|
||||
callLogActionMode.end()
|
||||
}
|
||||
}
|
||||
@@ -268,19 +246,16 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
}
|
||||
|
||||
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() {
|
||||
disposables += mainToolbarViewModel.getSearchEventsFlowable().subscribeBy {
|
||||
when (it) {
|
||||
MainToolbarViewModel.Event.Search.Close -> {
|
||||
viewModel.setSearchQuery("")
|
||||
searchBinder.onSearchClosed()
|
||||
}
|
||||
MainToolbarViewModel.Event.Search.Open -> {
|
||||
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
|
||||
}
|
||||
is MainToolbarViewModel.Event.Search.Query -> {
|
||||
viewModel.setSearchQuery(it.query.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +269,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
when (state) {
|
||||
FilterPullState.CLOSING -> {
|
||||
viewModel.setFilter(CallLogFilter.ALL)
|
||||
mainToolbarViewModel.setCallLogFilter(CallLogFilter.ALL)
|
||||
binding.recycler.doAfterNextLayout {
|
||||
scrollToPositionDelegate.resetScrollPosition()
|
||||
}
|
||||
@@ -302,6 +278,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
FilterPullState.OPENING -> {
|
||||
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight)
|
||||
viewModel.setFilter(CallLogFilter.MISSED)
|
||||
mainToolbarViewModel.setCallLogFilter(CallLogFilter.MISSED)
|
||||
}
|
||||
|
||||
FilterPullState.OPEN_APEX -> if (source === ConversationFilterSource.DRAG) {
|
||||
@@ -438,16 +415,15 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
|
||||
|
||||
private fun closeSearchIfOpen(): Boolean {
|
||||
if (isSearchOpen()) {
|
||||
requireListener<SearchBinder>().getSearchToolbar().get().collapse()
|
||||
requireListener<SearchBinder>().onSearchClosed()
|
||||
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
|
||||
tabsViewModel.onSearchClosed()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun isSearchVisible(): Boolean {
|
||||
return requireListener<SearchBinder>().getSearchToolbar().resolved() &&
|
||||
requireListener<SearchBinder>().getSearchToolbar().get().visibility == View.VISIBLE
|
||||
return mainToolbarViewModel.state.value.mode == MainToolbarMode.SEARCH
|
||||
}
|
||||
|
||||
private fun performDeletion(count: Int, callLogStagedDeletion: CallLogStagedDeletion) {
|
||||
|
||||
@@ -83,7 +83,11 @@ abstract class DSLSettingsFragment(
|
||||
return null
|
||||
}
|
||||
|
||||
return Material3OnScrollHelper(requireActivity(), toolbar, viewLifecycleOwner)
|
||||
return Material3OnScrollHelper(
|
||||
activity = requireActivity(),
|
||||
views = listOf(toolbar),
|
||||
lifecycleOwner = viewLifecycleOwner
|
||||
)
|
||||
}
|
||||
|
||||
open fun onToolbarNavigationClicked() {
|
||||
|
||||
@@ -25,37 +25,42 @@ enum class BadgeImageSize(val sizeCode: Int) {
|
||||
BADGE_112(5)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeImageSmall(
|
||||
badge: Badge?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BadgeImage(badge, BadgeImageSize.SMALL, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeImageMedium(
|
||||
badge: Badge?,
|
||||
modifier: Modifier
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = {
|
||||
BadgeImageView(it, BadgeImageSize.MEDIUM)
|
||||
},
|
||||
update = {
|
||||
it.setBadge(badge)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
BadgeImage(badge, BadgeImageSize.MEDIUM, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BadgeImage112(
|
||||
badge: Badge?,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BadgeImage(badge, BadgeImageSize.BADGE_112, modifier)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BadgeImage(
|
||||
badge: Badge?,
|
||||
size: BadgeImageSize,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(modifier = modifier.background(color = Color.Black, shape = CircleShape))
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = {
|
||||
BadgeImageView(it, BadgeImageSize.BADGE_112)
|
||||
BadgeImageView(it, size)
|
||||
},
|
||||
update = {
|
||||
it.setBadge(badge)
|
||||
|
||||
@@ -111,7 +111,7 @@ class DonateToSignalFragment :
|
||||
}
|
||||
|
||||
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
|
||||
return object : Material3OnScrollHelper(requireActivity(), toolbar!!, viewLifecycleOwner) {
|
||||
return object : Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbar!!), lifecycleOwner = viewLifecycleOwner) {
|
||||
override val activeColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
|
||||
override val inactiveColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ class ManageDonationsFragment :
|
||||
}
|
||||
|
||||
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
|
||||
return object : Material3OnScrollHelper(requireActivity(), toolbar!!, viewLifecycleOwner) {
|
||||
return object : Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbar!!), lifecycleOwner = viewLifecycleOwner) {
|
||||
override val activeColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
|
||||
override val inactiveColorSet: ColorSet = ColorSet(R.color.transparent, R.color.signal_colorBackground)
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ class ConversationSettingsFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
override fun getMaterial3OnScrollHelper(toolbar: Toolbar?): Material3OnScrollHelper {
|
||||
return object : Material3OnScrollHelper(requireActivity(), toolbar!!, viewLifecycleOwner) {
|
||||
return object : Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbar!!), lifecycleOwner = viewLifecycleOwner) {
|
||||
override val inactiveColorSet = ColorSet(
|
||||
toolbarColorRes = R.color.signal_colorBackground_0,
|
||||
statusBarColorRes = R.color.signal_colorBackground
|
||||
|
||||
@@ -87,7 +87,7 @@ public class ContactShareEditActivity extends PassphraseRequiredActivity impleme
|
||||
|
||||
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||
toolbar.setNavigationOnClickListener(unused -> onBackPressed());
|
||||
Material3OnScrollHelper onScrollHelper = new Material3OnScrollHelper(this, Collections.singletonList(toolbar), Collections.emptyList(), this);
|
||||
Material3OnScrollHelper onScrollHelper = Material3OnScrollHelper.create(this, toolbar);
|
||||
onScrollHelper.attach(contactList);
|
||||
|
||||
ContactShareEditAdapter contactAdapter = new ContactShareEditAdapter(Glide.with(this), dynamicLanguage.getCurrentLocale(), this);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.app.Activity
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
@@ -12,7 +12,7 @@ import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
|
||||
* Scroll helper to manage the color state of the top bar and status bar.
|
||||
*/
|
||||
class ConversationToolbarOnScrollHelper(
|
||||
activity: Activity,
|
||||
activity: FragmentActivity,
|
||||
toolbarBackground: View,
|
||||
private val wallpaperProvider: () -> ChatWallpaper?,
|
||||
lifecycleOwner: LifecycleOwner
|
||||
|
||||
@@ -27,8 +27,6 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.annotation.WorkerThread;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.material.snackbar.Snackbar;
|
||||
@@ -51,7 +49,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
||||
private Stub<View> emptyState;
|
||||
private PulsingFloatingActionButton fab;
|
||||
private PulsingFloatingActionButton cameraFab;
|
||||
private Stub<Toolbar> toolbar;
|
||||
|
||||
public static ConversationListArchiveFragment newInstance() {
|
||||
return new ConversationListArchiveFragment();
|
||||
@@ -65,8 +62,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||
toolbar = requireCallback().getBasicToolbar();
|
||||
|
||||
super.onViewCreated(view, savedInstanceState);
|
||||
|
||||
coordinator = view.findViewById(R.id.coordinator);
|
||||
@@ -76,9 +71,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
||||
cameraFab = view.findViewById(R.id.camera_fab);
|
||||
foldersList = view.findViewById(R.id.chat_folder_list);
|
||||
|
||||
toolbar.get().setNavigationOnClickListener(v -> NavHostFragment.findNavController(this).popBackStack());
|
||||
toolbar.get().setTitle(R.string.AndroidManifest_archived_conversations);
|
||||
|
||||
fab.hide();
|
||||
cameraFab.hide();
|
||||
foldersList.setVisibility(View.GONE);
|
||||
@@ -98,11 +90,6 @@ public class ConversationListArchiveFragment extends ConversationListFragment im
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @NonNull Toolbar getToolbar(@NonNull View rootView) {
|
||||
return toolbar.get();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @StringRes int getArchivedSnackbarTitleRes() {
|
||||
return R.plurals.ConversationListFragment_moved_conversations_to_inbox;
|
||||
|
||||
@@ -36,7 +36,6 @@ import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
@@ -54,8 +53,6 @@ import androidx.annotation.WorkerThread;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.appcompat.view.ActionMode;
|
||||
import androidx.appcompat.widget.Toolbar;
|
||||
import androidx.appcompat.widget.TooltipCompat;
|
||||
import androidx.compose.ui.platform.ComposeView;
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||
import androidx.core.content.ContextCompat;
|
||||
@@ -87,7 +84,6 @@ import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.concurrent.SimpleTask;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.MainActivity;
|
||||
import org.thoughtcrime.securesms.MainFragment;
|
||||
import org.thoughtcrime.securesms.MainNavigator;
|
||||
import org.thoughtcrime.securesms.MuteDialog;
|
||||
@@ -112,7 +108,6 @@ import org.thoughtcrime.securesms.banner.banners.ServiceOutageBanner;
|
||||
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner;
|
||||
import org.thoughtcrime.securesms.banner.banners.UsernameOutOfSyncBanner;
|
||||
import org.thoughtcrime.securesms.components.DeleteSyncEducationDialog;
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar;
|
||||
import org.thoughtcrime.securesms.components.RatingManager;
|
||||
import org.thoughtcrime.securesms.components.SignalProgressDialog;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
@@ -121,7 +116,6 @@ import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton;
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity;
|
||||
import org.thoughtcrime.securesms.components.settings.app.chats.folders.ChatFolderRecord;
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.completed.InAppPaymentsBottomSheetDelegate;
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.errors.UnexpectedSubscriptionCancellation;
|
||||
import org.thoughtcrime.securesms.components.spoiler.SpoilerAnnotation;
|
||||
@@ -145,14 +139,13 @@ import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
import org.thoughtcrime.securesms.database.model.ThreadRecord;
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies;
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceAttachmentBackfillMissingJob;
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceAttachmentBackfillUpdateJob;
|
||||
import org.thoughtcrime.securesms.jobs.RefreshOwnProfileJob;
|
||||
import org.thoughtcrime.securesms.keyvalue.AccountValues;
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity;
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode;
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel;
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder;
|
||||
import org.thoughtcrime.securesms.main.SearchBinder;
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil;
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity;
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphone;
|
||||
@@ -166,7 +159,6 @@ import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.search.MessageResult;
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.storage.StorageSyncHelper;
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab;
|
||||
@@ -255,6 +247,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
private Stopwatch startupStopwatch;
|
||||
private ConversationListTabsViewModel conversationListTabsViewModel;
|
||||
private ContactSearchMediator contactSearchMediator;
|
||||
private MainToolbarViewModel mainToolbarViewModel;
|
||||
|
||||
private BannerManager bannerManager;
|
||||
|
||||
@@ -276,8 +269,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
@Override
|
||||
public void onCreate(Bundle icicle) {
|
||||
super.onCreate(icicle);
|
||||
setHasOptionsMenu(true);
|
||||
startupStopwatch = new Stopwatch("startup");
|
||||
startupStopwatch = new Stopwatch("startup");
|
||||
mainToolbarViewModel = new ViewModelProvider(getActivity()).get(MainToolbarViewModel.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -353,10 +346,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
switch (state) {
|
||||
case CLOSING:
|
||||
viewModel.setFiltered(false, source);
|
||||
mainToolbarViewModel.setChatFilter(ConversationFilter.OFF);
|
||||
break;
|
||||
case OPENING:
|
||||
ViewUtil.setMinimumHeight(collapsingToolbarLayout, openHeight);
|
||||
viewModel.setFiltered(true, source);
|
||||
mainToolbarViewModel.setChatFilter(ConversationFilter.UNREAD);
|
||||
break;
|
||||
case OPEN_APEX:
|
||||
if (source == ConversationFilterSource.DRAG) {
|
||||
@@ -437,8 +432,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
RatingManager.showRatingDialogIfNecessary(requireContext());
|
||||
|
||||
TooltipCompat.setTooltipText(requireCallback().getSearchAction(), getText(R.string.SearchToolbar_search_for_conversations_contacts_and_messages));
|
||||
|
||||
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
public void handleOnBackPressed() {
|
||||
@@ -516,11 +509,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
super.onResume();
|
||||
|
||||
initializeSearchListener();
|
||||
initializeFilterListener();
|
||||
EventBus.getDefault().register(this);
|
||||
itemAnimator.disable();
|
||||
SpoilerAnnotation.resetRevealedSpoilers();
|
||||
|
||||
if ((!requireCallback().getSearchToolbar().resolved() || !(requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE)) && list.getAdapter() != defaultAdapter) {
|
||||
if (mainToolbarViewModel.getState().getValue().getMode() != MainToolbarMode.SEARCH && list.getAdapter() != defaultAdapter) {
|
||||
setAdapter(defaultAdapter);
|
||||
}
|
||||
|
||||
@@ -586,7 +580,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
public void onPause() {
|
||||
super.onPause();
|
||||
|
||||
requireCallback().getSearchAction().setOnClickListener(null);
|
||||
fab.stopPulse();
|
||||
cameraFab.stopPulse();
|
||||
EventBus.getDefault().unregister(this);
|
||||
@@ -598,58 +591,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
AppForegroundObserver.removeListener(appForegroundObserver);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
|
||||
menu.clear();
|
||||
inflater.inflate(R.menu.text_secure_normal, menu);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareOptionsMenu(Menu menu) {
|
||||
menu.findItem(R.id.menu_clear_passphrase).setVisible(!SignalStore.settings().getPassphraseDisabled());
|
||||
|
||||
ConversationFilterRequest request = viewModel.getConversationFilterRequest();
|
||||
boolean isChatFilterEnabled = request != null && request.getFilter() == ConversationFilter.UNREAD;
|
||||
|
||||
menu.findItem(R.id.menu_filter_unread_chats).setVisible(!isChatFilterEnabled);
|
||||
menu.findItem(R.id.menu_clear_unread_filter).setVisible(isChatFilterEnabled);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(@NonNull MenuItem item) {
|
||||
super.onOptionsItemSelected(item);
|
||||
|
||||
int itemId = item.getItemId();
|
||||
|
||||
if (itemId == R.id.menu_new_group) {
|
||||
handleCreateGroup();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_settings) {
|
||||
handleDisplaySettings();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_clear_passphrase) {
|
||||
handleClearPassphrase();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_mark_all_read) {
|
||||
handleMarkAllRead();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_invite) {
|
||||
handleInvite();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_notification_profile) {
|
||||
handleNotificationProfile();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_filter_unread_chats) {
|
||||
handleFilterUnreadChats();
|
||||
return true;
|
||||
} else if (itemId == R.id.menu_clear_unread_filter) {
|
||||
onClearFilterClick();
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
@@ -718,14 +659,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
private boolean isSearchVisible() {
|
||||
return (requireCallback().getSearchToolbar().resolved() && requireCallback().getSearchToolbar().get().getVisibility() == View.VISIBLE);
|
||||
return mainToolbarViewModel.getState().getValue().getMode() == MainToolbarMode.SEARCH;
|
||||
}
|
||||
|
||||
private boolean closeSearchIfOpen() {
|
||||
if (isSearchOpen()) {
|
||||
setAdapter(defaultAdapter);
|
||||
requireCallback().getSearchToolbar().get().collapse();
|
||||
requireCallback().onSearchClosed();
|
||||
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL);
|
||||
conversationListTabsViewModel.onSearchClosed();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -839,49 +780,33 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
})
|
||||
);
|
||||
|
||||
requireCallback().getSearchAction().setOnClickListener(v -> {
|
||||
fadeOutButtonsAndMegaphone(250);
|
||||
requireCallback().onSearchOpened();
|
||||
|
||||
requireCallback().getSearchToolbar().get().setListener(new Material3SearchToolbar.Listener() {
|
||||
@Override
|
||||
public void onSearchTextChange(String text) {
|
||||
String trimmed = text.trim();
|
||||
|
||||
contactSearchMediator.onFilterChanged(trimmed);
|
||||
|
||||
if (trimmed.length() > 0) {
|
||||
if (activeAdapter != searchAdapter && list != null) {
|
||||
setAdapter(searchAdapter);
|
||||
}
|
||||
} else {
|
||||
if (activeAdapter != defaultAdapter) {
|
||||
if (list != null) {
|
||||
setAdapter(defaultAdapter);
|
||||
}
|
||||
}
|
||||
lifecycleDisposable.add(
|
||||
mainToolbarViewModel.getSearchEventsFlowable().subscribe(event -> {
|
||||
if (event instanceof MainToolbarViewModel.Event.Search.Open) {
|
||||
onSearchOpen();
|
||||
} else if (event instanceof MainToolbarViewModel.Event.Search.Close) {
|
||||
onSearchClose();
|
||||
} else if (event instanceof MainToolbarViewModel.Event.Search.Query) {
|
||||
onSearchQueryUpdated(((MainToolbarViewModel.Event.Search.Query) event).getQuery());
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearchClosed() {
|
||||
if (list != null) {
|
||||
setAdapter(defaultAdapter);
|
||||
private void initializeFilterListener() {
|
||||
lifecycleDisposable.add(
|
||||
mainToolbarViewModel.getChatEventsFlowable().subscribe(event -> {
|
||||
if (event instanceof MainToolbarViewModel.Event.Chats.ApplyFilter) {
|
||||
handleFilterUnreadChats();
|
||||
} else if (event instanceof MainToolbarViewModel.Event.Chats.ClearFilter) {
|
||||
onClearFilterClick();
|
||||
}
|
||||
requireCallback().onSearchClosed();
|
||||
fadeInButtonsAndMegaphone(250);
|
||||
}
|
||||
});
|
||||
updateSearchToolbarHint(Objects.requireNonNull(viewModel.getConversationFilterRequest()));
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private void updateSearchToolbarHint(@NonNull ConversationFilterRequest conversationFilterRequest) {
|
||||
Stub<Material3SearchToolbar> searchToolbar = requireCallback().getSearchToolbar();
|
||||
if (!searchToolbar.resolved()) {
|
||||
return;
|
||||
}
|
||||
searchToolbar.get().setSearchInputHint(
|
||||
mainToolbarViewModel.setSearchHint(
|
||||
conversationFilterRequest.getFilter() == ConversationFilter.OFF ? R.string.SearchToolbar_search : R.string.SearchToolbar_search_unread_chats
|
||||
);
|
||||
}
|
||||
@@ -1058,10 +983,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
startupStopwatch.stop(TAG);
|
||||
mediaControllerOwner.getVoiceNoteMediaController().finishPostpone();
|
||||
|
||||
if (getParentFragment() != null) {
|
||||
requireCallback().getSearchToolbar().get();
|
||||
}
|
||||
|
||||
Context context = getContext();
|
||||
if (context != null) {
|
||||
FrameLayout parent = new FrameLayout(context);
|
||||
@@ -1128,31 +1049,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
viewModel.onMegaphoneVisible(megaphone);
|
||||
}
|
||||
|
||||
private void handleCreateGroup() {
|
||||
getNavigator().goToGroupCreation();
|
||||
}
|
||||
|
||||
private void handleDisplaySettings() {
|
||||
getNavigator().goToAppSettings();
|
||||
}
|
||||
|
||||
private void handleClearPassphrase() {
|
||||
Intent intent = new Intent(requireActivity(), KeyCachingService.class);
|
||||
intent.setAction(KeyCachingService.CLEAR_KEY_ACTION);
|
||||
requireActivity().startService(intent);
|
||||
}
|
||||
|
||||
private void handleMarkAllRead() {
|
||||
Context context = requireContext();
|
||||
|
||||
SignalExecutors.BOUNDED.execute(() -> {
|
||||
List<MarkedMessageInfo> messageIds = SignalDatabase.threads().setAllThreadsRead();
|
||||
|
||||
AppDependencies.getMessageNotifier().updateNotification(context);
|
||||
MarkReadReceiver.process(messageIds);
|
||||
});
|
||||
}
|
||||
|
||||
private void handleMarkAsRead(@NonNull Collection<Long> ids) {
|
||||
Context context = requireContext();
|
||||
Stopwatch stopwatch = new Stopwatch("mark-read");
|
||||
@@ -1178,23 +1074,11 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
private void handleMarkAsUnread(@NonNull Collection<Long> ids) {
|
||||
Context context = requireContext();
|
||||
|
||||
SimpleTask.run(getViewLifecycleOwner().getLifecycle(), () -> {
|
||||
SignalDatabase.threads().setForcedUnread(ids);
|
||||
StorageSyncHelper.scheduleSyncForDataChange();
|
||||
return null;
|
||||
}, none -> {
|
||||
endActionModeIfActive();
|
||||
});
|
||||
}
|
||||
|
||||
private void handleInvite() {
|
||||
getNavigator().goToInvite();
|
||||
}
|
||||
|
||||
private void handleNotificationProfile() {
|
||||
NotificationProfileSelectionFragment.show(getParentFragmentManager());
|
||||
}, none -> endActionModeIfActive());
|
||||
}
|
||||
|
||||
private void handleFilterUnreadChats() {
|
||||
@@ -1592,7 +1476,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
|
||||
private void showAddToFolderBottomSheet(Set<Conversation> conversations) {
|
||||
List<Long> threadIds = new ArrayList<>();
|
||||
List<Long> threadIds = new ArrayList<>();
|
||||
List<Integer> threadTypes = new ArrayList<>();
|
||||
|
||||
for (Conversation conversation : conversations) {
|
||||
@@ -1608,8 +1492,8 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
|
||||
private int getThreadType(Conversation conversation) {
|
||||
boolean isIndividual = conversation.getThreadRecord().getRecipient().isIndividual();
|
||||
boolean isGroup = conversation.getThreadRecord().getRecipient().isPushGroup();
|
||||
int type;
|
||||
boolean isGroup = conversation.getThreadRecord().getRecipient().isPushGroup();
|
||||
int type;
|
||||
if (isIndividual) {
|
||||
type = AddToFolderBottomSheet.ThreadType.INDIVIDUAL.getValue();
|
||||
} else if (isGroup) {
|
||||
@@ -1689,10 +1573,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
return ((Callback) getParentFragment().getParentFragment());
|
||||
}
|
||||
|
||||
protected Toolbar getToolbar(@NonNull View rootView) {
|
||||
return requireCallback().getToolbar();
|
||||
}
|
||||
|
||||
protected @PluralsRes int getArchivedSnackbarTitleRes() {
|
||||
return R.plurals.ConversationListFragment_conversations_archived;
|
||||
}
|
||||
@@ -1815,7 +1695,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
@Override
|
||||
public void onReadAll(@NonNull ChatFolderRecord chatFolder) {
|
||||
if (chatFolder.getFolderType() == ChatFolderRecord.FolderType.ALL) {
|
||||
handleMarkAllRead();
|
||||
mainToolbarViewModel.markAllMessagesRead();
|
||||
} else {
|
||||
viewModel.markChatFolderRead(chatFolder);
|
||||
}
|
||||
@@ -1831,6 +1711,35 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
startActivity(AppSettingsActivity.chatFolders(requireContext()));
|
||||
}
|
||||
|
||||
private void onSearchOpen() {
|
||||
fadeOutButtonsAndMegaphone(250);
|
||||
}
|
||||
|
||||
private void onSearchClose() {
|
||||
if (list != null) {
|
||||
setAdapter(defaultAdapter);
|
||||
}
|
||||
fadeInButtonsAndMegaphone(250);
|
||||
}
|
||||
|
||||
private void onSearchQueryUpdated(@NonNull String query) {
|
||||
String trimmed = query.trim();
|
||||
|
||||
contactSearchMediator.onFilterChanged(trimmed);
|
||||
|
||||
if (!trimmed.isEmpty()) {
|
||||
if (activeAdapter != searchAdapter && list != null) {
|
||||
setAdapter(searchAdapter);
|
||||
}
|
||||
} else {
|
||||
if (activeAdapter != defaultAdapter) {
|
||||
if (list != null) {
|
||||
setAdapter(defaultAdapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ArchiveListenerCallback extends ItemTouchHelper.SimpleCallback {
|
||||
|
||||
private static final long SWIPE_ANIMATION_DURATION = 175;
|
||||
@@ -2083,13 +1992,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
|
||||
}
|
||||
}
|
||||
|
||||
public interface Callback extends Material3OnScrollHelperBinder, SearchBinder {
|
||||
@NonNull Toolbar getToolbar();
|
||||
|
||||
@NonNull View getUnreadPaymentsDot();
|
||||
|
||||
@NonNull Stub<Toolbar> getBasicToolbar();
|
||||
|
||||
public interface Callback extends Material3OnScrollHelperBinder {
|
||||
void updateProxyStatus(@NonNull WebSocketConnectionState state);
|
||||
|
||||
void onMultiSelectStarted();
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Circle Reveal Modifiers found here:
|
||||
* https://gist.github.com/darvld/eb3844474baf2f3fc6d3ab44a4b4b5f8
|
||||
*
|
||||
* A modifier that clips the composable content using a circular shape. The radius of the circle
|
||||
* will be determined by the [transitionProgress].
|
||||
*
|
||||
* The values of the progress should be between 0 and 1.
|
||||
*
|
||||
* By default, the circle is centered in the content, but custom positions may be specified using
|
||||
* [revealFrom]. Specified offsets should be between 0 (left/top) and 1 (right/bottom).
|
||||
*/
|
||||
fun Modifier.circularReveal(
|
||||
transitionProgress: State<Float>,
|
||||
revealFrom: Offset = Offset(0.5f, 0.5f)
|
||||
): Modifier {
|
||||
return drawWithCache {
|
||||
val path = Path()
|
||||
|
||||
val center = revealFrom.mapTo(size)
|
||||
val radius = calculateRadius(revealFrom, size)
|
||||
|
||||
path.addOval(Rect(center, radius * transitionProgress.value))
|
||||
|
||||
onDrawWithContent {
|
||||
clipPath(path) { this@onDrawWithContent.drawContent() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Offset.mapTo(size: Size): Offset {
|
||||
return Offset(x * size.width, y * size.height)
|
||||
}
|
||||
|
||||
private fun calculateRadius(normalizedOrigin: Offset, size: Size) = with(normalizedOrigin) {
|
||||
val x = (if (x > 0.5f) x else 1 - x) * size.width
|
||||
val y = (if (y > 0.5f) y else 1 - y) * size.height
|
||||
|
||||
sqrt(x * x + y * y)
|
||||
}
|
||||
@@ -1,53 +1,51 @@
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.ActionMenuView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.children
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.findNavController
|
||||
import androidx.navigation.fragment.FragmentNavigatorExtras
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.InviteActivity
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
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
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.ConversationListFragment
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.conversationlist.model.UnreadPaymentsLiveData
|
||||
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsState
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
|
||||
import org.thoughtcrime.securesms.util.AvatarUtil
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.Material3OnScrollHelper
|
||||
import org.thoughtcrime.securesms.util.TopToastPopup
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.runHideAnimation
|
||||
import org.thoughtcrime.securesms.util.runRevealAnimation
|
||||
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, CallLogFragment.Callback {
|
||||
@@ -59,16 +57,6 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
private val conversationListTabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private lateinit var _toolbarBackground: View
|
||||
private lateinit var _toolbar: Toolbar
|
||||
private lateinit var _basicToolbar: Stub<Toolbar>
|
||||
private lateinit var notificationProfileStatus: ImageView
|
||||
private lateinit var proxyStatus: ImageView
|
||||
private lateinit var _searchToolbar: Stub<Material3SearchToolbar>
|
||||
private lateinit var _searchAction: ImageView
|
||||
private lateinit var _unreadPaymentsDot: View
|
||||
private lateinit var _backupsFailedDot: View
|
||||
|
||||
private var previousTopToastPopup: TopToastPopup? = null
|
||||
|
||||
private val destinationChangedListener = DestinationChangedListener()
|
||||
@@ -79,28 +67,111 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
}
|
||||
}
|
||||
|
||||
private val toolbarCallback = object : MainToolbarCallback {
|
||||
override fun onNewGroupClick() {
|
||||
startActivity(CreateGroupActivity.newIntent(requireActivity()))
|
||||
}
|
||||
|
||||
override fun onClearPassphraseClick() {
|
||||
val intent = Intent(requireActivity(), KeyCachingService::class.java)
|
||||
intent.setAction(KeyCachingService.CLEAR_KEY_ACTION)
|
||||
requireActivity().startService(intent)
|
||||
}
|
||||
|
||||
override fun onMarkReadClick() {
|
||||
toolbarViewModel.markAllMessagesRead()
|
||||
}
|
||||
|
||||
override fun onInviteFriendsClick() {
|
||||
val intent = Intent(requireContext(), InviteActivity::class.java)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
override fun onFilterUnreadChatsClick() {
|
||||
toolbarViewModel.setChatFilter(ConversationFilter.UNREAD)
|
||||
}
|
||||
|
||||
override fun onClearUnreadChatsFilterClick() {
|
||||
toolbarViewModel.setChatFilter(ConversationFilter.OFF)
|
||||
}
|
||||
|
||||
override fun onSettingsClick() {
|
||||
openSettings.launch(AppSettingsActivity.home(requireContext()))
|
||||
}
|
||||
|
||||
override fun onNotificationProfileClick() {
|
||||
NotificationProfileSelectionFragment.show(parentFragmentManager)
|
||||
}
|
||||
|
||||
override fun onProxyClick() {
|
||||
startActivity(AppSettingsActivity.proxy(requireContext()))
|
||||
}
|
||||
|
||||
override fun onSearchClick() {
|
||||
conversationListTabsViewModel.onSearchOpened()
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.SEARCH)
|
||||
toolbarViewModel.emitEvent(MainToolbarViewModel.Event.Search.Open)
|
||||
}
|
||||
|
||||
override fun onClearCallHistoryClick() {
|
||||
toolbarViewModel.clearCallHistory()
|
||||
}
|
||||
|
||||
override fun onFilterMissedCallsClick() {
|
||||
toolbarViewModel.setCallLogFilter(CallLogFilter.MISSED)
|
||||
}
|
||||
|
||||
override fun onClearCallFilterClick() {
|
||||
toolbarViewModel.setCallLogFilter(CallLogFilter.ALL)
|
||||
}
|
||||
|
||||
override fun onStoryPrivacyClick() {
|
||||
startActivity(StorySettingsActivity.getIntent(requireContext()))
|
||||
}
|
||||
|
||||
override fun onCloseSearchClick() {
|
||||
conversationListTabsViewModel.onSearchClosed()
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
|
||||
toolbarViewModel.emitEvent(MainToolbarViewModel.Event.Search.Close)
|
||||
}
|
||||
|
||||
override fun onCloseArchiveClick() {
|
||||
getChildNavController().popBackStack()
|
||||
}
|
||||
|
||||
override fun onSearchQueryUpdated(query: String) {
|
||||
toolbarViewModel.setSearchQuery(query)
|
||||
}
|
||||
|
||||
override fun onNotificationProfileTooltipDismissed() {
|
||||
SignalStore.notificationProfile.hasSeenTooltip = true
|
||||
toolbarViewModel.setShowNotificationProfilesTooltip(false)
|
||||
}
|
||||
}
|
||||
|
||||
private val toolbarViewModel: MainToolbarViewModel by activityViewModels()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
|
||||
_toolbarBackground = view.findViewById(R.id.toolbar_background)
|
||||
_toolbar = view.findViewById(R.id.toolbar)
|
||||
_basicToolbar = Stub(view.findViewById(R.id.toolbar_basic_stub))
|
||||
notificationProfileStatus = view.findViewById(R.id.conversation_list_notification_profile_status)
|
||||
proxyStatus = view.findViewById(R.id.conversation_list_proxy_status)
|
||||
_searchAction = view.findViewById(R.id.search_action)
|
||||
_searchToolbar = Stub(view.findViewById(R.id.search_toolbar))
|
||||
_unreadPaymentsDot = view.findViewById(R.id.unread_payments_indicator)
|
||||
_backupsFailedDot = view.findViewById(R.id.backups_failed_indicator)
|
||||
val toolbarContainer = view.findViewById<ComposeView>(R.id.toolbar_container)
|
||||
toolbarContainer.setContent {
|
||||
val state by toolbarViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
notificationProfileStatus.setOnClickListener { handleNotificationProfile() }
|
||||
proxyStatus.setOnClickListener { onProxyStatusClicked() }
|
||||
SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) {
|
||||
MainToolbar(
|
||||
state = state,
|
||||
callback = toolbarCallback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
initializeSettingsTouchTarget()
|
||||
|
||||
(requireActivity() as AppCompatActivity).setSupportActionBar(_toolbar)
|
||||
UnreadPaymentsLiveData().observe(viewLifecycleOwner) { unread ->
|
||||
toolbarViewModel.setHasUnreadPayments(unread.isPresent)
|
||||
}
|
||||
|
||||
disposables += conversationListTabsViewModel.state.subscribeBy { state ->
|
||||
val controller: NavController = requireView().findViewById<View>(R.id.fragment_container).findNavController()
|
||||
val controller: NavController = getChildNavController()
|
||||
when (controller.currentDestination?.id) {
|
||||
R.id.conversationListFragment -> goToStateFromConversationList(state, controller)
|
||||
R.id.conversationListArchiveFragment -> Unit
|
||||
@@ -114,6 +185,10 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
}
|
||||
}
|
||||
|
||||
private fun getChildNavController(): NavController {
|
||||
return requireView().findViewById<View>(R.id.fragment_container).findNavController()
|
||||
}
|
||||
|
||||
private fun goToStateFromConversationList(state: ConversationListTabsState, navController: NavController) {
|
||||
if (state.tab == ConversationListTab.CHATS) {
|
||||
return
|
||||
@@ -168,13 +243,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
SimpleTask.run(viewLifecycleOwner.lifecycle, { Recipient.self() }, ::initializeProfileIcon)
|
||||
|
||||
_backupsFailedDot.alpha = if (BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator()) {
|
||||
1f
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
toolbarViewModel.refresh()
|
||||
|
||||
requireView()
|
||||
.findViewById<View>(R.id.fragment_container)
|
||||
@@ -195,40 +264,23 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
}
|
||||
|
||||
private fun presentToolbarForConversationListFragment() {
|
||||
if (_basicToolbar.resolved() && _basicToolbar.get().visible) {
|
||||
_toolbar.runRevealAnimation(R.anim.slide_from_start)
|
||||
}
|
||||
|
||||
_toolbar.visible = true
|
||||
_searchAction.visible = true
|
||||
|
||||
if (_basicToolbar.resolved() && _basicToolbar.get().visible) {
|
||||
_basicToolbar.get().runHideAnimation(R.anim.slide_to_end)
|
||||
}
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationDestination.CHATS)
|
||||
}
|
||||
|
||||
private fun presentToolbarForConversationListArchiveFragment() {
|
||||
_toolbar.runHideAnimation(R.anim.slide_to_start)
|
||||
_basicToolbar.get().runRevealAnimation(R.anim.slide_from_end)
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.BASIC, destination = MainNavigationDestination.CHATS)
|
||||
}
|
||||
|
||||
private fun presentToolbarForStoriesLandingFragment() {
|
||||
_toolbar.visible = true
|
||||
_searchAction.visible = true
|
||||
if (_basicToolbar.resolved()) {
|
||||
_basicToolbar.get().visible = false
|
||||
}
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationDestination.STORIES)
|
||||
}
|
||||
|
||||
private fun presentToolbarForCallLogFragment() {
|
||||
presentToolbarForConversationListFragment()
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.FULL, destination = MainNavigationDestination.CALLS)
|
||||
}
|
||||
|
||||
private fun presentToolbarForMultiselect() {
|
||||
_toolbar.visible = false
|
||||
if (_basicToolbar.resolved()) {
|
||||
_basicToolbar.get().visible = false
|
||||
}
|
||||
toolbarViewModel.setToolbarMode(MainToolbarMode.NONE)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
@@ -236,36 +288,6 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun getToolbar(): Toolbar {
|
||||
return _toolbar
|
||||
}
|
||||
|
||||
override fun getSearchAction(): ImageView {
|
||||
return _searchAction
|
||||
}
|
||||
|
||||
override fun getSearchToolbar(): Stub<Material3SearchToolbar> {
|
||||
return _searchToolbar
|
||||
}
|
||||
|
||||
override fun getUnreadPaymentsDot(): View {
|
||||
return _unreadPaymentsDot
|
||||
}
|
||||
|
||||
override fun getBasicToolbar(): Stub<Toolbar> {
|
||||
return _basicToolbar
|
||||
}
|
||||
|
||||
override fun onSearchOpened() {
|
||||
conversationListTabsViewModel.onSearchOpened()
|
||||
_searchToolbar.get().clearText()
|
||||
_searchToolbar.get().display(_searchAction.x + (_searchAction.width / 2.0f), _searchAction.y + (_searchAction.height / 2.0f))
|
||||
}
|
||||
|
||||
override fun onSearchClosed() {
|
||||
conversationListTabsViewModel.onSearchClosed()
|
||||
}
|
||||
|
||||
override fun onMultiSelectStarted() {
|
||||
presentToolbarForMultiselect()
|
||||
conversationListTabsViewModel.onMultiSelectStarted()
|
||||
@@ -280,43 +302,18 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
conversationListTabsViewModel.onMultiSelectFinished()
|
||||
}
|
||||
|
||||
private fun initializeProfileIcon(recipient: Recipient) {
|
||||
Log.d(TAG, "Initializing profile icon")
|
||||
val icon = requireView().findViewById<ImageView>(R.id.toolbar_icon)
|
||||
val imageView: BadgeImageView = requireView().findViewById(R.id.toolbar_badge)
|
||||
imageView.setBadgeFromRecipient(recipient)
|
||||
AvatarUtil.loadIconIntoImageView(recipient, icon, resources.getDimensionPixelSize(R.dimen.toolbar_avatar_size))
|
||||
}
|
||||
|
||||
private fun initializeSettingsTouchTarget() {
|
||||
val touchArea = requireView().findViewById<View>(R.id.toolbar_settings_touch_area)
|
||||
touchArea.setOnClickListener {
|
||||
BackupRepository.markBackupFailedIndicatorClicked()
|
||||
openSettings.launch(AppSettingsActivity.home(requireContext()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNotificationProfile() {
|
||||
NotificationProfileSelectionFragment.show(parentFragmentManager)
|
||||
}
|
||||
|
||||
private fun onProxyStatusClicked() {
|
||||
startActivity(AppSettingsActivity.proxy(requireContext()))
|
||||
}
|
||||
|
||||
override fun updateProxyStatus(state: WebSocketConnectionState) {
|
||||
if (SignalStore.proxy.isProxyEnabled) {
|
||||
proxyStatus.visibility = View.VISIBLE
|
||||
when (state) {
|
||||
WebSocketConnectionState.CONNECTING, WebSocketConnectionState.DISCONNECTING, WebSocketConnectionState.DISCONNECTED -> proxyStatus.setImageResource(R.drawable.ic_proxy_connecting_24)
|
||||
WebSocketConnectionState.CONNECTED -> proxyStatus.setImageResource(R.drawable.ic_proxy_connected_24)
|
||||
WebSocketConnectionState.AUTHENTICATION_FAILED,
|
||||
WebSocketConnectionState.REMOTE_DEPRECATED,
|
||||
WebSocketConnectionState.FAILED -> proxyStatus.setImageResource(R.drawable.ic_proxy_failed_24)
|
||||
else -> proxyStatus.visibility = View.GONE
|
||||
val proxyState: MainToolbarState.ProxyState = when (state) {
|
||||
WebSocketConnectionState.CONNECTING, WebSocketConnectionState.DISCONNECTING, WebSocketConnectionState.DISCONNECTED -> MainToolbarState.ProxyState.CONNECTING
|
||||
WebSocketConnectionState.CONNECTED -> MainToolbarState.ProxyState.CONNECTED
|
||||
WebSocketConnectionState.AUTHENTICATION_FAILED, WebSocketConnectionState.FAILED, WebSocketConnectionState.REMOTE_DEPRECATED -> MainToolbarState.ProxyState.FAILED
|
||||
else -> MainToolbarState.ProxyState.NONE
|
||||
}
|
||||
|
||||
toolbarViewModel.setProxyState(proxyState = proxyState)
|
||||
} else {
|
||||
proxyStatus.visibility = View.GONE
|
||||
toolbarViewModel.setProxyState(proxyState = MainToolbarState.ProxyState.NONE)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,30 +343,16 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
}
|
||||
}, 500L)
|
||||
}
|
||||
notificationProfileStatus.visibility = View.VISIBLE
|
||||
toolbarViewModel.setNotificationProfileEnabled(true)
|
||||
} else {
|
||||
notificationProfileStatus.visibility = View.GONE
|
||||
toolbarViewModel.setNotificationProfileEnabled(false)
|
||||
}
|
||||
|
||||
if (!SignalStore.notificationProfile.hasSeenTooltip && Util.hasItems(notificationProfiles)) {
|
||||
val target: View? = findOverflowMenuButton(_toolbar)
|
||||
if (target != null) {
|
||||
TooltipPopup.forTarget(target)
|
||||
.setText(R.string.ConversationListFragment__turn_your_notification_profile_on_or_off_here)
|
||||
.setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.signal_button_primary))
|
||||
.setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_button_primary_text))
|
||||
.setOnDismissListener { SignalStore.notificationProfile.hasSeenTooltip = true }
|
||||
.show(TooltipPopup.POSITION_BELOW)
|
||||
} else {
|
||||
Log.w(TAG, "Unable to find overflow menu to show Notification Profile tooltip")
|
||||
}
|
||||
toolbarViewModel.setShowNotificationProfilesTooltip(true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOverflowMenuButton(viewGroup: Toolbar): View? {
|
||||
return viewGroup.children.find { it is ActionMenuView }
|
||||
}
|
||||
|
||||
private fun presentToolbarForDestination(destination: NavDestination) {
|
||||
when (destination.id) {
|
||||
R.id.conversationListFragment -> {
|
||||
@@ -402,18 +385,24 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f
|
||||
|
||||
override fun bindScrollHelper(recyclerView: RecyclerView) {
|
||||
Material3OnScrollHelper(
|
||||
requireActivity(),
|
||||
listOf(_toolbarBackground),
|
||||
listOf(_searchToolbar),
|
||||
viewLifecycleOwner
|
||||
activity = requireActivity(),
|
||||
views = listOf(),
|
||||
viewStubs = listOf(),
|
||||
onSetToolbarColor = {
|
||||
toolbarViewModel.setToolbarColor(it)
|
||||
},
|
||||
lifecycleOwner = viewLifecycleOwner
|
||||
).attach(recyclerView)
|
||||
}
|
||||
|
||||
override fun bindScrollHelper(recyclerView: RecyclerView, chatFolders: RecyclerView, setChatFolder: (Int) -> Unit) {
|
||||
Material3OnScrollHelper(
|
||||
activity = requireActivity(),
|
||||
views = listOf(_toolbarBackground, chatFolders),
|
||||
viewStubs = listOf(_searchToolbar),
|
||||
views = listOf(chatFolders),
|
||||
viewStubs = listOf(),
|
||||
onSetToolbarColor = {
|
||||
toolbarViewModel.setToolbarColor(it)
|
||||
},
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
setChatFolderColor = setChatFolder
|
||||
).attach(recyclerView)
|
||||
|
||||
704
app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt
Normal file
704
app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt
Normal file
@@ -0,0 +1,704 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.EnterExitState
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.core.LinearOutSlowInEasing
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.positionInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DropdownMenus
|
||||
import org.signal.core.ui.compose.IconButtons
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.SignalPreview
|
||||
import org.signal.core.ui.compose.TextFields
|
||||
import org.signal.core.ui.compose.Tooltips
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.BadgeImageSmall
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
interface MainToolbarCallback {
|
||||
fun onNewGroupClick()
|
||||
fun onClearPassphraseClick()
|
||||
fun onMarkReadClick()
|
||||
fun onInviteFriendsClick()
|
||||
fun onFilterUnreadChatsClick()
|
||||
fun onClearUnreadChatsFilterClick()
|
||||
fun onSettingsClick()
|
||||
fun onNotificationProfileClick()
|
||||
fun onProxyClick()
|
||||
fun onSearchClick()
|
||||
fun onClearCallHistoryClick()
|
||||
fun onFilterMissedCallsClick()
|
||||
fun onClearCallFilterClick()
|
||||
fun onStoryPrivacyClick()
|
||||
fun onCloseSearchClick()
|
||||
fun onCloseArchiveClick()
|
||||
fun onSearchQueryUpdated(query: String)
|
||||
fun onNotificationProfileTooltipDismissed()
|
||||
|
||||
object Empty : MainToolbarCallback {
|
||||
override fun onNewGroupClick() = Unit
|
||||
override fun onClearPassphraseClick() = Unit
|
||||
override fun onMarkReadClick() = Unit
|
||||
override fun onInviteFriendsClick() = Unit
|
||||
override fun onFilterUnreadChatsClick() = Unit
|
||||
override fun onClearUnreadChatsFilterClick() = Unit
|
||||
override fun onSettingsClick() = Unit
|
||||
override fun onNotificationProfileClick() = Unit
|
||||
override fun onProxyClick() = Unit
|
||||
override fun onSearchClick() = Unit
|
||||
override fun onClearCallHistoryClick() = Unit
|
||||
override fun onFilterMissedCallsClick() = Unit
|
||||
override fun onClearCallFilterClick() = Unit
|
||||
override fun onStoryPrivacyClick() = Unit
|
||||
override fun onCloseSearchClick() = Unit
|
||||
override fun onCloseArchiveClick() = Unit
|
||||
override fun onSearchQueryUpdated(query: String) = Unit
|
||||
override fun onNotificationProfileTooltipDismissed() = Unit
|
||||
}
|
||||
}
|
||||
|
||||
enum class MainToolbarMode {
|
||||
NONE,
|
||||
FULL,
|
||||
BASIC,
|
||||
SEARCH
|
||||
}
|
||||
|
||||
data class MainToolbarState(
|
||||
val toolbarColor: Color? = null,
|
||||
val self: Recipient = Recipient.self(),
|
||||
val mode: MainToolbarMode = MainToolbarMode.FULL,
|
||||
val destination: MainNavigationDestination = MainNavigationDestination.CHATS,
|
||||
val chatFilter: ConversationFilter = ConversationFilter.OFF,
|
||||
val callFilter: CallLogFilter = CallLogFilter.ALL,
|
||||
val hasUnreadPayments: Boolean = false,
|
||||
val hasFailedBackups: Boolean = false,
|
||||
val hasEnabledNotificationProfile: Boolean = false,
|
||||
val showNotificationProfilesTooltip: Boolean = false,
|
||||
val hasPassphrase: Boolean = false,
|
||||
val proxyState: ProxyState = ProxyState.NONE,
|
||||
@StringRes val searchHint: Int = R.string.SearchToolbar_search,
|
||||
val searchQuery: String = ""
|
||||
) {
|
||||
enum class ProxyState(@DrawableRes val icon: Int) {
|
||||
NONE(-1),
|
||||
CONNECTING(R.drawable.ic_proxy_connecting_24),
|
||||
CONNECTED(R.drawable.ic_proxy_connected_24),
|
||||
FAILED(R.drawable.ic_proxy_failed_24)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainToolbar(
|
||||
state: MainToolbarState,
|
||||
callback: MainToolbarCallback
|
||||
) {
|
||||
if (state.mode == MainToolbarMode.NONE) {
|
||||
return
|
||||
}
|
||||
|
||||
Crossfade(
|
||||
targetState = state.mode != MainToolbarMode.BASIC
|
||||
) { targetState ->
|
||||
when (targetState) {
|
||||
true -> Box {
|
||||
var revealOffset by remember { mutableStateOf(Offset.Zero) }
|
||||
|
||||
BoxWithConstraints {
|
||||
val maxWidth = with(LocalDensity.current) {
|
||||
maxWidth.toPx()
|
||||
}
|
||||
|
||||
PrimaryToolbar(state, callback) {
|
||||
revealOffset = Offset(it / maxWidth, 0.5f)
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.mode == MainToolbarMode.SEARCH,
|
||||
enter = EnterTransition.None,
|
||||
exit = ExitTransition.None
|
||||
) {
|
||||
val visibility = transition.animateFloat(
|
||||
transitionSpec = { tween(durationMillis = 400, easing = LinearOutSlowInEasing) },
|
||||
label = "Visibility"
|
||||
) { state ->
|
||||
if (state == EnterExitState.Visible) 1f else 0f
|
||||
}
|
||||
|
||||
SearchToolbar(
|
||||
state = state,
|
||||
callback = callback,
|
||||
modifier = Modifier.circularReveal(visibility, revealOffset)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false -> ArchiveToolbar(state, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchToolbar(
|
||||
state: MainToolbarState,
|
||||
callback: MainToolbarCallback,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
TextFields.TextField(
|
||||
value = state.searchQuery,
|
||||
onValueChange = callback::onSearchQueryUpdated,
|
||||
leadingIcon = {
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onCloseSearchClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_left_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
errorContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
textStyle = MaterialTheme.typography.bodyLarge,
|
||||
shape = RoundedCornerShape(50),
|
||||
singleLine = true,
|
||||
placeholder = {
|
||||
Text(text = stringResource(state.searchHint))
|
||||
},
|
||||
modifier = modifier
|
||||
.background(color = state.toolbarColor ?: MaterialTheme.colorScheme.surface)
|
||||
.height(dimensionResource(R.dimen.signal_m3_toolbar_height))
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester)
|
||||
)
|
||||
|
||||
LaunchedEffect(state.mode) {
|
||||
if (state.mode == MainToolbarMode.SEARCH) {
|
||||
focusRequester.requestFocus()
|
||||
} else {
|
||||
focusRequester.freeFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ArchiveToolbar(
|
||||
state: MainToolbarState,
|
||||
callback: MainToolbarCallback
|
||||
) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = state.toolbarColor ?: MaterialTheme.colorScheme.surface
|
||||
),
|
||||
navigationIcon = {
|
||||
IconButtons.IconButton(onClick = {
|
||||
callback.onCloseArchiveClick()
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_arrow_left_24),
|
||||
contentDescription = stringResource(R.string.CallScreenTopBar__go_back)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(text = stringResource(R.string.AndroidManifest_archived_conversations))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun PrimaryToolbar(
|
||||
state: MainToolbarState,
|
||||
callback: MainToolbarCallback,
|
||||
onSearchButtonPositioned: (Float) -> Unit
|
||||
) {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = state.toolbarColor ?: MaterialTheme.colorScheme.surface
|
||||
),
|
||||
navigationIcon = {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.padding(start = 28.dp, end = 26.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
AvatarImage(
|
||||
recipient = state.self,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable(
|
||||
onClick = callback::onSettingsClick,
|
||||
onClickLabel = stringResource(R.string.conversation_list_settings_shortcut)
|
||||
)
|
||||
.size(28.dp)
|
||||
)
|
||||
|
||||
BadgeImageSmall(
|
||||
badge = state.self.featuredBadge,
|
||||
modifier = Modifier
|
||||
.padding(start = 14.dp, top = 16.dp)
|
||||
.size(16.dp)
|
||||
)
|
||||
|
||||
HeadsUpIndicator(
|
||||
state = state,
|
||||
modifier = Modifier.padding(start = 20.dp, bottom = 20.dp)
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.app_name)
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
NotificationProfileAction(state, callback)
|
||||
ProxyAction(state, callback)
|
||||
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onSearchClick,
|
||||
modifier = Modifier.onPlaced {
|
||||
onSearchButtonPositioned(it.positionInWindow().x + (it.size.width / 2f))
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_search_24),
|
||||
contentDescription = stringResource(R.string.conversation_list_search_description)
|
||||
)
|
||||
}
|
||||
|
||||
val controller = remember { DropdownMenus.MenuController() }
|
||||
val dismiss = remember(controller) { { controller.hide() } }
|
||||
|
||||
TooltipOverflowButton(
|
||||
onOverflowClick = { controller.show() },
|
||||
isTooltipVisible = state.showNotificationProfilesTooltip,
|
||||
onDismiss = { callback.onNotificationProfileTooltipDismissed() }
|
||||
)
|
||||
|
||||
DropdownMenus.Menu(
|
||||
controller = controller
|
||||
) {
|
||||
when (state.destination) {
|
||||
MainNavigationDestination.CHATS -> ChatDropdownItems(state, callback, dismiss)
|
||||
MainNavigationDestination.CALLS -> CallDropdownItems(state.callFilter, callback, dismiss)
|
||||
MainNavigationDestination.STORIES -> StoryDropDownItems(callback, dismiss)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TooltipOverflowButton(
|
||||
onOverflowClick: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
isTooltipVisible: Boolean
|
||||
) {
|
||||
Tooltips.PlainBelowAnchor(
|
||||
onDismiss = onDismiss,
|
||||
isTooltipVisible = isTooltipVisible,
|
||||
tooltipContent = {
|
||||
Text(text = stringResource(R.string.ConversationListFragment__turn_your_notification_profile_on_or_off_here))
|
||||
},
|
||||
anchorContent = {
|
||||
IconButtons.IconButton(
|
||||
onClick = onOverflowClick
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_more_vertical),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationProfileAction(
|
||||
state: MainToolbarState,
|
||||
callback: MainToolbarCallback
|
||||
) {
|
||||
if (state.hasEnabledNotificationProfile) {
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onNotificationProfileClick
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.ic_notification_profile_active),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ProxyAction(
|
||||
state: MainToolbarState,
|
||||
callback: MainToolbarCallback
|
||||
) {
|
||||
if (state.proxyState != MainToolbarState.ProxyState.NONE) {
|
||||
IconButtons.IconButton(
|
||||
onClick = callback::onProxyClick
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(state.proxyState.icon),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeadsUpIndicator(state: MainToolbarState, modifier: Modifier = Modifier) {
|
||||
if (!state.hasUnreadPayments && !state.hasFailedBackups) {
|
||||
return
|
||||
}
|
||||
|
||||
val color = if (state.hasFailedBackups) {
|
||||
Color(0xFFFFCC00)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(13.dp)
|
||||
.background(color = color, shape = CircleShape)
|
||||
) {
|
||||
// Intentionally empty
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StoryDropDownItems(callback: MainToolbarCallback, onOptionSelected: () -> Unit) {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.StoriesLandingFragment__story_privacy),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onStoryPrivacyClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CallDropdownItems(callFilter: CallLogFilter, callback: MainToolbarCallback, onOptionSelected: () -> Unit) {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.CallLogFragment__clear_call_history),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onClearCallHistoryClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
|
||||
if (callFilter == CallLogFilter.ALL) {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.CallLogFragment__filter_missed_calls),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onFilterMissedCallsClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.CallLogFragment__clear_filter),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onClearCallFilterClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__menu_settings),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onSettingsClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.ConversationListFragment__notification_profile),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onNotificationProfileClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatDropdownItems(state: MainToolbarState, callback: MainToolbarCallback, onOptionSelected: () -> Unit) {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__menu_new_group),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onNewGroupClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
|
||||
if (state.hasPassphrase) {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__menu_clear_passphrase),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onNewGroupClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__mark_all_as_read),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onMarkReadClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__invite_friends),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onInviteFriendsClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
|
||||
if (state.chatFilter == ConversationFilter.OFF) {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__filter_unread_chats),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onFilterUnreadChatsClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
} else {
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__clear_unread_filter),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onClearUnreadChatsFilterClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.text_secure_normal__menu_settings),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onSettingsClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenus.Item(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.ConversationListFragment__notification_profile),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
callback.onNotificationProfileClick()
|
||||
onOptionSelected()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun FullMainToolbarPreview() {
|
||||
Previews.Preview {
|
||||
var mode by remember { mutableStateOf(MainToolbarMode.FULL) }
|
||||
|
||||
MainToolbar(
|
||||
state = MainToolbarState(
|
||||
self = Recipient(isResolving = false),
|
||||
mode = mode,
|
||||
destination = MainNavigationDestination.CHATS,
|
||||
hasEnabledNotificationProfile = true,
|
||||
proxyState = MainToolbarState.ProxyState.CONNECTED,
|
||||
hasFailedBackups = true
|
||||
),
|
||||
callback = object : MainToolbarCallback by MainToolbarCallback.Empty {
|
||||
override fun onSearchClick() {
|
||||
mode = MainToolbarMode.SEARCH
|
||||
}
|
||||
|
||||
override fun onCloseSearchClick() {
|
||||
mode = MainToolbarMode.FULL
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun SearchToolbarPreview() {
|
||||
Previews.Preview {
|
||||
SearchToolbar(
|
||||
state = MainToolbarState(
|
||||
self = Recipient(isResolving = false, isSelf = true),
|
||||
searchQuery = "Test query"
|
||||
),
|
||||
callback = MainToolbarCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ArchiveToolbarPreview() {
|
||||
Previews.Preview {
|
||||
ArchiveToolbar(
|
||||
state = MainToolbarState(),
|
||||
callback = MainToolbarCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun TooltipOverflowButtonPreview() {
|
||||
Previews.Preview {
|
||||
TooltipOverflowButton(
|
||||
onOverflowClick = {},
|
||||
onDismiss = {},
|
||||
isTooltipVisible = true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
|
||||
|
||||
object MainToolbarRepository {
|
||||
/**
|
||||
* Mark all unread messages in the local database as read.
|
||||
*/
|
||||
fun markAllMessagesRead() {
|
||||
SignalExecutors.BOUNDED.execute {
|
||||
val messageIds = SignalDatabase.threads.setAllThreadsRead()
|
||||
AppDependencies.messageNotifier.updateNotification(AppDependencies.application)
|
||||
MarkReadReceiver.process(messageIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlowable
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository
|
||||
import org.thoughtcrime.securesms.calls.log.CallLogFilter
|
||||
import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
class MainToolbarViewModel : ViewModel() {
|
||||
private val internalStateFlow = MutableStateFlow(MainToolbarState())
|
||||
private val internalEvents = MutableSharedFlow<Event>()
|
||||
|
||||
val state: StateFlow<MainToolbarState> = internalStateFlow
|
||||
|
||||
fun refresh() {
|
||||
internalStateFlow.update {
|
||||
it.copy(
|
||||
self = Recipient.self(),
|
||||
hasFailedBackups = BackupRepository.shouldDisplayBackupFailedIndicator() || BackupRepository.shouldDisplayBackupAlreadyRedeemedIndicator(),
|
||||
hasPassphrase = !SignalStore.settings.passphraseDisabled
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun emitEvent(event: Event) {
|
||||
viewModelScope.launch {
|
||||
internalEvents.emit(event)
|
||||
}
|
||||
}
|
||||
|
||||
fun setToolbarColor(@ColorInt color: Int) {
|
||||
internalStateFlow.update {
|
||||
it.copy(toolbarColor = Color(color))
|
||||
}
|
||||
}
|
||||
|
||||
fun setSearchQuery(query: String) {
|
||||
internalStateFlow.update {
|
||||
it.copy(searchQuery = query)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
internalEvents.emit(Event.Search.Query(query))
|
||||
}
|
||||
}
|
||||
|
||||
@JvmOverloads
|
||||
fun setToolbarMode(mode: MainToolbarMode, destination: MainNavigationDestination? = null) {
|
||||
internalStateFlow.update {
|
||||
it.copy(mode = mode, destination = destination ?: it.destination, searchQuery = "")
|
||||
}
|
||||
}
|
||||
|
||||
fun setProxyState(proxyState: MainToolbarState.ProxyState) {
|
||||
internalStateFlow.update {
|
||||
it.copy(proxyState = proxyState)
|
||||
}
|
||||
}
|
||||
|
||||
fun setNotificationProfileEnabled(hasEnabledNotificationProfile: Boolean) {
|
||||
internalStateFlow.update {
|
||||
it.copy(hasEnabledNotificationProfile = hasEnabledNotificationProfile)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowNotificationProfilesTooltip(showNotificationProfilesTooltip: Boolean) {
|
||||
internalStateFlow.update {
|
||||
it.copy(showNotificationProfilesTooltip = showNotificationProfilesTooltip)
|
||||
}
|
||||
}
|
||||
|
||||
fun setHasUnreadPayments(hasUnreadPayments: Boolean) {
|
||||
internalStateFlow.update {
|
||||
it.copy(hasUnreadPayments = hasUnreadPayments)
|
||||
}
|
||||
}
|
||||
|
||||
fun setChatFilter(conversationFilter: ConversationFilter) {
|
||||
internalStateFlow.update {
|
||||
it.copy(chatFilter = conversationFilter)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
when (conversationFilter) {
|
||||
ConversationFilter.UNREAD -> internalEvents.emit(Event.Chats.ApplyFilter)
|
||||
else -> internalEvents.emit(Event.Chats.ClearFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCallLogFilter(callLogFilter: CallLogFilter) {
|
||||
internalStateFlow.update {
|
||||
it.copy(callFilter = callLogFilter)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
when (callLogFilter) {
|
||||
CallLogFilter.MISSED -> internalEvents.emit(Event.CallLog.ApplyFilter)
|
||||
else -> internalEvents.emit(Event.CallLog.ClearFilter)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSearchEventsFlowable(): Flowable<Event.Search> {
|
||||
return internalEvents.filterIsInstance(Event.Search::class).asFlowable()
|
||||
}
|
||||
|
||||
fun getCallLogEventsFlowable(): Flowable<Event.CallLog> {
|
||||
return internalEvents.filterIsInstance(Event.CallLog::class).asFlowable()
|
||||
}
|
||||
|
||||
fun getChatEventsFlowable(): Flowable<Event.Chats> {
|
||||
return internalEvents.filterIsInstance(Event.Chats::class).asFlowable()
|
||||
}
|
||||
|
||||
fun clearCallHistory() {
|
||||
viewModelScope.launch {
|
||||
internalEvents.emit(Event.CallLog.ClearHistory)
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllMessagesRead() {
|
||||
MainToolbarRepository.markAllMessagesRead()
|
||||
}
|
||||
|
||||
fun setSearchHint(@StringRes hint: Int) {
|
||||
internalStateFlow.update {
|
||||
it.copy(searchHint = hint)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface Event {
|
||||
sealed interface Search : Event {
|
||||
data object Open : Search
|
||||
data object Close : Search
|
||||
data class Query(val query: String) : Search
|
||||
}
|
||||
|
||||
sealed interface Chats : Event {
|
||||
data object ApplyFilter : Chats
|
||||
data object ClearFilter : Chats
|
||||
}
|
||||
|
||||
sealed interface CallLog : Event {
|
||||
data object ApplyFilter : CallLog
|
||||
data object ClearFilter : CallLog
|
||||
data object ClearHistory : CallLog
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package org.thoughtcrime.securesms.main
|
||||
|
||||
import android.widget.ImageView
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.util.views.Stub
|
||||
|
||||
interface SearchBinder {
|
||||
fun getSearchAction(): ImageView
|
||||
|
||||
fun getSearchToolbar(): Stub<Material3SearchToolbar>
|
||||
|
||||
fun onSearchOpened()
|
||||
|
||||
fun onSearchClosed()
|
||||
}
|
||||
@@ -84,7 +84,7 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
|
||||
list.adapter = adapter
|
||||
list.itemAnimator = null
|
||||
Material3OnScrollHelper(requireActivity(), toolbarShadow, viewLifecycleOwner).attach(list)
|
||||
Material3OnScrollHelper(activity = requireActivity(), views = listOf(toolbarShadow), lifecycleOwner = viewLifecycleOwner).attach(list)
|
||||
}
|
||||
|
||||
private fun initializeViewModel() {
|
||||
|
||||
@@ -4,9 +4,6 @@ import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
@@ -14,6 +11,7 @@ import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.core.app.ActivityOptionsCompat
|
||||
import androidx.core.app.SharedElementCallback
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -31,7 +29,6 @@ import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.banner.BannerManager
|
||||
import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner
|
||||
import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner
|
||||
import org.thoughtcrime.securesms.components.Material3SearchToolbar
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
@@ -42,8 +39,9 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.main.MainToolbarMode
|
||||
import org.thoughtcrime.securesms.main.MainToolbarViewModel
|
||||
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
|
||||
import org.thoughtcrime.securesms.main.SearchBinder
|
||||
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
@@ -53,7 +51,6 @@ import org.thoughtcrime.securesms.stories.StoryViewerArgs
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu
|
||||
import org.thoughtcrime.securesms.stories.dialogs.StoryDialogs
|
||||
import org.thoughtcrime.securesms.stories.my.MyStoriesActivity
|
||||
import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTab
|
||||
import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel
|
||||
import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity
|
||||
@@ -87,19 +84,10 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
|
||||
)
|
||||
|
||||
private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() })
|
||||
private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels()
|
||||
|
||||
private lateinit var adapter: MappingAdapter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setHasOptionsMenu(true)
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||
menu.clear()
|
||||
inflater.inflate(R.menu.story_landing_menu, menu)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
viewModel.isTransitioningToAnotherScreen = false
|
||||
@@ -109,25 +97,17 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
|
||||
AppDependencies.expireStoriesManager.scheduleIfNecessary()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
requireListener<SearchBinder>().getSearchAction().setOnClickListener(null)
|
||||
}
|
||||
|
||||
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() {
|
||||
lifecycleDisposable += mainToolbarViewModel.getSearchEventsFlowable().subscribeBy {
|
||||
when (it) {
|
||||
MainToolbarViewModel.Event.Search.Close -> {
|
||||
viewModel.setSearchQuery("")
|
||||
searchBinder.onSearchClosed()
|
||||
}
|
||||
MainToolbarViewModel.Event.Search.Open -> {
|
||||
mainToolbarViewModel.setSearchHint(R.string.SearchToolbar_search)
|
||||
}
|
||||
is MainToolbarViewModel.Event.Search.Query -> {
|
||||
viewModel.setSearchQuery(it.query.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,15 +390,6 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
|
||||
}
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return if (item.itemId == R.id.action_settings) {
|
||||
startActivityIfAble(StorySettingsActivity.getIntent(requireContext()))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("OVERRIDE_DEPRECATION")
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
|
||||
@@ -438,19 +409,15 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l
|
||||
}
|
||||
|
||||
private fun isSearchVisible(): Boolean {
|
||||
return requreSearchBinder().getSearchToolbar().resolved() && requreSearchBinder().getSearchToolbar().get().getVisibility() == View.VISIBLE
|
||||
return mainToolbarViewModel.state.value.mode == MainToolbarMode.SEARCH
|
||||
}
|
||||
|
||||
private fun closeSearchIfOpen(): Boolean {
|
||||
if (isSearchOpen()) {
|
||||
requreSearchBinder().getSearchToolbar().get().collapse()
|
||||
requreSearchBinder().onSearchClosed()
|
||||
mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL)
|
||||
tabsViewModel.onSearchClosed()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun requreSearchBinder(): SearchBinder {
|
||||
return requireListener()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ class CreateStoryWithViewersFragment : DSLSettingsFragment(
|
||||
}
|
||||
|
||||
Material3OnScrollHelper(
|
||||
context = requireContext(),
|
||||
activity = requireActivity(),
|
||||
setStatusBarColor = { requireListener<Callback>().setStatusBarColor(it) },
|
||||
getStatusBarColor = { requireListener<Callback>().getStatusBarColor() },
|
||||
views = listOf(binding.toolbar),
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
package org.thoughtcrime.securesms.util
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.annotation.ColorInt
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.NestedScrollView
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
@@ -22,40 +21,25 @@ import org.thoughtcrime.securesms.util.views.Stub
|
||||
* for other purposes.
|
||||
*/
|
||||
open class Material3OnScrollHelper(
|
||||
private val context: Context,
|
||||
private val setStatusBarColor: (Int) -> Unit,
|
||||
private val getStatusBarColor: () -> Int,
|
||||
private val activity: FragmentActivity,
|
||||
private val setStatusBarColor: (Int) -> Unit = { WindowUtil.setStatusBarColor(activity.window, it) },
|
||||
getStatusBarColor: () -> Int = { WindowUtil.getStatusBarColor(activity.window) },
|
||||
private val setChatFolderColor: (Int) -> Unit = {},
|
||||
private val views: List<View>,
|
||||
private val onSetToolbarColor: (Int) -> Unit = {},
|
||||
private val views: List<View> = emptyList(),
|
||||
private val viewStubs: List<Stub<out View>> = emptyList(),
|
||||
lifecycleOwner: LifecycleOwner
|
||||
) {
|
||||
|
||||
constructor(activity: Activity, view: View, lifecycleOwner: LifecycleOwner) : this(activity = activity, views = listOf(view), lifecycleOwner = lifecycleOwner)
|
||||
|
||||
constructor(activity: Activity, views: List<View>, viewStubs: List<Stub<out View>> = emptyList(), lifecycleOwner: LifecycleOwner) : this(
|
||||
activity = activity,
|
||||
views = views,
|
||||
viewStubs = viewStubs,
|
||||
lifecycleOwner = lifecycleOwner,
|
||||
setChatFolderColor = {}
|
||||
)
|
||||
|
||||
constructor(
|
||||
activity: Activity,
|
||||
views: List<View>,
|
||||
viewStubs: List<Stub<out View>> = emptyList(),
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
setChatFolderColor: (Int) -> Unit = {}
|
||||
) : this(
|
||||
context = activity,
|
||||
setStatusBarColor = { WindowUtil.setStatusBarColor(activity.window, it) },
|
||||
getStatusBarColor = { WindowUtil.getStatusBarColor(activity.window) },
|
||||
setChatFolderColor = setChatFolderColor,
|
||||
views = views,
|
||||
viewStubs = viewStubs,
|
||||
lifecycleOwner = lifecycleOwner
|
||||
)
|
||||
companion object {
|
||||
/**
|
||||
* Override for our single java usage.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun create(activity: FragmentActivity, toolbar: View): Material3OnScrollHelper {
|
||||
return Material3OnScrollHelper(activity = activity, views = listOf(toolbar), lifecycleOwner = activity)
|
||||
}
|
||||
}
|
||||
|
||||
open val activeColorSet: ColorSet = ColorSet(
|
||||
toolbarColorRes = R.color.signal_colorSurface2,
|
||||
@@ -116,9 +100,9 @@ open class Material3OnScrollHelper(
|
||||
|
||||
animator?.cancel()
|
||||
val colorSet = if (active == true) activeColorSet else inactiveColorSet
|
||||
setToolbarColor(ContextCompat.getColor(context, colorSet.toolbarColorRes))
|
||||
setStatusBarColor(ContextCompat.getColor(context, colorSet.statusBarColorRes))
|
||||
setChatFolderColor(ContextCompat.getColor(context, colorSet.chatFolderColorRes))
|
||||
setToolbarColor(ContextCompat.getColor(activity, colorSet.toolbarColorRes))
|
||||
setStatusBarColor(ContextCompat.getColor(activity, colorSet.statusBarColorRes))
|
||||
setChatFolderColor(ContextCompat.getColor(activity, colorSet.chatFolderColorRes))
|
||||
}
|
||||
|
||||
private fun updateActiveState(isActive: Boolean) {
|
||||
@@ -139,12 +123,12 @@ open class Material3OnScrollHelper(
|
||||
val endColorSet = if (isActive) activeColorSet else inactiveColorSet
|
||||
|
||||
if (hadActiveState) {
|
||||
val startToolbarColor = ContextCompat.getColor(context, startColorSet.toolbarColorRes)
|
||||
val endToolbarColor = ContextCompat.getColor(context, endColorSet.toolbarColorRes)
|
||||
val startStatusBarColor = ContextCompat.getColor(context, startColorSet.statusBarColorRes)
|
||||
val endStatusBarColor = ContextCompat.getColor(context, endColorSet.statusBarColorRes)
|
||||
val startChatFolderColor = ContextCompat.getColor(context, startColorSet.chatFolderColorRes)
|
||||
val endChatFolderColor = ContextCompat.getColor(context, endColorSet.chatFolderColorRes)
|
||||
val startToolbarColor = ContextCompat.getColor(activity, startColorSet.toolbarColorRes)
|
||||
val endToolbarColor = ContextCompat.getColor(activity, endColorSet.toolbarColorRes)
|
||||
val startStatusBarColor = ContextCompat.getColor(activity, startColorSet.statusBarColorRes)
|
||||
val endStatusBarColor = ContextCompat.getColor(activity, endColorSet.statusBarColorRes)
|
||||
val startChatFolderColor = ContextCompat.getColor(activity, startColorSet.chatFolderColorRes)
|
||||
val endChatFolderColor = ContextCompat.getColor(activity, endColorSet.chatFolderColorRes)
|
||||
|
||||
animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 200
|
||||
@@ -164,6 +148,7 @@ open class Material3OnScrollHelper(
|
||||
private fun setToolbarColor(@ColorInt color: Int) {
|
||||
views.forEach { it.setBackgroundColor(color) }
|
||||
viewStubs.filter { it.resolved() }.forEach { it.get().setBackgroundColor(color) }
|
||||
onSetToolbarColor(color)
|
||||
}
|
||||
|
||||
private inner class OnScrollListener : RecyclerView.OnScrollListener(), AppBarLayout.OnOffsetChangedListener, NestedScrollView.OnScrollChangeListener {
|
||||
|
||||
@@ -1,177 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
tools:viewBindingIgnore="true">
|
||||
|
||||
<View
|
||||
android:id="@+id/toolbar_background"
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/toolbar_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:background="@color/signal_colorBackground"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:minHeight="@dimen/signal_m3_toolbar_height"
|
||||
android:theme="?attr/actionBarStyle"
|
||||
android:visibility="gone"
|
||||
app:contentInsetStart="0dp"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:visibility="visible">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
||||
android:id="@+id/toolbar_icon"
|
||||
android:layout_width="@dimen/toolbar_avatar_size"
|
||||
android:layout_height="@dimen/toolbar_avatar_size"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_marginStart="@dimen/toolbar_avatar_margin"
|
||||
android:contentDescription="@string/conversation_list_settings_shortcut"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@tools:sample/avatars" />
|
||||
|
||||
<org.thoughtcrime.securesms.badges.BadgeImageView
|
||||
android:id="@+id/toolbar_badge"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_marginTop="16dp"
|
||||
app:badge_size="small"
|
||||
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
|
||||
app:layout_constraintTop_toTopOf="@id/toolbar_icon" />
|
||||
|
||||
<View
|
||||
android:id="@+id/toolbar_settings_touch_area"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/toolbar_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<View
|
||||
android:id="@+id/unread_payments_indicator"
|
||||
android:layout_width="13dp"
|
||||
android:layout_height="13dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:alpha="0"
|
||||
android:background="@drawable/unread_count_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/toolbar_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
|
||||
tools:alpha="1" />
|
||||
|
||||
<View
|
||||
android:id="@+id/backups_failed_indicator"
|
||||
android:layout_width="13dp"
|
||||
android:layout_height="13dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:alpha="0"
|
||||
android:background="@drawable/backups_failed_background"
|
||||
app:layout_constraintBottom_toBottomOf="@id/toolbar_icon"
|
||||
app:layout_constraintStart_toStartOf="@id/toolbar_icon"
|
||||
tools:alpha="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/conversation_list_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="30dp"
|
||||
android:text="@string/app_name"
|
||||
android:textAlignment="viewStart"
|
||||
android:textAppearance="@style/Signal.Text.TitleLarge"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/conversation_list_notification_profile_status"
|
||||
app:layout_constraintStart_toEndOf="@id/toolbar_icon"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/conversation_list_notification_profile_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:paddingHorizontal="3dp"
|
||||
android:paddingVertical="11dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/conversation_list_proxy_status"
|
||||
app:layout_constraintStart_toEndOf="@id/conversation_list_title"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/ic_notification_profile_active"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/conversation_list_proxy_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?selectableItemBackgroundBorderless"
|
||||
android:padding="12dp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@id/search_action"
|
||||
app:layout_constraintStart_toEndOf="@id/conversation_list_notification_profile_status"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:src="@drawable/ic_proxy_connected_24"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/search_action"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?actionBarItemBackground"
|
||||
android:contentDescription="@string/conversation_list_search_description"
|
||||
android:padding="12dp"
|
||||
android:tint="@color/signal_icon_tint_primary"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:srcCompat="@drawable/symbol_search_24" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/toolbar_basic_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:inflatedId="@+id/toolbar_basic"
|
||||
android:layout="@layout/conversation_list_archive_toolbar"
|
||||
android:minHeight="@dimen/signal_m3_toolbar_height"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/search_toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height"
|
||||
android:layout="@layout/conversation_list_search_toolbar"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/toolbar_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="bottom"
|
||||
app:constraint_referenced_ids="toolbar,toolbar_basic,toolbar_background" />
|
||||
android:layout_height="@dimen/signal_m3_toolbar_height" />
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_container"
|
||||
android:name="androidx.navigation.fragment.NavHostFragment"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar_barrier"
|
||||
android:layout_weight="1"
|
||||
app:navGraph="@navigation/main_activity_list" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
</LinearLayout>
|
||||
121
core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt
Normal file
121
core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.ui.compose
|
||||
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.PlainTooltip
|
||||
import androidx.compose.material3.TooltipBox
|
||||
import androidx.compose.material3.TooltipDefaults
|
||||
import androidx.compose.material3.rememberTooltipState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntRect
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.window.PopupPositionProvider
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNot
|
||||
|
||||
object Tooltips {
|
||||
|
||||
/**
|
||||
* Renders a tooltip below the anchor content regardless of space, aligning the end edge of each.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PlainBelowAnchor(
|
||||
onDismiss: () -> Unit,
|
||||
containerColor: Color = MaterialTheme.colorScheme.primary,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onPrimary,
|
||||
isTooltipVisible: Boolean,
|
||||
tooltipContent: @Composable () -> Unit,
|
||||
anchorContent: @Composable () -> Unit
|
||||
) {
|
||||
val tooltipState = rememberTooltipState(
|
||||
initialIsVisible = isTooltipVisible,
|
||||
isPersistent = true
|
||||
)
|
||||
|
||||
val caretSize = with(LocalDensity.current) {
|
||||
TooltipDefaults.caretSize.toSize()
|
||||
}
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = PositionBelowAnchor,
|
||||
state = tooltipState,
|
||||
tooltip = {
|
||||
PlainTooltip(
|
||||
shape = TooltipDefaults.plainTooltipContainerShape,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
modifier = Modifier.drawCaret { anchorLayoutCoordinates ->
|
||||
|
||||
val path = if (anchorLayoutCoordinates != null) {
|
||||
val anchorBounds = anchorLayoutCoordinates.boundsInWindow()
|
||||
val anchorMid = (anchorBounds.right - anchorBounds.left) / 2
|
||||
val position = Offset(size.width - anchorMid, 0f)
|
||||
|
||||
Path().apply {
|
||||
moveTo(x = position.x, y = position.y)
|
||||
lineTo(x = position.x + caretSize.width / 2, y = position.y)
|
||||
lineTo(x = position.x, y = position.y - caretSize.height)
|
||||
lineTo(x = position.x - caretSize.width / 2, y = position.y)
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
Path()
|
||||
}
|
||||
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawPath(path = path, color = containerColor)
|
||||
}
|
||||
}
|
||||
) {
|
||||
tooltipContent()
|
||||
}
|
||||
}
|
||||
) {
|
||||
anchorContent()
|
||||
}
|
||||
|
||||
LaunchedEffect(isTooltipVisible) {
|
||||
if (isTooltipVisible) {
|
||||
tooltipState.show()
|
||||
} else {
|
||||
tooltipState.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(tooltipState) {
|
||||
snapshotFlow { tooltipState.isVisible }
|
||||
.drop(1)
|
||||
.filterNot { it }
|
||||
.collect { onDismiss() }
|
||||
}
|
||||
}
|
||||
|
||||
private object PositionBelowAnchor : PopupPositionProvider {
|
||||
override fun calculatePosition(anchorBounds: IntRect, windowSize: IntSize, layoutDirection: LayoutDirection, popupContentSize: IntSize): IntOffset {
|
||||
val x = if (layoutDirection == LayoutDirection.Ltr) {
|
||||
anchorBounds.right - popupContentSize.width
|
||||
} else {
|
||||
anchorBounds.left
|
||||
}
|
||||
|
||||
return IntOffset(x, anchorBounds.bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user