From f1985cf506e0e54bafd883ec21ba4c4db8be0274 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 28 Mar 2025 14:34:04 -0300 Subject: [PATCH] Reimplement main activity toolbars in compose. --- .../thoughtcrime/securesms/MainActivity.kt | 2 +- .../featured/SelectFeaturedBadgeFragment.kt | 6 +- .../securesms/calls/log/CallLogFragment.kt | 80 +- .../settings/DSLSettingsFragment.kt | 6 +- .../settings/app/subscription/BadgeImage.kt | 35 +- .../donate/DonateToSignalFragment.kt | 2 +- .../manage/ManageDonationsFragment.kt | 2 +- .../ConversationSettingsFragment.kt | 2 +- .../ContactShareEditActivity.java | 2 +- .../v2/ConversationToolbarOnScrollHelper.kt | 4 +- .../ConversationListArchiveFragment.java | 13 - .../ConversationListFragment.java | 233 ++---- .../securesms/main/CircularRevealModifiers.kt | 57 ++ .../main/MainActivityListHostFragment.kt | 303 ++++---- .../securesms/main/MainToolbar.kt | 704 ++++++++++++++++++ .../securesms/main/MainToolbarRepository.kt | 24 + .../securesms/main/MainToolbarViewModel.kt | 168 +++++ .../securesms/main/SearchBinder.kt | 15 - .../messagedetails/MessageDetailsFragment.kt | 2 +- .../stories/landing/StoriesLandingFragment.kt | 65 +- .../create/CreateStoryWithViewersFragment.kt | 2 +- .../securesms/util/Material3OnScrollHelper.kt | 65 +- .../main_activity_list_host_fragment.xml | 172 +---- .../org/signal/core/ui/compose/Tooltips.kt | 121 +++ 24 files changed, 1405 insertions(+), 680 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/CircularRevealModifiers.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/SearchBinder.kt create mode 100644 core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index a7ac488471..18c298c86d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt index 90f50eac4b..88477ba66c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/self/featured/SelectFeaturedBadgeFragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index bcc0dbe157..166c0ed677 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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.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().getSearchToolbar().get().collapse() - requireListener().onSearchClosed() + mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL) + tabsViewModel.onSearchClosed() return true } return false } private fun isSearchVisible(): Boolean { - return requireListener().getSearchToolbar().resolved() && - requireListener().getSearchToolbar().get().visibility == View.VISIBLE + return mainToolbarViewModel.state.value.mode == MainToolbarMode.SEARCH } private fun performDeletion(count: Int, callLogStagedDeletion: CallLogStagedDeletion) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt index 99868096a2..1ac1e3f363 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/DSLSettingsFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt index cd5d5d1d17..e5360ab6c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/BadgeImage.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 71608da5c0..f61187b67f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt index 988a6a2642..affe5b0ea4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/manage/ManageDonationsFragment.kt @@ -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) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 9ed566d98a..e777010e2a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java index 738ff77a00..894f581dba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/ContactShareEditActivity.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt index 55968b8bbb..97172496d5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java index e764da05d4..3c0f7f408f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -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 emptyState; private PulsingFloatingActionButton fab; private PulsingFloatingActionButton cameraFab; - private Stub 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index ebd3b720b6..d905b861a0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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 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 messageIds = SignalDatabase.threads().setAllThreadsRead(); - - AppDependencies.getMessageNotifier().updateNotification(context); - MarkReadReceiver.process(messageIds); - }); - } - private void handleMarkAsRead(@NonNull Collection 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 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 conversations) { - List threadIds = new ArrayList<>(); + List threadIds = new ArrayList<>(); List 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 getBasicToolbar(); - + public interface Callback extends Material3OnScrollHelperBinder { void updateProxyStatus(@NonNull WebSocketConnectionState state); void onMultiSelectStarted(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/CircularRevealModifiers.kt b/app/src/main/java/org/thoughtcrime/securesms/main/CircularRevealModifiers.kt new file mode 100644 index 0000000000..725ac41fd8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/CircularRevealModifiers.kt @@ -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, + 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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt index d12490af8b..74c6f0ec5d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt @@ -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 - private lateinit var notificationProfileStatus: ImageView - private lateinit var proxyStatus: ImageView - private lateinit var _searchToolbar: Stub - 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(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(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(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(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 { - return _searchToolbar - } - - override fun getUnreadPaymentsDot(): View { - return _unreadPaymentsDot - } - - override fun getBasicToolbar(): Stub { - 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(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(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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt new file mode 100644 index 0000000000..b7685d6a28 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarRepository.kt new file mode 100644 index 0000000000..eae3c18f47 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarRepository.kt @@ -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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt new file mode 100644 index 0000000000..f03e7d7c39 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbarViewModel.kt @@ -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() + + val state: StateFlow = 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 { + return internalEvents.filterIsInstance(Event.Search::class).asFlowable() + } + + fun getCallLogEventsFlowable(): Flowable { + return internalEvents.filterIsInstance(Event.CallLog::class).asFlowable() + } + + fun getChatEventsFlowable(): Flowable { + 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 + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/SearchBinder.kt b/app/src/main/java/org/thoughtcrime/securesms/main/SearchBinder.kt deleted file mode 100644 index c69317c2a8..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/main/SearchBinder.kt +++ /dev/null @@ -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 - - fun onSearchOpened() - - fun onSearchClosed() -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt index 018d347630..821939d74b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.kt @@ -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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index a2b684f5ff..739e767cf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -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().getSearchAction().setOnClickListener(null) - } - private fun initializeSearchAction() { - val searchBinder = requireListener() - 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, 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() - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt index 50b911d497..5a29292a13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/create/CreateStoryWithViewersFragment.kt @@ -77,7 +77,7 @@ class CreateStoryWithViewersFragment : DSLSettingsFragment( } Material3OnScrollHelper( - context = requireContext(), + activity = requireActivity(), setStatusBarColor = { requireListener().setStatusBarColor(it) }, getStatusBarColor = { requireListener().getStatusBarColor() }, views = listOf(binding.toolbar), diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Material3OnScrollHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/util/Material3OnScrollHelper.kt index 3f8da83453..14e61d8635 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Material3OnScrollHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Material3OnScrollHelper.kt @@ -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, + private val onSetToolbarColor: (Int) -> Unit = {}, + private val views: List = emptyList(), private val viewStubs: List> = emptyList(), lifecycleOwner: LifecycleOwner ) { - constructor(activity: Activity, view: View, lifecycleOwner: LifecycleOwner) : this(activity = activity, views = listOf(view), lifecycleOwner = lifecycleOwner) - - constructor(activity: Activity, views: List, viewStubs: List> = emptyList(), lifecycleOwner: LifecycleOwner) : this( - activity = activity, - views = views, - viewStubs = viewStubs, - lifecycleOwner = lifecycleOwner, - setChatFolderColor = {} - ) - - constructor( - activity: Activity, - views: List, - viewStubs: List> = 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 { diff --git a/app/src/main/res/layout/main_activity_list_host_fragment.xml b/app/src/main/res/layout/main_activity_list_host_fragment.xml index e1e8ac943b..3dabb94b0a 100644 --- a/app/src/main/res/layout/main_activity_list_host_fragment.xml +++ b/app/src/main/res/layout/main_activity_list_host_fragment.xml @@ -1,177 +1,23 @@ - + android:layout_height="match_parent" + android:orientation="vertical" + tools:viewBindingIgnore="true"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="@dimen/signal_m3_toolbar_height" /> - \ No newline at end of file + \ No newline at end of file diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt new file mode 100644 index 0000000000..45893859a1 --- /dev/null +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Tooltips.kt @@ -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) + } + } +}