Add proper adaptive material app scaffolding.

This commit is contained in:
Alex Hart
2025-04-09 11:30:46 -03:00
committed by Michelle Tang
parent 86ea3e8572
commit b64f3a48bf
26 changed files with 723 additions and 899 deletions

View File

@@ -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<Any>()
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<out String>, 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")
}
}
}

View File

@@ -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<Material3OnScrollHelperBinder>().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<String>?, sharedElements: MutableList<View>?, sharedElementSnapshots: MutableList<View>?) {
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<Callback>().onMultiSelectStarted()
signalBottomActionBarController.setVisibility(true)
binding.fab.visible = false
return actionMode
}
override fun onActionModeWillEnd() {
requireListener<Callback>().onMultiSelectFinished()
signalBottomActionBarController.setVisibility(false)
binding.fab.visible = true
}
override fun getResources(): Resources = resources

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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))

View File

@@ -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<View> 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<Long>(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

View File

@@ -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<ComposeView> 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<ContactSearchKey> searchAdapter;
private Stub<ViewGroup> 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<Long> 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<Void>(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<Long>(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<Long> pinnedThreadIds = SignalDatabase.threads().getPinnedThreadIds();
SignalDatabase.threads().archiveConversation(threadId);
private List<Long> 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) {

View File

@@ -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<List<Conversation>> = store.mapDistinctForUi { it.conversations }
val megaphoneState: Flowable<Megaphone> = store.mapDistinctForUi { it.megaphone }
val selectedState: Flowable<ConversationSet> = store.mapDistinctForUi { it.selectedConversations }
val filterRequestState: Flowable<ConversationFilterRequest> = store.mapDistinctForUi { it.filterRequest }
val chatFolderState: Flowable<List<ChatFolderMappingModel>> = 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<WebSocketConnectionState>
@@ -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<ChatFolderMappingModel> = emptyList(),
val currentFolder: ChatFolderRecord = ChatFolderRecord(),
val conversations: List<Conversation> = emptyList(),
val megaphone: Megaphone = Megaphone.NONE,
val selectedConversations: ConversationSet = ConversationSet(),
val internalSelection: Set<Conversation> = emptySet(),
val filterRequest: ConversationFilterRequest = ConversationFilterRequest(ConversationFilter.OFF, ConversationFilterSource.DRAG),

View File

@@ -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<View?>(R.id.camera_fab)
val newConvoFab = requireView().findViewById<View?>(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)
}

View File

@@ -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()

View File

@@ -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 = {}
)
}
}

View File

@@ -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)
}
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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<MainNavigationDetailLocation>()
private val megaphoneRepository = AppDependencies.megaphoneRepository
private val detailLocationFlow = MutableSharedFlow<MainNavigationDetailLocation>()
val detailLocation: SharedFlow<MainNavigationDetailLocation> = detailLocationFlow
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
private val internalSnackbar = MutableStateFlow<SnackbarState?>(null)
val snackbar: StateFlow<SnackbarState?> = internalSnackbar
private val internalNavigationEvents = MutableSharedFlow<NavigationEvent>()
val navigationEvents: Flow<NavigationEvent> = 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
}
}

View File

@@ -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)

View File

@@ -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<ComposeView>
@@ -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<String>?, sharedElements: MutableList<View>?, sharedElementSnapshots: MutableList<View>?) {
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)
)
)
}
}
}

View File

@@ -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<Params>
extends AsyncTask<Params, Void, Void>
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<Void, Void, Void>() {
@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);
}

View File

@@ -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<Any> = rememberListDetailPaneScaffoldNavigator<Any>(),
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")