From b64f3a48bfa0fcf6f1d933e77030508349c2ef46 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Wed, 9 Apr 2025 11:30:46 -0300 Subject: [PATCH] Add proper adaptive material app scaffolding. --- .../thoughtcrime/securesms/MainActivity.kt | 303 ++++++++++++++++-- .../securesms/calls/log/CallLogFragment.kt | 62 ++-- .../components/InsetAwareConstraintLayout.kt | 16 +- .../conversation/v2/ConversationFragment.kt | 21 +- .../v2/ConversationToolbarOnScrollHelper.kt | 3 +- .../ConversationListArchiveFragment.java | 69 ++-- .../ConversationListFragment.java | 294 ++++------------- .../ConversationListViewModel.kt | 28 +- .../main/MainActivityListHostFragment.kt | 24 +- .../securesms/main/MainBottomChrome.kt | 47 +-- .../main/MainFloatingActionButtons.kt | 134 ++++---- .../securesms/main/MainMegaphoneContainer.kt | 18 +- .../securesms/main/MainNavigation.kt | 5 +- .../main/MainNavigationDetailLocation.kt | 5 +- .../securesms/main/MainNavigationViewModel.kt | 53 ++- .../securesms/main/MainToolbar.kt | 2 + .../stories/landing/StoriesLandingFragment.kt | 67 +--- .../util/task/SnackbarAsyncTask.java | 108 ------- .../securesms/window/AppScaffold.kt | 178 ++++++---- app/src/main/res/layout/call_log_fragment.xml | 43 --- .../res/layout/conversation_list_fragment.xml | 75 ----- .../layout/conversation_list_item_view.xml | 1 - .../res/layout/stories_landing_fragment.xml | 55 ---- .../res/layout/v2_conversation_fragment.xml | 9 +- app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/ids.xml | 1 + 26 files changed, 723 insertions(+), 899 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 0d61b2dab7..e4666fa7af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -5,35 +5,59 @@ package org.thoughtcrime.securesms +import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle +import android.view.MotionEvent import android.view.View import android.view.ViewTreeObserver +import android.widget.Toast import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.fragment.app.DialogFragment import androidx.fragment.compose.AndroidFragment import androidx.fragment.compose.rememberFragmentState +import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.getSerializableCompat import org.signal.donations.StripeApi import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show import org.thoughtcrime.securesms.calls.log.CallLogFilter +import org.thoughtcrime.securesms.calls.new.NewCallActivity import org.thoughtcrime.securesms.components.ConnectivityWarningBottomSheet import org.thoughtcrime.securesms.components.DebugLogsPromptDialogFragment import org.thoughtcrime.securesms.components.DeviceSpecificNotificationBottomSheet @@ -43,36 +67,55 @@ import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity.Co import org.thoughtcrime.securesms.components.settings.app.notifications.manual.NotificationProfileSelectionFragment import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner +import org.thoughtcrime.securesms.conversation.v2.ConversationFragment +import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay import org.thoughtcrime.securesms.conversationlist.RelinkDevicesReminderBottomSheetFragment import org.thoughtcrime.securesms.conversationlist.RestoreCompleteBottomSheetDialog import org.thoughtcrime.securesms.conversationlist.model.ConversationFilter import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity import org.thoughtcrime.securesms.main.MainActivityListHostFragment +import org.thoughtcrime.securesms.main.MainBottomChrome +import org.thoughtcrime.securesms.main.MainBottomChromeCallback +import org.thoughtcrime.securesms.main.MainBottomChromeState +import org.thoughtcrime.securesms.main.MainMegaphoneState import org.thoughtcrime.securesms.main.MainNavigationDestination import org.thoughtcrime.securesms.main.MainNavigationDetailLocation +import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainToolbar import org.thoughtcrime.securesms.main.MainToolbarCallback import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarViewModel +import org.thoughtcrime.securesms.main.SnackbarState +import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity +import org.thoughtcrime.securesms.megaphone.Megaphone +import org.thoughtcrime.securesms.megaphone.MegaphoneActionController +import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.net.DeviceTransferBlockingInterceptor import org.thoughtcrime.securesms.notifications.VitalsViewModel +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment import org.thoughtcrime.securesms.service.KeyCachingService import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.stories.settings.StorySettingsActivity import org.thoughtcrime.securesms.stories.tabs.ConversationListTabRepository import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsFragment import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel +import org.thoughtcrime.securesms.util.AppForegroundObserver import org.thoughtcrime.securesms.util.AppStartup import org.thoughtcrime.securesms.util.CachedInflater import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.DynamicTheme +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.SplashScreenUtil import org.thoughtcrime.securesms.util.WindowUtil import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.window.AppScaffold +import org.thoughtcrime.securesms.window.WindowSizeClass class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner, MainNavigator.NavigatorProvider { @@ -119,57 +162,153 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner private val toolbarViewModel: MainToolbarViewModel by viewModels() private val toolbarCallback = ToolbarCallback() + private val motionEventRelay: MotionEventRelay by viewModels() + private var onFirstRender = false + private val mainBottomChromeCallback = BottomChromeCallback() + private val megaphoneActionController = MainMegaphoneActionController() + + override fun dispatchTouchEvent(ev: MotionEvent?): Boolean { + return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev) + } + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) { enableEdgeToEdge() + AppStartup.getInstance().onCriticalRenderEventStart() super.onCreate(savedInstanceState, ready) conversationListTabsViewModel + AppForegroundObserver.addListener(object : AppForegroundObserver.Listener { + override fun onForeground() { + navigator.viewModel.getNextMegaphone() + } + }) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + navigator.viewModel.navigationEvents.collectLatest { + when (it) { + MainNavigationViewModel.NavigationEvent.STORY_CAMERA_FIRST -> { + mainBottomChromeCallback.onCameraClick(MainNavigationDestination.STORIES) + } + } + } + } + } + setContent { val navState = rememberFragmentState() val listHostState = rememberFragmentState() val detailLocation by navigator.viewModel.detailLocation.collectAsStateWithLifecycle(MainNavigationDetailLocation.Empty) + val snackbar by navigator.viewModel.snackbar.collectAsStateWithLifecycle() + val mainToolbarState by toolbarViewModel.state.collectAsStateWithLifecycle() + val megaphone by navigator.viewModel.megaphone.collectAsStateWithLifecycle() - LaunchedEffect(detailLocation) { - if (detailLocation is MainNavigationDetailLocation.Conversation) { - startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent) - overridePendingTransition(R.anim.slide_from_end, R.anim.fade_scale_out) + val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) { + MainBottomChromeState( + destination = mainToolbarState.destination, + snackbarState = snackbar, + mainToolbarMode = mainToolbarState.mode, + megaphoneState = MainMegaphoneState( + megaphone = megaphone, + mainToolbarMode = mainToolbarState.mode + ) + ) + } + + val scaffoldNavigator = rememberListDetailPaneScaffoldNavigator() + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + + val contentClip: Shape = remember(windowSizeClass) { + if (windowSizeClass.isExtended()) { + RoundedCornerShape(18.dp) + } else { + RectangleShape } } - AppScaffold( - bottomNavContent = { - AndroidFragment( - clazz = ConversationListTabsFragment::class.java, - fragmentState = navState - ) - }, - navRailContent = { - AndroidFragment( - clazz = ConversationListTabsFragment::class.java, - fragmentState = navState - ) - } - ) { - Column { - val state by toolbarViewModel.state.collectAsStateWithLifecycle() - - SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) { - MainToolbar( - state = state, - callback = toolbarCallback - ) + LaunchedEffect(detailLocation) { + if (detailLocation is MainNavigationDetailLocation.Conversation) { + if (RemoteConfig.largeScreenUi) { + scaffoldNavigator.navigateTo(ThreePaneScaffoldRole.Primary, detailLocation) + } else { + startActivity((detailLocation as MainNavigationDetailLocation.Conversation).intent) } - - AndroidFragment( - clazz = MainActivityListHostFragment::class.java, - fragmentState = listHostState, - modifier = Modifier.fillMaxSize() - ) } } + + SignalTheme(isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)) { + AppScaffold( + navigator = scaffoldNavigator, + bottomNavContent = { + AndroidFragment( + clazz = ConversationListTabsFragment::class.java, + fragmentState = navState + ) + }, + navRailContent = { + AndroidFragment( + clazz = ConversationListTabsFragment::class.java, + fragmentState = navState + ) + }, + listContent = { + val listContainerColor = if (windowSizeClass.isMedium()) { + SignalTheme.colors.colorSurface1 + } else { + MaterialTheme.colorScheme.surface + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(listContainerColor) + .clip(contentClip) + ) { + MainToolbar( + state = mainToolbarState, + callback = toolbarCallback + ) + + Box( + modifier = Modifier.weight(1f) + ) { + AndroidFragment( + clazz = MainActivityListHostFragment::class.java, + fragmentState = listHostState, + modifier = Modifier.fillMaxSize() + ) + + MainBottomChrome( + state = mainBottomChromeState, + callback = mainBottomChromeCallback, + megaphoneActionController = megaphoneActionController, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } + }, + detailContent = { + when (val destination = scaffoldNavigator.currentDestination?.contentKey) { + is MainNavigationDetailLocation.Conversation -> { + val fragmentState = key(destination) { rememberFragmentState() } + AndroidFragment( + clazz = ConversationFragment::class.java, + fragmentState = fragmentState, + arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." }, + modifier = Modifier + .background(color = MaterialTheme.colorScheme.surface) + .fillMaxSize() + .clip(contentClip) + ) + } + } + } + ) + } } val content: View = findViewById(android.R.id.content) @@ -260,11 +399,29 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner SplashScreenUtil.setSplashScreenThemeIfNecessary(this, SignalStore.settings.theme) } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray, deviceId: Int) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (requestCode == MainNavigator.REQUEST_CONFIG_CHANGES && resultCode == RESULT_CONFIG_CHANGED) { recreate() } + + if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) { + getNavigator().getViewModel().setSnackbar(SnackbarState(message = getString(R.string.ConfirmKbsPinFragment__pin_created))) + getNavigator().getViewModel().onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL) + } + + if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) { + val snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account.username) + getNavigator().getViewModel().setSnackbar( + SnackbarState( + message = snackbarString + ) + ) + } } override fun onFirstRender() { @@ -412,4 +569,86 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner toolbarViewModel.setShowNotificationProfilesTooltip(false) } } + + inner class BottomChromeCallback : MainBottomChromeCallback { + override fun onNewChatClick() { + startActivity(Intent(this@MainActivity, NewConversationActivity::class.java)) + } + + override fun onNewCallClick() { + startActivity(NewCallActivity.createIntent(this@MainActivity)) + } + + override fun onCameraClick(destination: MainNavigationDestination) { + val onGranted = { + startActivity( + MediaSelectionActivity.camera( + context = this@MainActivity, + isStory = destination == MainNavigationDestination.STORIES + ) + ) + } + + if (CameraXUtil.isSupported()) { + onGranted() + } else { + Permissions.with(this@MainActivity) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), R.drawable.symbol_camera_24) + .withPermanentDenialDialog( + getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), + null, + R.string.CameraXFragment_allow_access_camera, + R.string.CameraXFragment_to_capture_photos_videos, + supportFragmentManager + ) + .onAllGranted(onGranted) + .onAnyDenied { Toast.makeText(this@MainActivity, R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() } + .execute() + } + } + + override fun onMegaphoneVisible(megaphone: Megaphone) { + navigator.viewModel.onMegaphoneVisible(megaphone) + } + + override fun onSnackbarDismissed() { + navigator.viewModel.setSnackbar(null) + } + } + + inner class MainMegaphoneActionController : MegaphoneActionController { + override fun onMegaphoneNavigationRequested(intent: Intent) { + startActivity(intent) + } + + override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) { + startActivityForResult(intent, requestCode) + } + + override fun onMegaphoneToastRequested(string: String) { + getNavigator().viewModel.setSnackbar( + SnackbarState( + message = string + ) + ) + } + + override fun getMegaphoneActivity(): Activity { + return this@MainActivity + } + + override fun onMegaphoneSnooze(event: Megaphones.Event) { + getNavigator().viewModel.onMegaphoneSnoozed(event) + } + + override fun onMegaphoneCompleted(event: Megaphones.Event) { + getNavigator().viewModel.onMegaphoneCompleted(event) + } + + override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) { + dialogFragment.show(supportFragmentManager, "megaphone_dialog") + } + } } 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 6b62959d8e..33c2606eed 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 @@ -9,20 +9,16 @@ import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.ActionMode +import androidx.compose.material3.SnackbarDuration import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.app.SharedElementCallback -import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.RecyclerView -import androidx.transition.TransitionInflater import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.Flowables import io.reactivex.rxjava3.kotlin.subscribeBy import org.signal.core.util.DimensionUnit @@ -31,9 +27,7 @@ import org.signal.core.util.concurrent.addTo import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.MainNavigator 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.ProgressCardDialogFragment import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.ViewBinderDelegate @@ -51,9 +45,11 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.main.MainNavigationDestination +import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder +import org.thoughtcrime.securesms.main.SnackbarState import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.stories.tabs.ConversationListTabsViewModel import org.thoughtcrime.securesms.util.CommunicationActions @@ -62,7 +58,6 @@ import org.thoughtcrime.securesms.util.doAfterNextLayout import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.visible import java.util.Objects -import java.util.concurrent.TimeUnit /** * Call Log tab. @@ -91,10 +86,9 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal private val viewModel: CallLogViewModel by activityViewModels() private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() }) private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels() + private val mainNavigationViewModel: MainNavigationViewModel by activityViewModels() override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - initializeSharedElementTransition() - viewLifecycleOwner.lifecycle.addObserver(conversationUpdateTick) viewLifecycleOwner.lifecycle.addObserver(viewModel.callLogPeekHelper) @@ -150,9 +144,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal this.callLogAdapter = callLogAdapter requireListener().bindScrollHelper(binding.recycler, viewLifecycleOwner) - binding.fab.setOnClickListener { - startActivity(NewCallActivity.createIntent(requireContext())) - } binding.pullView.setPillText(R.string.CallLogFragment__filtered_by_missed) @@ -204,26 +195,6 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal callLogAdapter?.onTimestampTick() } - private fun initializeSharedElementTransition() { - ViewCompat.setTransitionName(binding.fab, "new_convo_fab") - ViewCompat.setTransitionName(binding.fabSharedElementTarget, "camera_fab") - - sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.change_transform_fabs) - setEnterSharedElementCallback(object : SharedElementCallback() { - override fun onSharedElementStart(sharedElementNames: MutableList?, sharedElements: MutableList?, sharedElementSnapshots: MutableList?) { - if (sharedElementNames?.contains("camera_fab") == true) { - this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_edit_24) - disposables += Single.timer(200, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - this@CallLogFragment.binding.fab.setImageResource(R.drawable.symbol_phone_plus_24) - this@CallLogFragment.binding.fabSharedElementTarget.alpha = 0f - } - } - } - }) - } - private fun initializeTapToScrollToTop(scrollToPositionDelegate: ScrollToPositionDelegate) { disposables += tabsViewModel.tabClickEvents .filter { it == MainNavigationDestination.CALLS } @@ -363,14 +334,22 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal override fun onStartAudioCallClicked(recipient: Recipient) { CommunicationActions.startVoiceCall(this, recipient) { - YouAreAlreadyInACallSnackbar.show(requireView()) + mainNavigationViewModel.setSnackbar( + SnackbarState( + getString(R.string.CommunicationActions__you_are_already_in_a_call) + ) + ) } } override fun onStartVideoCallClicked(recipient: Recipient, canUserBeginCall: Boolean) { if (canUserBeginCall) { CommunicationActions.startVideoCall(this, recipient) { - YouAreAlreadyInACallSnackbar.show(requireView()) + mainNavigationViewModel.setSnackbar( + SnackbarState( + getString(R.string.CommunicationActions__you_are_already_in_a_call) + ) + ) } } else { ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext()) @@ -461,13 +440,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal } CallLogDeletionResult.Success -> { - Snackbar - .make( - binding.root, - snackbarMessage, - Snackbar.LENGTH_SHORT + mainNavigationViewModel.setSnackbar( + SnackbarState( + message = snackbarMessage, + duration = SnackbarDuration.Short ) - .show() + ) } is CallLogDeletionResult.UnknownFailure -> { @@ -488,14 +466,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal val actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback) requireListener().onMultiSelectStarted() signalBottomActionBarController.setVisibility(true) - binding.fab.visible = false return actionMode } override fun onActionModeWillEnd() { requireListener().onMultiSelectFinished() signalBottomActionBarController.setVisibility(false) - binding.fab.visible = true } override fun getResources(): Resources = resources diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt index 7feca1e674..c5bb1306b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InsetAwareConstraintLayout.kt @@ -5,6 +5,7 @@ import android.os.Build import android.util.AttributeSet import android.util.DisplayMetrics import android.view.Surface +import android.view.View import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.Guideline import androidx.core.content.withStyledAttributes @@ -63,25 +64,34 @@ open class InsetAwareConstraintLayout @JvmOverloads constructor( private val displayMetrics = DisplayMetrics() private var overridingKeyboard: Boolean = false private var previousKeyboardHeight: Int = 0 + private var applyRootInsets: Boolean = false val isKeyboardShowing: Boolean get() = previousKeyboardHeight > 0 - init { - ViewCompat.setOnApplyWindowInsetsListener(this) { _, windowInsetsCompat -> + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + ViewCompat.setOnApplyWindowInsetsListener(insetTarget()) { _, windowInsetsCompat -> applyInsets(windowInsets = windowInsetsCompat.getInsets(windowTypes), keyboardInsets = windowInsetsCompat.getInsets(keyboardType)) windowInsetsCompat } + } + init { if (attrs != null) { context.withStyledAttributes(attrs, R.styleable.InsetAwareConstraintLayout) { + applyRootInsets = getBoolean(R.styleable.InsetAwareConstraintLayout_applyRootInsets, false) + if (getBoolean(R.styleable.InsetAwareConstraintLayout_animateKeyboardChanges, false)) { - ViewCompat.setWindowInsetsAnimationCallback(this@InsetAwareConstraintLayout, keyboardAnimator) + ViewCompat.setWindowInsetsAnimationCallback(insetTarget(), keyboardAnimator) } } } } + private fun insetTarget(): View = if (applyRootInsets) rootView else this + fun addKeyboardStateListener(listener: KeyboardStateListener) { keyboardStateListeners += listener } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 51e48d28f0..d7ca1e0805 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -346,6 +346,7 @@ import org.thoughtcrime.securesms.util.visible import org.thoughtcrime.securesms.verify.VerifyIdentityActivity import org.thoughtcrime.securesms.wallpaper.ChatWallpaper import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil +import org.thoughtcrime.securesms.window.WindowSizeClass.Companion.getWindowSizeClass import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -589,7 +590,10 @@ class ConversationFragment : binding.toolbar.isBackInvokedCallbackEnabled = false disposables.bindTo(viewLifecycleOwner) - FullscreenHelper(requireActivity()).showSystemUI() + + if (requireActivity() is ConversationActivity) { + FullscreenHelper(requireActivity()).showSystemUI() + } markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner) markReadHelper.ignoreViewReveals() @@ -1361,10 +1365,17 @@ class ConversationFragment : } private fun presentNavigationIconForNormal() { - binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24) - binding.toolbar.setNavigationContentDescription(R.string.ConversationFragment__content_description_back_button) - binding.toolbar.setNavigationOnClickListener { - requireActivity().finishAfterTransition() + val windowSizeClass = resources.getWindowSizeClass() + + if (windowSizeClass.isCompact()) { + binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24) + binding.toolbar.setNavigationContentDescription(R.string.ConversationFragment__content_description_back_button) + binding.toolbar.setNavigationOnClickListener { + requireActivity().finishAfterTransition() + } + } else { + binding.toolbar.navigationIcon = null + binding.toolbar.contentInsetStartWithNavigation = 0 } } 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 97172496d5..3c88aff53c 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 @@ -19,7 +19,8 @@ class ConversationToolbarOnScrollHelper( ) : Material3OnScrollHelper( activity = activity, views = listOf(toolbarBackground), - lifecycleOwner = lifecycleOwner + lifecycleOwner = lifecycleOwner, + setStatusBarColor = {} ) { override val activeColorSet: ColorSet get() = ColorSet(getActiveToolbarColor(wallpaperProvider() != null)) 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 3c0f7f408f..09cf803662 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListArchiveFragment.java @@ -17,7 +17,6 @@ package org.thoughtcrime.securesms.conversationlist; import android.annotation.SuppressLint; -import android.os.AsyncTask; import android.os.Bundle; import android.view.View; @@ -27,19 +26,24 @@ import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.annotation.WorkerThread; import androidx.appcompat.view.ActionMode; +import androidx.compose.material3.SnackbarDuration; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.snackbar.Snackbar; - +import org.signal.core.util.concurrent.LifecycleDisposable; +import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.components.registration.PulsingFloatingActionButton; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.main.SnackbarState; import org.thoughtcrime.securesms.util.ConversationUtil; -import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; import org.thoughtcrime.securesms.util.views.Stub; import java.util.Set; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import kotlin.Unit; + public class ConversationListArchiveFragment extends ConversationListFragment implements ActionMode.Callback { @@ -47,8 +51,7 @@ public class ConversationListArchiveFragment extends ConversationListFragment im private RecyclerView list; private RecyclerView foldersList; private Stub emptyState; - private PulsingFloatingActionButton fab; - private PulsingFloatingActionButton cameraFab; + private LifecycleDisposable lifecycleDisposable = new LifecycleDisposable(); public static ConversationListArchiveFragment newInstance() { return new ConversationListArchiveFragment(); @@ -64,15 +67,13 @@ public class ConversationListArchiveFragment extends ConversationListFragment im public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); + lifecycleDisposable.bindTo(getViewLifecycleOwner()); + coordinator = view.findViewById(R.id.coordinator); list = view.findViewById(R.id.list); emptyState = new Stub<>(view.findViewById(R.id.empty_state)); - fab = view.findViewById(R.id.fab); - cameraFab = view.findViewById(R.id.camera_fab); foldersList = view.findViewById(R.id.chat_folder_list); - fab.hide(); - cameraFab.hide(); foldersList.setVisibility(View.GONE); } @@ -118,26 +119,34 @@ public class ConversationListArchiveFragment extends ConversationListFragment im archiveDecoration.onArchiveStarted(); itemAnimator.enable(); - new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), - coordinator, - getResources().getQuantityString(R.plurals.ConversationListFragment_moved_conversations_to_inbox, 1, 1), - getString(R.string.ConversationListFragment_undo), - getResources().getColor(R.color.amber_500), - Snackbar.LENGTH_LONG, - false) - { - @Override - protected void executeAction(@Nullable Long parameter) { - SignalDatabase.threads().unarchiveConversation(threadId); - ConversationUtil.refreshRecipientShortcuts(); - } + lifecycleDisposable.add( + Completable + .fromAction(() -> { + SignalDatabase.threads().unarchiveConversation(threadId); + ConversationUtil.refreshRecipientShortcuts(); + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + getNavigator().getViewModel().setSnackbar(new SnackbarState( + getResources().getQuantityString(R.plurals.ConversationListFragment_moved_conversations_to_inbox, 1, 1), + new SnackbarState.ActionState( + getString(R.string.ConversationListFragment_undo), + R.color.amber_500, + () -> { + SignalExecutors.BOUNDED_IO.execute(() -> { + SignalDatabase.threads().archiveConversation(threadId); + ConversationUtil.refreshRecipientShortcuts(); + }); - @Override - protected void reverseAction(@Nullable Long parameter) { - SignalDatabase.threads().archiveConversation(threadId); - ConversationUtil.refreshRecipientShortcuts(); - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, threadId); + return Unit.INSTANCE; + } + ), + false, + SnackbarDuration.Long + )); + }) + ); } @Override 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 1d5a7c7856..8d07d7bf56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -16,11 +16,8 @@ */ package org.thoughtcrime.securesms.conversationlist; -import android.Manifest; import android.annotation.SuppressLint; -import android.app.Activity; import android.content.Context; -import android.content.Intent; import android.content.res.ColorStateList; import android.content.res.Configuration; import android.content.res.Resources; @@ -38,7 +35,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.inputmethod.InputMethodManager; import android.widget.FrameLayout; -import android.widget.Toast; import androidx.activity.OnBackPressedCallback; import androidx.annotation.ColorInt; @@ -50,11 +46,11 @@ import androidx.annotation.WorkerThread; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.view.ActionMode; +import androidx.compose.material3.SnackbarDuration; import androidx.compose.ui.platform.ComposeView; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; import androidx.core.view.ViewCompat; -import androidx.fragment.app.DialogFragment; import androidx.lifecycle.ViewModelProvider; import androidx.navigation.fragment.NavHostFragment; import androidx.recyclerview.widget.DefaultItemAnimator; @@ -70,7 +66,6 @@ import com.google.android.material.animation.ArgbEvaluatorCompat; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.CollapsingToolbarLayout; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.snackbar.Snackbar; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; @@ -84,7 +79,6 @@ import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.MainFragment; import org.thoughtcrime.securesms.MainNavigator; import org.thoughtcrime.securesms.MuteDialog; -import org.thoughtcrime.securesms.NewConversationActivity; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.backup.v2.ui.BackupAlert; import org.thoughtcrime.securesms.backup.v2.ui.BackupAlertBottomSheet; @@ -110,7 +104,6 @@ import org.thoughtcrime.securesms.components.SignalProgressDialog; import org.thoughtcrime.securesms.components.menu.ActionItem; import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar; 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.subscription.completed.InAppPaymentsBottomSheetDelegate; @@ -139,19 +132,13 @@ import org.thoughtcrime.securesms.groups.SelectionLimits; 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.MainNavigationDestination; import org.thoughtcrime.securesms.main.MainToolbarMode; import org.thoughtcrime.securesms.main.MainToolbarViewModel; import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder; -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; -import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity; -import org.thoughtcrime.securesms.megaphone.Megaphone; -import org.thoughtcrime.securesms.megaphone.MegaphoneActionController; -import org.thoughtcrime.securesms.megaphone.MegaphoneViewBuilder; +import org.thoughtcrime.securesms.main.SnackbarState; import org.thoughtcrime.securesms.megaphone.Megaphones; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; -import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.profiles.manage.UsernameEditFragment; import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment; import org.thoughtcrime.securesms.recipients.Recipient; @@ -171,7 +158,6 @@ import org.thoughtcrime.securesms.util.SignalProxyUtil; import org.thoughtcrime.securesms.util.SnapToTopDataObserver; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.adapter.mapping.PagingMappingAdapter; -import org.thoughtcrime.securesms.util.task.SnackbarAsyncTask; import org.thoughtcrime.securesms.util.views.SimpleProgressDialog; import org.thoughtcrime.securesms.util.views.Stub; import org.thoughtcrime.securesms.wallpaper.ChatWallpaper; @@ -188,14 +174,15 @@ import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Completable; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; import kotlin.Unit; -import static android.app.Activity.RESULT_OK; - public class ConversationListFragment extends MainFragment implements ActionMode.Callback, ConversationListAdapter.OnConversationClickListener, - MegaphoneActionController, ClearFilterViewHolder.OnClearFilterClickListener, ChatFolderAdapter.Callbacks, ConversationListAdapter.EmptyFolderViewHolder.OnFolderSettingsClickListener @@ -217,15 +204,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode private RecyclerView chatFolderList; private RecyclerView list; private Stub bannerView; - private PulsingFloatingActionButton fab; - private PulsingFloatingActionButton cameraFab; private ConversationListFilterPullView pullView; private AppBarLayout pullViewAppBarLayout; private ConversationListViewModel viewModel; private RecyclerView.Adapter activeAdapter; private ConversationListAdapter defaultAdapter; private PagingMappingAdapter searchAdapter; - private Stub megaphoneContainer; private SnapToTopDataObserver snapToTopDataObserver; private Drawable archiveDrawable; private AppForegroundObserver.Listener appForegroundObserver; @@ -283,21 +267,14 @@ public class ConversationListFragment extends MainFragment implements ActionMode lifecycleDisposable = new LifecycleDisposable(); lifecycleDisposable.bindTo(getViewLifecycleOwner()); - coordinator = view.findViewById(R.id.coordinator); chatFolderList = view.findViewById(R.id.chat_folder_list); list = view.findViewById(R.id.list); bottomActionBar = view.findViewById(R.id.conversation_list_bottom_action_bar); bannerView = new Stub<>(view.findViewById(R.id.banner_compose_view)); - megaphoneContainer = new Stub<>(view.findViewById(R.id.megaphone_container)); voiceNotePlayerViewStub = new Stub<>(view.findViewById(R.id.voice_note_player)); - fab = view.findViewById(R.id.fab); - cameraFab = view.findViewById(R.id.camera_fab); pullView = view.findViewById(R.id.pull_view); pullViewAppBarLayout = view.findViewById(R.id.recycler_coordinator_app_bar); - fab.setVisibility(View.VISIBLE); - cameraFab.setVisibility(View.VISIBLE); - contactSearchMediator = new ContactSearchMediator(this, Collections.emptySet(), SelectionLimits.NO_LIMITS, @@ -381,9 +358,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode pullView.onUserDrag(progress); }); - fab.show(); - cameraFab.show(); - archiveDecoration = new ConversationListArchiveItemDecoration(new ColorDrawable(getResources().getColor(R.color.conversation_list_archive_background_end))); itemAnimator = new ConversationListItemAnimator(); @@ -404,22 +378,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode new ItemTouchHelper(new ArchiveListenerCallback(getResources().getColor(R.color.conversation_list_archive_background_start), getResources().getColor(R.color.conversation_list_archive_background_end))).attachToRecyclerView(list); - fab.setOnClickListener(v -> startActivity(new Intent(getActivity(), NewConversationActivity.class))); - cameraFab.setOnClickListener(v -> { - if (CameraXUtil.isSupported()) { - startActivity(MediaSelectionActivity.camera(requireContext())); - } else { - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .ifNecessary() - .withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), R.drawable.symbol_camera_24) - .withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos_videos, getParentFragmentManager()) - .onAllGranted(() -> startActivity(MediaSelectionActivity.camera(requireContext()))) - .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show()) - .execute(); - } - }); - initializeViewModel(); initializeListAdapters(); initializeTypingObserver(); @@ -479,10 +437,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode coordinator = null; list = null; bottomActionBar = null; - megaphoneContainer = null; voiceNotePlayerViewStub = null; - fab = null; - cameraFab = null; snapToTopDataObserver = null; itemAnimator = null; @@ -569,8 +524,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode public void onPause() { super.onPause(); - fab.stopPulse(); - cameraFab.stopPulse(); EventBus.getDefault().unregister(this); } @@ -583,7 +536,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); - onMegaphoneChanged(viewModel.getMegaphone()); } private ContactSearchConfiguration mapSearchStateToConfiguration(@NonNull ContactSearchState state) { @@ -654,31 +606,12 @@ public class ConversationListFragment extends MainFragment implements ActionMode private void closeSearchIfOpen() { if (isSearchOpen()) { setAdapter(defaultAdapter); - fadeInButtonsAndMegaphone(250); mainToolbarViewModel.setToolbarMode(MainToolbarMode.FULL); chatListBackHandler.setEnabled(false); return true; } } - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (resultCode == RESULT_OK && requestCode == CreateSvrPinActivity.REQUEST_NEW_PIN) { - Snackbar.make(fab, R.string.ConfirmKbsPinFragment__pin_created, Snackbar.LENGTH_LONG).show(); - viewModel.onMegaphoneCompleted(Megaphones.Event.PINS_FOR_ALL); - } - - if (resultCode == RESULT_OK && requestCode == UsernameEditFragment.REQUEST_CODE) { - String snackbarString = getString(R.string.ConversationListFragment_username_recovered_toast, SignalStore.account().getUsername()); - Snackbar.make(fab, snackbarString, Snackbar.LENGTH_LONG).show(); - } - } - private void onConversationClicked(@NonNull ThreadRecord threadRecord) { hideKeyboard(); getNavigator().goToConversation(threadRecord.getRecipient().getId(), @@ -720,41 +653,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode }); } - @Override - public void onMegaphoneNavigationRequested(@NonNull Intent intent) { - startActivity(intent); - } - - @Override - public void onMegaphoneNavigationRequested(@NonNull Intent intent, int requestCode) { - startActivityForResult(intent, requestCode); - } - - @Override - public void onMegaphoneToastRequested(@NonNull String string) { - Snackbar.make(fab, string, Snackbar.LENGTH_LONG).show(); - } - - @Override - public @NonNull Activity getMegaphoneActivity() { - return requireActivity(); - } - - @Override - public void onMegaphoneSnooze(@NonNull Megaphones.Event event) { - viewModel.onMegaphoneSnoozed(event); - } - - @Override - public void onMegaphoneCompleted(@NonNull Megaphones.Event event) { - viewModel.onMegaphoneCompleted(event); - } - - @Override - public void onMegaphoneDialogFragmentRequested(@NonNull DialogFragment dialogFragment) { - dialogFragment.show(getChildFragmentManager(), "megaphone_dialog"); - } - private void hideKeyboard() { InputMethodManager imm = ServiceUtil.getInputMethodManager(requireContext()); imm.hideSoftInputFromWindow(requireView().getWindowToken(), 0); @@ -772,7 +670,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode mainToolbarViewModel.getSearchEventsFlowable().subscribe(event -> { if (event instanceof MainToolbarViewModel.Event.Search.Open) { onSearchOpen(); - } else if (event instanceof MainToolbarViewModel.Event.Search.Close) { + } if (event instanceof MainToolbarViewModel.Event.Search.Close) { onSearchClose(); } else if (event instanceof MainToolbarViewModel.Event.Search.Query) { onSearchQueryUpdated(((MainToolbarViewModel.Event.Search.Query) event).getQuery()); @@ -939,7 +837,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode private void initializeViewModel() { viewModel = new ViewModelProvider(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class); - lifecycleDisposable.add(viewModel.getMegaphoneState().subscribe(this::onMegaphoneChanged)); lifecycleDisposable.add(viewModel.getConversationsState().subscribe(this::onConversationListChanged)); lifecycleDisposable.add(viewModel.getHasNoConversations().subscribe(this::updateEmptyState)); lifecycleDisposable.add(viewModel.getWebSocketState().subscribe(pipeState -> requireCallback().updateProxyStatus(pipeState))); @@ -1008,33 +905,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode chatFolderAdapter.submitList(new ArrayList<>(folders)); } - private void onMegaphoneChanged(@NonNull Megaphone megaphone) { - if (megaphone == Megaphone.NONE || isArchived() || getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE) { - if (megaphoneContainer.resolved()) { - megaphoneContainer.get().setVisibility(View.GONE); - megaphoneContainer.get().removeAllViews(); - } - return; - } - - View view = MegaphoneViewBuilder.build(requireContext(), megaphone, this); - - megaphoneContainer.get().removeAllViews(); - - if (view != null) { - megaphoneContainer.get().addView(view); - if (isSearchOpen() || actionMode != null) { - megaphoneContainer.get().setVisibility(View.GONE); - } else { - megaphoneContainer.get().setVisibility(View.VISIBLE); - } - } else { - megaphoneContainer.get().setVisibility(View.GONE); - } - - viewModel.onMegaphoneVisible(megaphone); - } - private void handleMarkAsRead(@NonNull Collection ids) { Context context = requireContext(); Stopwatch stopwatch = new Stopwatch("mark-read"); @@ -1078,31 +948,27 @@ public class ConversationListFragment extends MainFragment implements ActionMode int count = selectedConversations.size(); String snackBarTitle = getResources().getQuantityString(getArchivedSnackbarTitleRes(), count, count); - new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), - coordinator, - snackBarTitle, - getString(R.string.ConversationListFragment_undo), - getResources().getColor(R.color.amber_500), - Snackbar.LENGTH_LONG, - showProgress) - { + lifecycleDisposable.add(Completable + .fromAction(() -> archiveThreads(selectedConversations)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(() -> { + endActionModeIfActive(); - @Override - protected void onPostExecute(Void result) { - super.onPostExecute(result); - endActionModeIfActive(); - } - - @Override - protected void executeAction(@Nullable Void parameter) { - archiveThreads(selectedConversations); - } - - @Override - protected void reverseAction(@Nullable Void parameter) { - reverseArchiveThreads(selectedConversations); - } - }.executeOnExecutor(SignalExecutors.BOUNDED); + getNavigator().getViewModel().setSnackbar(new SnackbarState( + snackBarTitle, + new SnackbarState.ActionState( + getString(R.string.ConversationListFragment_undo), + R.color.amber_500, + () -> { + SignalExecutors.BOUNDED_IO.execute(() -> reverseArchiveThreads(selectedConversations)); + return Unit.INSTANCE; + } + ), + showProgress, + SnackbarDuration.Long + )); + })); } @SuppressLint("StaticFieldLeak") @@ -1178,10 +1044,13 @@ public class ConversationListFragment extends MainFragment implements ActionMode .toList()); if (toPin.size() + viewModel.getPinnedCount() > MAXIMUM_PINNED_CONVERSATIONS) { - Snackbar.make(fab, - getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), - Snackbar.LENGTH_LONG) - .show(); + getNavigator().getViewModel().setSnackbar(new SnackbarState( + getString(R.string.conversation_list__you_can_only_pin_up_to_d_chats, MAXIMUM_PINNED_CONVERSATIONS), + null, + false, + SnackbarDuration.Long + )); + endActionModeIfActive(); return; } @@ -1250,38 +1119,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode }); } - private void fadeOutButtonsAndMegaphone(int fadeDuration) { - if (fab != null) { - ViewUtil.fadeOut(fab, fadeDuration); - } - if (cameraFab != null) { - ViewUtil.fadeOut(cameraFab, fadeDuration); - } - if (megaphoneContainer != null && megaphoneContainer.resolved()) { - ViewUtil.fadeOut(megaphoneContainer.get(), fadeDuration); - } - } - - private void fadeInButtonsAndMegaphone(int fadeDuration) { - if (fab != null) { - ViewUtil.fadeIn(fab, fadeDuration); - } - if (cameraFab != null) { - ViewUtil.fadeIn(cameraFab, fadeDuration); - } - if (megaphoneContainer != null && megaphoneContainer.resolved()) { - ViewUtil.fadeIn(megaphoneContainer.get(), fadeDuration); - } - } - private void startActionMode() { actionMode = ((AppCompatActivity) getActivity()).startSupportActionMode(ConversationListFragment.this); ViewUtil.animateIn(bottomActionBar, bottomActionBar.getEnterAnimation()); - ViewUtil.fadeOut(fab, 250); - ViewUtil.fadeOut(cameraFab, 250); - if (megaphoneContainer.resolved()) { - ViewUtil.fadeOut(megaphoneContainer.get(), 250); - } requireCallback().onMultiSelectStarted(); } @@ -1295,25 +1135,15 @@ public class ConversationListFragment extends MainFragment implements ActionMode actionMode.finish(); actionMode = null; ViewUtil.animateOut(bottomActionBar, bottomActionBar.getExitAnimation()); - ViewUtil.fadeIn(fab, 250); - ViewUtil.fadeIn(cameraFab, 250); - if (megaphoneContainer.resolved()) { - ViewUtil.fadeIn(megaphoneContainer.get(), 250); - } requireCallback().onMultiSelectFinished(); } void updateEmptyState(boolean isConversationEmpty) { if (isConversationEmpty) { Log.i(TAG, "Received an empty data set."); - fab.startPulse(3 * 1000); - cameraFab.startPulse(3 * 1000); SignalStore.onboarding().setShowNewGroup(true); SignalStore.onboarding().setShowInviteFriends(true); - } else { - fab.stopPulse(); - cameraFab.stopPulse(); } } @@ -1566,34 +1396,40 @@ public class ConversationListFragment extends MainFragment implements ActionMode archiveDecoration.onArchiveStarted(); itemAnimator.enable(); - new SnackbarAsyncTask(getViewLifecycleOwner().getLifecycle(), - coordinator, - getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1), - getString(R.string.ConversationListFragment_undo), - getResources().getColor(R.color.amber_500), - Snackbar.LENGTH_LONG, - false) - { - private final ThreadTable threadTable = SignalDatabase.threads(); + lifecycleDisposable.add( + Single + .fromCallable(() -> { + List pinnedThreadIds = SignalDatabase.threads().getPinnedThreadIds(); + SignalDatabase.threads().archiveConversation(threadId); - private List pinnedThreadIds; + ConversationUtil.refreshRecipientShortcuts(); - @Override - protected void executeAction(@Nullable Long parameter) { - pinnedThreadIds = threadTable.getPinnedThreadIds(); - threadTable.archiveConversation(threadId); + return pinnedThreadIds; + }) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(pinnedThreadIds -> { + getNavigator().getViewModel().setSnackbar(new SnackbarState( + getResources().getQuantityString(R.plurals.ConversationListFragment_conversations_archived, 1, 1), + new SnackbarState.ActionState( + getString(R.string.ConversationListFragment_undo), + R.color.amber_500, + () -> { + SignalExecutors.BOUNDED_IO.execute(() -> { + SignalDatabase.threads().unarchiveConversation(threadId); + SignalDatabase.threads().restorePins(pinnedThreadIds); - ConversationUtil.refreshRecipientShortcuts(); - } + ConversationUtil.refreshRecipientShortcuts(); + }); - @Override - protected void reverseAction(@Nullable Long parameter) { - threadTable.unarchiveConversation(threadId); - threadTable.restorePins(pinnedThreadIds); - - ConversationUtil.refreshRecipientShortcuts(); - } - }.executeOnExecutor(SignalExecutors.BOUNDED, threadId); + return Unit.INSTANCE; + } + ), + false, + SnackbarDuration.Long + )); + }) + ); } @Override @@ -1683,7 +1519,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode private void onSearchOpen() { chatListBackHandler.setEnabled(true); - fadeOutButtonsAndMegaphone(250); } private void onSearchClose() { @@ -1692,7 +1527,6 @@ public class ConversationListFragment extends MainFragment implements ActionMode } chatListBackHandler.setEnabled(false); - fadeInButtonsAndMegaphone(250); } private void onSearchQueryUpdated(@NonNull String query) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt index 1c1184643c..9111735926 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListViewModel.kt @@ -26,9 +26,6 @@ import org.thoughtcrime.securesms.database.RxDatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.megaphone.Megaphone -import org.thoughtcrime.securesms.megaphone.MegaphoneRepository -import org.thoughtcrime.securesms.megaphone.Megaphones import org.thoughtcrime.securesms.notifications.MarkReadReceiver import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -37,8 +34,7 @@ import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState import java.util.concurrent.TimeUnit class ConversationListViewModel( - private val isArchived: Boolean, - private val megaphoneRepository: MegaphoneRepository = AppDependencies.megaphoneRepository + private val isArchived: Boolean ) : ViewModel() { companion object { @@ -55,7 +51,6 @@ class ConversationListViewModel( .build() val conversationsState: Flowable> = store.mapDistinctForUi { it.conversations } - val megaphoneState: Flowable = store.mapDistinctForUi { it.megaphone } val selectedState: Flowable = store.mapDistinctForUi { it.selectedConversations } val filterRequestState: Flowable = store.mapDistinctForUi { it.filterRequest } val chatFolderState: Flowable> = store.mapDistinctForUi { it.chatFolders } @@ -69,8 +64,6 @@ class ConversationListViewModel( get() = store.state.currentFolder val conversationFilterRequest: ConversationFilterRequest get() = store.state.filterRequest - val megaphone: Megaphone - get() = store.state.megaphone val pinnedCount: Int get() = store.state.pinnedCount val webSocketState: Observable @@ -154,10 +147,6 @@ class ConversationListViewModel( } fun onVisible() { - megaphoneRepository.getNextMegaphone { next -> - store.update { it.copy(megaphone = next ?: Megaphone.NONE) } - } - if (!coldStart) { AppDependencies.databaseObserver.notifyConversationListListeners() } @@ -202,20 +191,6 @@ class ConversationListViewModel( } } - fun onMegaphoneCompleted(event: Megaphones.Event) { - store.update { it.copy(megaphone = Megaphone.NONE) } - megaphoneRepository.markFinished(event) - } - - fun onMegaphoneSnoozed(event: Megaphones.Event) { - megaphoneRepository.markSeen(event) - store.update { it.copy(megaphone = Megaphone.NONE) } - } - - fun onMegaphoneVisible(visible: Megaphone) { - megaphoneRepository.markVisible(visible.event) - } - private fun loadCurrentFolders() { viewModelScope.launch(Dispatchers.IO) { val folders = ChatFoldersRepository.getCurrentFolders() @@ -303,7 +278,6 @@ class ConversationListViewModel( val chatFolders: List = emptyList(), val currentFolder: ChatFolderRecord = ChatFolderRecord(), val conversations: List = emptyList(), - val megaphone: Megaphone = Megaphone.NONE, val selectedConversations: ConversationSet = ConversationSet(), val internalSelection: Set = emptySet(), val filterRequest: ConversationFilterRequest = ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG), 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 7eb5b1329e..7e7d940e27 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityListHostFragment.kt @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.main import android.os.Bundle import android.view.View import android.view.ViewGroup -import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels @@ -11,7 +10,6 @@ import androidx.lifecycle.LifecycleOwner 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.util.concurrent.LifecycleDisposable @@ -75,23 +73,6 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f if (state.tab == MainNavigationDestination.CHATS) { return } else { - val cameraFab = requireView().findViewById(R.id.camera_fab) - val newConvoFab = requireView().findViewById(R.id.fab) - - val extras = when { - cameraFab != null && newConvoFab != null -> { - ViewCompat.setTransitionName(cameraFab, "camera_fab") - ViewCompat.setTransitionName(newConvoFab, "new_convo_fab") - - FragmentNavigatorExtras( - cameraFab to "camera_fab", - newConvoFab to "new_convo_fab" - ) - } - - else -> null - } - val destination = if (state.tab == MainNavigationDestination.STORIES) { R.id.action_conversationListFragment_to_storiesLandingFragment } else { @@ -101,8 +82,7 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f navController.navigate( destination, null, - null, - extras + null ) } } @@ -283,11 +263,11 @@ class MainActivityListHostFragment : Fragment(R.layout.main_activity_list_host_f activity = requireActivity(), views = listOf(chatFolders), viewStubs = listOf(), + setStatusBarColor = {}, onSetToolbarColor = { toolbarViewModel.setToolbarColor(it) }, lifecycleOwner = lifecycleOwner, - setStatusBarColor = {}, setChatFolderColor = setChatFolder ).attach(recyclerView) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt index 6dae66403f..da19a93ef8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainBottomChrome.kt @@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.megaphone.Megaphones data class SnackbarState( val message: String, - val actionState: ActionState?, + val actionState: ActionState? = null, val showProgress: Boolean = false, val duration: SnackbarDuration = SnackbarDuration.Long ) { @@ -59,7 +59,8 @@ interface MainBottomChromeCallback { data class MainBottomChromeState( val destination: MainNavigationDestination = MainNavigationDestination.CHATS, val megaphoneState: MainMegaphoneState = MainMegaphoneState(), - val snackbarState: SnackbarState? = null + val snackbarState: SnackbarState? = null, + val mainToolbarMode: MainToolbarMode = MainToolbarMode.FULL ) /** @@ -72,31 +73,34 @@ data class MainBottomChromeState( fun MainBottomChrome( state: MainBottomChromeState, callback: MainBottomChromeCallback, - megaphoneActionController: MegaphoneActionController + megaphoneActionController: MegaphoneActionController, + modifier: Modifier = Modifier ) { Column( - modifier = Modifier + modifier = modifier .fillMaxWidth() .animateContentSize() ) { - Box( - contentAlignment = Alignment.CenterEnd, - modifier = Modifier.fillMaxWidth() - ) { - MainFloatingActionButtons( - destination = state.destination, - onCameraClick = callback::onCameraClick, - onNewCallClick = callback::onNewCallClick, - onNewChatClick = callback::onNewChatClick + if (state.mainToolbarMode == MainToolbarMode.FULL) { + Box( + contentAlignment = Alignment.CenterEnd, + modifier = Modifier.fillMaxWidth() + ) { + MainFloatingActionButtons( + destination = state.destination, + onCameraClick = callback::onCameraClick, + onNewCallClick = callback::onNewCallClick, + onNewChatClick = callback::onNewChatClick + ) + } + + MainMegaphoneContainer( + state = state.megaphoneState, + controller = megaphoneActionController, + onMegaphoneVisible = callback::onMegaphoneVisible ) } - MainMegaphoneContainer( - state = state.megaphoneState, - controller = megaphoneActionController, - onMegaphoneVisible = callback::onMegaphoneVisible - ) - MainSnackbar( snackbarState = state.snackbarState, onDismissed = callback::onSnackbarDismissed @@ -116,12 +120,13 @@ private fun MainSnackbar( LaunchedEffect(snackbarState) { if (snackbarState != null) { val result = hostState.showSnackbar( - message = snackbarState.message + message = snackbarState.message, + actionLabel = snackbarState.actionState?.action ) when (result) { SnackbarResult.Dismissed -> Unit - SnackbarResult.ActionPerformed -> snackbarState.actionState + SnackbarResult.ActionPerformed -> snackbarState.actionState?.onActionClick?.invoke() } onDismissed() diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt index dfd1386fc2..eb99475089 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainFloatingActionButtons.kt @@ -7,11 +7,10 @@ package org.thoughtcrime.securesms.main import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.core.animateDp import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -32,11 +31,14 @@ import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.SignalPreview import org.signal.core.ui.compose.theme.SignalTheme import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.window.Navigation +import org.thoughtcrime.securesms.window.WindowSizeClass import kotlin.math.roundToInt private val ACTION_BUTTON_SIZE = 56.dp @@ -49,6 +51,11 @@ fun MainFloatingActionButtons( onCameraClick: (MainNavigationDestination) -> Unit = {}, onNewCallClick: () -> Unit = {} ) { + val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() + if (windowSizeClass.navigation == Navigation.RAIL) { + return + } + val boxHeightDp = (ACTION_BUTTON_SIZE * 2 + ACTION_BUTTON_SPACING) val boxHeightPx = with(LocalDensity.current) { boxHeightDp.toPx().roundToInt() @@ -65,38 +72,65 @@ fun MainFloatingActionButtons( enter = slideInVertically(initialOffsetY = { boxHeightPx - it }), exit = slideOutVertically(targetOffsetY = { boxHeightPx - it }) ) { + val elevation by transition.animateDp(targetValueByState = { if (it == EnterExitState.Visible) 4.dp else 0.dp }) + CameraButton( colors = IconButtonDefaults.filledTonalIconButtonColors().copy( containerColor = SignalTheme.colors.colorSurface1 ), onClick = { onCameraClick(MainNavigationDestination.CHATS) - } + }, + shadowElevation = elevation ) } - AnimatedContent( - targetState = destination, - modifier = Modifier.align(Alignment.BottomCenter), - transitionSpec = { EnterTransition.None togetherWith ExitTransition.None } - ) { targetState -> - when (targetState) { - MainNavigationDestination.CHATS -> NewChatButton(onNewChatClick) - MainNavigationDestination.CALLS -> NewCallButton(onNewCallClick) - MainNavigationDestination.STORIES -> CameraButton(onClick = { onCameraClick(MainNavigationDestination.STORIES) }) - } + Box( + modifier = Modifier.align(Alignment.BottomCenter) + ) { + PrimaryActionButton( + destination = destination, + onNewChatClick = onNewChatClick, + onCameraClick = onCameraClick, + onNewCallClick = onNewCallClick + ) } } } @Composable -private fun NewChatButton( - onClick: () -> Unit +private fun PrimaryActionButton( + destination: MainNavigationDestination, + onNewChatClick: () -> Unit = {}, + onCameraClick: (MainNavigationDestination) -> Unit = {}, + onNewCallClick: () -> Unit = {} ) { + val onClick = remember(destination) { + when (destination) { + MainNavigationDestination.CHATS -> onNewChatClick + MainNavigationDestination.CALLS -> onNewCallClick + MainNavigationDestination.STORIES -> { + { onCameraClick(destination) } + } + } + } + MainFloatingActionButton( onClick = onClick, - contentDescription = "", - icon = ImageVector.vectorResource(R.drawable.symbol_edit_24) + icon = { + AnimatedContent(destination) { targetState -> + val icon = when (targetState) { + MainNavigationDestination.CHATS -> R.drawable.symbol_edit_24 + MainNavigationDestination.CALLS -> R.drawable.symbol_phone_plus_24 + MainNavigationDestination.STORIES -> R.drawable.symbol_camera_24 + } + + Icon( + imageVector = ImageVector.vectorResource(icon), + contentDescription = "" + ) + } + } ) } @@ -104,34 +138,29 @@ private fun NewChatButton( private fun CameraButton( onClick: () -> Unit, modifier: Modifier = Modifier, + shadowElevation: Dp = 4.dp, colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors() ) { MainFloatingActionButton( onClick = onClick, - contentDescription = "", - icon = ImageVector.vectorResource(R.drawable.symbol_camera_24), + icon = { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.symbol_camera_24), + contentDescription = "" + ) + }, colors = colors, - modifier = modifier - ) -} - -@Composable -private fun NewCallButton( - onClick: () -> Unit -) { - MainFloatingActionButton( - onClick = onClick, - contentDescription = "", - icon = ImageVector.vectorResource(R.drawable.symbol_phone_plus_24) + modifier = modifier, + shadowElevation = shadowElevation ) } @Composable private fun MainFloatingActionButton( onClick: () -> Unit, - icon: ImageVector, - contentDescription: String, + icon: @Composable () -> Unit, modifier: Modifier = Modifier, + shadowElevation: Dp = 4.dp, colors: IconButtonColors = IconButtonDefaults.filledTonalIconButtonColors() ) { FilledTonalIconButton( @@ -139,14 +168,11 @@ private fun MainFloatingActionButton( shape = RoundedCornerShape(18.dp), modifier = modifier .size(ACTION_BUTTON_SIZE) - .shadow(4.dp, RoundedCornerShape(18.dp)), + .shadow(shadowElevation, RoundedCornerShape(18.dp)), enabled = true, colors = colors ) { - Icon( - imageVector = icon, - contentDescription = contentDescription - ) + icon() } } @@ -164,33 +190,3 @@ private fun MainFloatingActionButtonsPreview() { ) } } - -@SignalPreview -@Composable -private fun NewChatButtonPreview() { - Previews.Preview { - NewChatButton( - onClick = {} - ) - } -} - -@SignalPreview -@Composable -private fun CameraButtonPreview() { - Previews.Preview { - CameraButton( - onClick = {} - ) - } -} - -@SignalPreview -@Composable -private fun NewCallButtonPreview() { - Previews.Preview { - NewCallButton( - onClick = {} - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt index f370ebf208..9e61566d13 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainMegaphoneContainer.kt @@ -23,12 +23,8 @@ import org.thoughtcrime.securesms.megaphone.Megaphones data class MainMegaphoneState( val megaphone: Megaphone = Megaphone.NONE, - val isDisplayingArchivedChats: Boolean = false, - private val isSearchOpen: Boolean = false, - private val isInActionMode: Boolean = false -) { - fun isVisible(): Boolean = !isDisplayingArchivedChats && !isSearchOpen && !isInActionMode -} + val mainToolbarMode: MainToolbarMode = MainToolbarMode.FULL +) object EmptyMegaphoneActionController : MegaphoneActionController { override fun onMegaphoneNavigationRequested(intent: Intent) = Unit @@ -51,7 +47,7 @@ fun MainMegaphoneContainer( ) { val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE val visible = remember(isLandscape, state) { - !isLandscape && state.isVisible() + !(state.megaphone == Megaphone.NONE || state.mainToolbarMode != MainToolbarMode.FULL || isLandscape) } AnimatedVisibility(visible = visible) { @@ -61,10 +57,12 @@ fun MainMegaphoneContainer( ) } - LaunchedEffect(state.megaphone, state.isDisplayingArchivedChats, isLandscape) { - if (!(state.megaphone == Megaphone.NONE || state.isDisplayingArchivedChats || isLandscape)) { - onMegaphoneVisible(state.megaphone) + LaunchedEffect(state, isLandscape) { + if (state.megaphone == Megaphone.NONE || state.mainToolbarMode == MainToolbarMode.BASIC || isLandscape) { + return@LaunchedEffect } + + onMegaphoneVisible(state.megaphone) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt index 793bb50ab4..38613a6ad2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigation.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -103,7 +104,9 @@ fun MainNavigationBar( NavigationBar( containerColor = SignalTheme.colors.colorSurface2, contentColor = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.height(if (state.compact) 48.dp else 80.dp) + modifier = Modifier + .navigationBarsPadding() + .height(if (state.compact) 48.dp else 80.dp) ) { val entries = remember(state.isStoriesFeatureEnabled) { if (state.isStoriesFeatureEnabled) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index 3bf4e0fa63..a01b8ef8d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -6,11 +6,14 @@ package org.thoughtcrime.securesms.main import android.content.Intent +import android.os.Parcelable +import kotlinx.parcelize.Parcelize /** * Describes which content to display in the detail view. */ -sealed interface MainNavigationDetailLocation { +@Parcelize +sealed interface MainNavigationDetailLocation : Parcelable { data object Empty : MainNavigationDetailLocation data class Conversation(val intent: Intent) : MainNavigationDetailLocation } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index 13600bebae..ed0d6a56e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -7,18 +7,69 @@ package org.thoughtcrime.securesms.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.megaphone.Megaphone +import org.thoughtcrime.securesms.megaphone.Megaphones class MainNavigationViewModel : ViewModel() { - private val detailLocationFlow = MutableSharedFlow() + private val megaphoneRepository = AppDependencies.megaphoneRepository + private val detailLocationFlow = MutableSharedFlow() val detailLocation: SharedFlow = detailLocationFlow + private val internalMegaphone = MutableStateFlow(Megaphone.NONE) + val megaphone: StateFlow = internalMegaphone + + private val internalSnackbar = MutableStateFlow(null) + val snackbar: StateFlow = internalSnackbar + + private val internalNavigationEvents = MutableSharedFlow() + val navigationEvents: Flow = internalNavigationEvents + fun goTo(location: MainNavigationDetailLocation) { viewModelScope.launch { detailLocationFlow.emit(location) } } + + fun goToCameraFirstStoryCapture() { + viewModelScope.launch { + internalNavigationEvents.emit(NavigationEvent.STORY_CAMERA_FIRST) + } + } + + fun getNextMegaphone() { + megaphoneRepository.getNextMegaphone { next -> + internalMegaphone.update { next ?: Megaphone.NONE } + } + } + + fun setSnackbar(snackbarState: SnackbarState?) { + internalSnackbar.update { snackbarState } + } + + fun onMegaphoneSnoozed(event: Megaphones.Event) { + megaphoneRepository.markSeen(event) + internalMegaphone.update { Megaphone.NONE } + } + + fun onMegaphoneCompleted(event: Megaphones.Event) { + internalMegaphone.update { Megaphone.NONE } + megaphoneRepository.markFinished(event) + } + + fun onMegaphoneVisible(visible: Megaphone) { + megaphoneRepository.markVisible(visible.event) + } + + enum class NavigationEvent { + STORY_CAMERA_FIRST + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt index 584b8fca26..9b87a177cc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainToolbar.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -244,6 +245,7 @@ private fun SearchToolbar( Text(text = stringResource(state.searchHint)) }, modifier = modifier + .systemBarsPadding() .background(color = state.toolbarColor ?: MaterialTheme.colorScheme.surface) .height(dimensionResource(R.dimen.signal_m3_toolbar_height)) .padding(horizontal = 16.dp, vertical = 10.dp) 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 824db9d6bc..ae7c09fd04 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 @@ -1,26 +1,18 @@ package org.thoughtcrime.securesms.stories.landing -import android.Manifest import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View -import android.widget.Toast import androidx.activity.OnBackPressedCallback 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 import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver -import androidx.transition.TransitionInflater -import com.google.android.material.floatingactionbutton.FloatingActionButton -import com.google.android.material.snackbar.Snackbar -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.coroutines.launch import org.signal.core.util.concurrent.LifecycleDisposable @@ -40,11 +32,11 @@ 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.MainNavigationDestination +import org.thoughtcrime.securesms.main.MainNavigationViewModel import org.thoughtcrime.securesms.main.MainToolbarMode import org.thoughtcrime.securesms.main.MainToolbarViewModel import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil -import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity +import org.thoughtcrime.securesms.main.SnackbarState import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostModel @@ -59,7 +51,6 @@ import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.fragments.requireListener import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible -import java.util.concurrent.TimeUnit /** * The "landing page" for Stories. @@ -71,7 +62,6 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l } private lateinit var emptyNotice: View - private lateinit var cameraFab: FloatingActionButton private lateinit var bannerView: Stub @@ -85,6 +75,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l private val tabsViewModel: ConversationListTabsViewModel by viewModels(ownerProducer = { requireActivity() }) private val mainToolbarViewModel: MainToolbarViewModel by activityViewModels() + private val mainNavigationViewModel: MainNavigationViewModel by activityViewModels() private lateinit var adapter: MappingAdapter @@ -152,47 +143,6 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l lifecycleDisposable.bindTo(viewLifecycleOwner) emptyNotice = requireView().findViewById(R.id.empty_notice) - cameraFab = requireView().findViewById(R.id.camera_fab) - val sharedElementTarget: View = requireView().findViewById(R.id.camera_fab_shared_element_target) - - ViewCompat.setTransitionName(cameraFab, "new_convo_fab") - ViewCompat.setTransitionName(sharedElementTarget, "camera_fab") - - sharedElementEnterTransition = TransitionInflater.from(requireContext()).inflateTransition(R.transition.change_transform_fabs) - setEnterSharedElementCallback(object : SharedElementCallback() { - override fun onSharedElementStart(sharedElementNames: MutableList?, sharedElements: MutableList?, sharedElementSnapshots: MutableList?) { - if (sharedElementNames?.contains("camera_fab") == true) { - cameraFab.setImageResource(R.drawable.symbol_edit_24) - lifecycleDisposable += Single.timer(200, TimeUnit.MILLISECONDS) - .observeOn(AndroidSchedulers.mainThread()) - .subscribeBy { - cameraFab.setImageResource(R.drawable.symbol_camera_24) - sharedElementTarget.alpha = 0f - } - } - } - }) - - cameraFab.setOnClickListener { - if (CameraXUtil.isSupported()) { - startActivityIfAble(MediaSelectionActivity.camera(requireContext(), isStory = true)) - } else { - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .ifNecessary() - .onAllGranted { startActivityIfAble(MediaSelectionActivity.camera(requireContext(), isStory = true)) } - .withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), R.drawable.symbol_camera_24) - .withPermanentDenialDialog( - getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), - null, - R.string.CameraXFragment_allow_access_camera, - R.string.CameraXFragment_to_capture_photos_videos, - getParentFragmentManager() - ) - .onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() } - .execute() - } - } viewModel.state.observe(viewLifecycleOwner) { if (it.loadingState == StoriesLandingState.LoadingState.LOADED) { @@ -253,7 +203,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l MyStoriesItem.Model( lifecycleOwner = viewLifecycleOwner, onClick = { - cameraFab.performClick() + mainNavigationViewModel.goToCameraFirstStoryCapture() } ) ) @@ -323,7 +273,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l openStoryViewer(model, preview, true) }, onAvatarClick = { - cameraFab.performClick() + mainNavigationViewModel.goToCameraFirstStoryCapture() }, onLockList = { recyclerView?.suppressLayout(true) @@ -385,8 +335,11 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l private fun handleHideStory(model: StoriesLandingItem.Model) { StoryDialogs.hideStory(requireContext(), model.data.storyRecipient.getShortDisplayName(requireContext())) { viewModel.setHideStory(model.data.storyRecipient, true).subscribe { - Snackbar.make(cameraFab, R.string.StoriesLandingFragment__story_hidden, Snackbar.LENGTH_SHORT) - .show() + mainNavigationViewModel.setSnackbar( + SnackbarState( + message = getString(R.string.StoriesLandingFragment__story_hidden) + ) + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java b/app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java deleted file mode 100644 index 24c784dad2..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/task/SnackbarAsyncTask.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.thoughtcrime.securesms.util.task; - -import android.os.AsyncTask; -import android.view.View; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.lifecycle.Lifecycle; - -import com.google.android.material.snackbar.Snackbar; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.components.SignalProgressDialog; - -public abstract class SnackbarAsyncTask - extends AsyncTask - implements View.OnClickListener -{ - private static final String TAG = Log.tag(SnackbarAsyncTask.class); - - private final Lifecycle lifecycle; - private final View view; - private final String snackbarText; - private final String snackbarActionText; - private final int snackbarActionColor; - private final int snackbarDuration; - private final boolean showProgress; - - private @Nullable Params reversibleParameter; - private @Nullable SignalProgressDialog progressDialog; - - public SnackbarAsyncTask(@NonNull Lifecycle lifecycle, - @NonNull View view, - String snackbarText, - String snackbarActionText, - int snackbarActionColor, - int snackbarDuration, - boolean showProgress) - { - this.lifecycle = lifecycle; - this.view = view; - this.snackbarText = snackbarText; - this.snackbarActionText = snackbarActionText; - this.snackbarActionColor = snackbarActionColor; - this.snackbarDuration = snackbarDuration; - this.showProgress = showProgress; - } - - @Override - protected void onPreExecute() { - if (this.showProgress) this.progressDialog = SignalProgressDialog.show(view.getContext(), "", "", true); - else this.progressDialog = null; - } - - @SafeVarargs - @Override - protected final Void doInBackground(Params... params) { - this.reversibleParameter = params != null && params.length > 0 ?params[0] : null; - executeAction(reversibleParameter); - return null; - } - - @Override - protected void onPostExecute(Void result) { - if (this.showProgress && this.progressDialog != null) { - this.progressDialog.dismiss(); - this.progressDialog = null; - } - - if (!lifecycle.getCurrentState().isAtLeast(Lifecycle.State.CREATED)) { - Log.w(TAG, "Not in at least created state. Refusing to show snack bar."); - return; - } - - Snackbar.make(view, snackbarText, snackbarDuration) - .setAction(snackbarActionText, this) - .show(); - } - - @Override - public void onClick(View v) { - new AsyncTask() { - @Override - protected void onPreExecute() { - if (showProgress) progressDialog = SignalProgressDialog.show(view.getContext(), "", "", true); - else progressDialog = null; - } - - @Override - protected Void doInBackground(Void... params) { - reverseAction(reversibleParameter); - return null; - } - - @Override - protected void onPostExecute(Void result) { - if (showProgress && progressDialog != null) { - progressDialog.dismiss(); - progressDialog = null; - } - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - protected abstract void executeAction(@Nullable Params parameter); - protected abstract void reverseAction(@Nullable Params parameter); - -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt index befb809875..3d9780ce8d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/window/AppScaffold.kt @@ -6,16 +6,20 @@ package org.thoughtcrime.securesms.window import android.content.res.Configuration +import android.content.res.Resources import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.width import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.AnimatedPane +import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -25,7 +29,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp +import androidx.window.core.ExperimentalWindowCoreApi import androidx.window.core.layout.WindowHeightSizeClass import androidx.window.core.layout.WindowWidthSizeClass import org.signal.core.ui.compose.Previews @@ -58,43 +62,76 @@ enum class WindowSizeClass( EXTENDED_PORTRAIT(Navigation.RAIL), EXTENDED_LANDSCAPE(Navigation.RAIL); + fun isCompact(): Boolean = this == COMPACT_PORTRAIT || this == COMPACT_LANDSCAPE + fun isMedium(): Boolean = this == MEDIUM_PORTRAIT || this == MEDIUM_LANDSCAPE + fun isExtended(): Boolean = this == EXTENDED_PORTRAIT || this == EXTENDED_LANDSCAPE + companion object { + + @OptIn(ExperimentalWindowCoreApi::class) + fun Resources.getWindowSizeClass(): WindowSizeClass { + val orientation = configuration.orientation + + if (!RemoteConfig.largeScreenUi) { + return getCompactSizeClassForOrientation(orientation) + } + + val windowSizeClass = androidx.window.core.layout.WindowSizeClass.compute( + displayMetrics.widthPixels, + displayMetrics.heightPixels, + displayMetrics.density + ) + + return getSizeClassForOrientationAndSystemSizeClass(orientation, windowSizeClass) + } + @Composable fun rememberWindowSizeClass(): WindowSizeClass { val orientation = LocalConfiguration.current.orientation if (!LocalInspectionMode.current && !RemoteConfig.largeScreenUi) { - return when (orientation) { - Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { - COMPACT_PORTRAIT - } - Configuration.ORIENTATION_LANDSCAPE -> COMPACT_LANDSCAPE - else -> error("Unexpected orientation: $orientation") - } + return getCompactSizeClassForOrientation(orientation) } val wsc = currentWindowAdaptiveInfo().windowSizeClass return remember(orientation, wsc) { - when (orientation) { - Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { - when (wsc.windowWidthSizeClass) { - WindowWidthSizeClass.COMPACT -> COMPACT_PORTRAIT - WindowWidthSizeClass.MEDIUM -> MEDIUM_PORTRAIT - WindowWidthSizeClass.EXPANDED -> EXTENDED_PORTRAIT - else -> error("Unsupported.") - } - } - Configuration.ORIENTATION_LANDSCAPE -> { - when (wsc.windowHeightSizeClass) { - WindowHeightSizeClass.COMPACT -> COMPACT_LANDSCAPE - WindowHeightSizeClass.MEDIUM -> MEDIUM_LANDSCAPE - WindowHeightSizeClass.EXPANDED -> EXTENDED_LANDSCAPE - else -> error("Unsupported.") - } - } - else -> error("Unexpected orientation: $orientation") + getSizeClassForOrientationAndSystemSizeClass(orientation, wsc) + } + } + + private fun getCompactSizeClassForOrientation(orientation: Int): WindowSizeClass { + return when (orientation) { + Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { + COMPACT_PORTRAIT } + + Configuration.ORIENTATION_LANDSCAPE -> COMPACT_LANDSCAPE + else -> error("Unexpected orientation: $orientation") + } + } + + private fun getSizeClassForOrientationAndSystemSizeClass(orientation: Int, windowSizeClass: androidx.window.core.layout.WindowSizeClass): WindowSizeClass { + return when (orientation) { + Configuration.ORIENTATION_PORTRAIT, Configuration.ORIENTATION_UNDEFINED, Configuration.ORIENTATION_SQUARE -> { + when (windowSizeClass.windowWidthSizeClass) { + WindowWidthSizeClass.COMPACT -> COMPACT_PORTRAIT + WindowWidthSizeClass.MEDIUM -> MEDIUM_PORTRAIT + WindowWidthSizeClass.EXPANDED -> EXTENDED_PORTRAIT + else -> error("Unsupported.") + } + } + + Configuration.ORIENTATION_LANDSCAPE -> { + when (windowSizeClass.windowHeightSizeClass) { + WindowHeightSizeClass.COMPACT -> COMPACT_LANDSCAPE + WindowHeightSizeClass.MEDIUM -> MEDIUM_LANDSCAPE + WindowHeightSizeClass.EXPANDED -> EXTENDED_LANDSCAPE + else -> error("Unsupported.") + } + } + + else -> error("Unexpected orientation: $orientation") } } } @@ -104,8 +141,10 @@ enum class WindowSizeClass( * Composable who's precise layout will depend on the window size class of the device it is being utilized on. * This is built to be generic so that we can use it throughout the application to support different device classes. */ +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun AppScaffold( + navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), detailContent: @Composable () -> Unit = {}, navRailContent: @Composable () -> Unit = {}, bottomNavContent: @Composable () -> Unit = {}, @@ -113,49 +152,68 @@ fun AppScaffold( ) { val windowSizeClass = WindowSizeClass.rememberWindowSizeClass() - Row { + if (windowSizeClass.isMedium()) { + Row { + Box(modifier = Modifier.weight(1f)) { + ListAndNavigation( + listContent = listContent, + navRailContent = navRailContent, + bottomNavContent = bottomNavContent, + windowSizeClass = windowSizeClass + ) + } + + Box(modifier = Modifier.weight(1f)) { + detailContent() + } + } + } else { + NavigableListDetailPaneScaffold( + navigator = navigator, + listPane = { + AnimatedPane { + ListAndNavigation( + listContent = listContent, + navRailContent = navRailContent, + bottomNavContent = bottomNavContent, + windowSizeClass = windowSizeClass + ) + } + }, + detailPane = { + AnimatedPane { + detailContent() + } + } + ) + } +} + +@Composable +private fun ListAndNavigation( + listContent: @Composable () -> Unit, + navRailContent: @Composable () -> Unit, + bottomNavContent: @Composable () -> Unit, + windowSizeClass: WindowSizeClass +) { + Row(modifier = Modifier.navigationBarsPadding()) { if (windowSizeClass.navigation == Navigation.RAIL) { navRailContent() } - BoxWithConstraints( - modifier = Modifier.weight(1f) - ) { - val listWidth = when (windowSizeClass) { - WindowSizeClass.COMPACT_PORTRAIT -> maxWidth - WindowSizeClass.COMPACT_LANDSCAPE -> maxWidth - WindowSizeClass.MEDIUM_PORTRAIT -> maxWidth * 0.5f - WindowSizeClass.MEDIUM_LANDSCAPE -> 360.dp - WindowSizeClass.EXTENDED_PORTRAIT -> 360.dp - WindowSizeClass.EXTENDED_LANDSCAPE -> 360.dp + Column { + Box(modifier = Modifier.weight(1f)) { + listContent() } - val detailWidth = maxWidth - listWidth - - Row { - Column( - modifier = Modifier.width(listWidth).navigationBarsPadding() - ) { - Box(modifier = Modifier.weight(1f)) { - listContent() - } - - if (windowSizeClass.navigation == Navigation.BAR) { - bottomNavContent() - } - } - - if (detailWidth > 0.dp) { - // TODO -- slider to divide sizing? - Box(modifier = Modifier.width(detailWidth)) { - detailContent() - } - } + if (windowSizeClass.navigation == Navigation.BAR) { + bottomNavContent() } } } } +@OptIn(ExperimentalMaterial3AdaptiveApi::class) @Preview(device = "spec:width=360dp,height=640dp,orientation=portrait") @Preview(device = "spec:width=640dp,height=360dp,orientation=landscape") @Preview(device = "spec:width=600dp,height=1024dp,orientation=portrait") diff --git a/app/src/main/res/layout/call_log_fragment.xml b/app/src/main/res/layout/call_log_fragment.xml index 7db4467cb3..1ee2d98d19 100644 --- a/app/src/main/res/layout/call_log_fragment.xml +++ b/app/src/main/res/layout/call_log_fragment.xml @@ -66,49 +66,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + app:animateKeyboardChanges="true" + app:applyRootInsets="true"> @@ -83,11 +84,11 @@ + app:layout_constraintTop_toTopOf="parent" /> + diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index b03512ee37..3cd649b9c7 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -1,5 +1,6 @@ +