diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index a79c102fe..f3497cd17 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -54,7 +54,7 @@ + + + + @@ -905,7 +916,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -916,7 +927,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca36d9408..ea749845b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -22,7 +22,8 @@ android:theme="@style/AppTheme" android:usesCleartextTraffic="false" android:localeConfig="@xml/locales_config" - android:networkSecurityConfig="@xml/network_security_config"> + android:networkSecurityConfig="@xml/network_security_config" + android:enableOnBackInvokedCallback="true"> + viewModel.onChange(currentProfileData) + } + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } override fun onStop() { diff --git a/app/src/main/java/app/pachli/MainActivity.kt b/app/src/main/java/app/pachli/MainActivity.kt index f1c27e827..7a231fe6b 100644 --- a/app/src/main/java/app/pachli/MainActivity.kt +++ b/app/src/main/java/app/pachli/MainActivity.kt @@ -53,6 +53,7 @@ import androidx.core.view.GravityCompat import androidx.core.view.MenuProvider import androidx.core.view.forEach import androidx.core.view.isVisible +import androidx.drawerlayout.widget.DrawerLayout.DrawerListener import androidx.lifecycle.lifecycleScope import androidx.viewpager2.widget.MarginPageTransformer import app.pachli.appstore.AnnouncementReadEvent @@ -70,6 +71,9 @@ import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.PostLookupFallbackBehavior import app.pachli.core.activity.emojify +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.common.di.ApplicationScope import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show @@ -215,6 +219,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { /** Adapter for the different timeline tabs */ private lateinit var tabAdapter: MainPagerAdapter + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + when { + binding.mainDrawerLayout.isOpen -> binding.mainDrawerLayout.close() + binding.viewPager.currentItem != 0 -> binding.viewPager.currentItem = 0 + } + } + } + @SuppressLint("RestrictedApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -284,7 +297,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { // otherwise show notification tab if (MainActivityIntent.getNotificationType(intent) == Notification.Type.FOLLOW_REQUEST) { val intent = AccountListActivityIntent(this, AccountListActivityIntent.Kind.FOLLOW_REQUESTS) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } else { showNotificationTab = true } @@ -379,24 +392,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { selectedEmojiPack = sharedPreferencesRepository.getString(EMOJI_PREFERENCE, "") - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - when { - binding.mainDrawerLayout.isOpen -> { - binding.mainDrawerLayout.close() - } - binding.viewPager.currentItem != 0 -> { - binding.viewPager.currentItem = 0 - } - else -> { - finish() - } - } - } - }, - ) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) if (Build.VERSION.SDK_INT >= TIRAMISU && ActivityCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, arrayOf(POST_NOTIFICATIONS), 1) @@ -500,7 +496,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { return true } KeyEvent.KEYCODE_SEARCH -> { - startActivityWithSlideInAnimation(SearchActivityIntent(this)) + startActivityWithDefaultTransition(SearchActivityIntent(this)) return true } } @@ -603,6 +599,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { refreshMainDrawerItems(addSearchButton) setSavedInstance(savedInstanceState) } + + binding.mainDrawerLayout.addDrawerListener(object : DrawerListener { + override fun onDrawerSlide(drawerView: View, slideOffset: Float) { } + + override fun onDrawerOpened(drawerView: View) { + onBackPressedCallback.isEnabled = true + } + + override fun onDrawerClosed(drawerView: View) { + onBackPressedCallback.isEnabled = binding.tabLayout.selectedTabPosition > 0 + } + + override fun onDrawerStateChanged(newState: Int) { } + }) } private fun refreshMainDrawerItems(addSearchButton: Boolean) { @@ -616,7 +626,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameText = list.title iconicsIcon = GoogleMaterial.Icon.gmd_list onClick = { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( TimelineActivityIntent.list(this@MainActivity, list.id, list.title), ) } @@ -635,7 +645,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.title_notifications iconicsIcon = GoogleMaterial.Icon.gmd_notifications onClick = { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( TimelineActivityIntent.notifications(context), ) } @@ -644,7 +654,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.title_public_local iconRes = R.drawable.ic_local_24dp onClick = { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( TimelineActivityIntent.publicLocal(context), ) } @@ -653,7 +663,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.title_public_federated iconRes = R.drawable.ic_public_24dp onClick = { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( TimelineActivityIntent.publicFederated(context), ) } @@ -662,7 +672,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.title_direct_messages iconRes = R.drawable.ic_reblog_direct_24dp onClick = { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( TimelineActivityIntent.conversations(context), ) } @@ -672,7 +682,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconicsIcon = GoogleMaterial.Icon.gmd_bookmark onClick = { val intent = TimelineActivityIntent.bookmarks(context) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, primaryDrawerItem { @@ -681,21 +691,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconicsIcon = GoogleMaterial.Icon.gmd_star onClick = { val intent = TimelineActivityIntent.favourites(context) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, primaryDrawerItem { nameRes = R.string.title_public_trending iconicsIcon = GoogleMaterial.Icon.gmd_trending_up onClick = { - startActivityWithSlideInAnimation(TrendingActivityIntent(context)) + startActivityWithDefaultTransition(TrendingActivityIntent(context)) } }, primaryDrawerItem { nameRes = R.string.title_followed_hashtags iconRes = R.drawable.ic_hashtag onClick = { - startActivityWithSlideInAnimation(FollowedTagsActivityIntent(context)) + startActivityWithDefaultTransition(FollowedTagsActivityIntent(context)) } }, primaryDrawerItem { @@ -703,7 +713,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconicsIcon = GoogleMaterial.Icon.gmd_person_add onClick = { val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.FOLLOW_REQUESTS) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, SectionDrawerItem().apply { @@ -714,7 +724,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.manage_lists iconicsIcon = GoogleMaterial.Icon.gmd_settings onClick = { - startActivityWithSlideInAnimation(ListActivityIntent(context)) + startActivityWithDefaultTransition(ListActivityIntent(context)) } }, DividerDrawerItem(), @@ -723,14 +733,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconRes = R.drawable.ic_notebook onClick = { val intent = DraftsActivityIntent(context) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, primaryDrawerItem { nameRes = R.string.action_access_scheduled_posts iconRes = R.drawable.ic_access_time onClick = { - startActivityWithSlideInAnimation(ScheduledStatusActivityIntent(context)) + startActivityWithDefaultTransition(ScheduledStatusActivityIntent(context)) } }, primaryDrawerItem { @@ -738,7 +748,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.title_announcements iconRes = R.drawable.ic_bullhorn_24dp onClick = { - startActivityWithSlideInAnimation(AnnouncementsActivityIntent(context)) + startActivityWithDefaultTransition(AnnouncementsActivityIntent(context)) } badgeStyle = BadgeStyle().apply { textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary)) @@ -751,7 +761,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconRes = R.drawable.ic_account_settings onClick = { val intent = PreferencesActivityIntent(context, PreferenceScreen.ACCOUNT) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, secondaryDrawerItem { @@ -759,7 +769,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconicsIcon = GoogleMaterial.Icon.gmd_settings onClick = { val intent = PreferencesActivityIntent(context, PreferenceScreen.GENERAL) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, primaryDrawerItem { @@ -767,7 +777,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconicsIcon = GoogleMaterial.Icon.gmd_person onClick = { val intent = EditProfileActivityIntent(context) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, secondaryDrawerItem { @@ -775,7 +785,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { iconicsIcon = GoogleMaterial.Icon.gmd_info onClick = { val intent = AboutActivityIntent(context) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } }, secondaryDrawerItem { @@ -792,7 +802,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { nameRes = R.string.action_search iconicsIcon = GoogleMaterial.Icon.gmd_search onClick = { - startActivityWithSlideInAnimation(SearchActivityIntent(context)) + startActivityWithDefaultTransition(SearchActivityIntent(context)) } }, ) @@ -943,6 +953,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { onTabSelectedListener = object : OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab) { + onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen + supportActionBar?.title = tabs[tab.position].title(this@MainActivity) refreshComposeButtonState(tabs[tab.position]) @@ -952,9 +964,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { override fun onTabReselected(tab: TabLayout.Tab) { val fragment = tabAdapter.getFragment(tab.position) - if (fragment is ReselectableFragment) { - (fragment as ReselectableFragment).onReselect() - } + (fragment as? ReselectableFragment)?.onReselect() refreshComposeButtonState(tabs[tab.position]) } @@ -987,12 +997,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { // open profile when active image was clicked if (current && activeAccount != null) { val intent = AccountActivityIntent(this, activeAccount.accountId) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) return } // open LoginActivity to add new account if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( LoginActivityIntent(this, LoginMode.ADDITIONAL_LOGIN), ) return @@ -1012,12 +1022,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { intent.action = forward.action intent.putExtras(forward) } - startActivity(intent) - finishWithoutSlideOutAnimation() - overridePendingTransition( - DR.anim.explode, - DR.anim.explode, - ) + startActivityWithTransition(intent, TransitionKind.EXPLODE) + finish() } private fun logout() { @@ -1040,7 +1046,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider { LoginActivityIntent(this@MainActivity, LoginMode.DEFAULT) } startActivity(intent) - finishWithoutSlideOutAnimation() + finish() } } .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/app/pachli/ViewMediaActivity.kt b/app/src/main/java/app/pachli/ViewMediaActivity.kt index 157f57961..970be3557 100644 --- a/app/src/main/java/app/pachli/ViewMediaActivity.kt +++ b/app/src/main/java/app/pachli/ViewMediaActivity.kt @@ -46,6 +46,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.widget.ViewPager2 import app.pachli.BuildConfig.APPLICATION_ID import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -250,7 +251,7 @@ class ViewMediaActivity : BaseActivity(), MediaActionsListener { private fun onOpenStatus() { val attach = attachmentViewData!![binding.viewPager.currentItem] - startActivityWithSlideInAnimation(ViewThreadActivityIntent(this, attach.statusId, attach.statusUrl)) + startActivityWithDefaultTransition(ViewThreadActivityIntent(this, attach.statusId, attach.statusUrl)) } private fun copyLink() { diff --git a/app/src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt b/app/src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt index 4ff826831..95cd1d195 100644 --- a/app/src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt +++ b/app/src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt @@ -25,7 +25,12 @@ import app.pachli.core.ui.BindingHolder import app.pachli.databinding.ItemEditFieldBinding import app.pachli.util.fixTextSelection -class AccountFieldEditAdapter : RecyclerView.Adapter>() { +/** + * @property onChange Call this whenever data in the UI fields changes + */ +class AccountFieldEditAdapter( + val onChange: () -> Unit, +) : RecyclerView.Adapter>() { private val fieldData = mutableListOf() private var maxNameLength: Int? = null @@ -84,10 +89,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString() + onChange() } holder.binding.accountFieldValueText.doAfterTextChanged { newText -> fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString() + onChange() } // Ensure the textview contents are selectable diff --git a/app/src/main/java/app/pachli/components/account/AccountActivity.kt b/app/src/main/java/app/pachli/components/account/AccountActivity.kt index 06996151f..9655880c7 100644 --- a/app/src/main/java/app/pachli/components/account/AccountActivity.kt +++ b/app/src/main/java/app/pachli/components/account/AccountActivity.kt @@ -36,6 +36,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.annotation.ColorInt import androidx.annotation.DrawableRes @@ -58,6 +59,9 @@ import app.pachli.R import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.emojify +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.activity.loadAvatar import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show @@ -180,6 +184,12 @@ class AccountActivity : private var noteWatcher: TextWatcher? = null + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + binding.accountFragmentViewPager.currentItem = 0 + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadResources() @@ -207,6 +217,8 @@ class AccountActivity : } else { binding.saveNoteInfo.visibility = View.INVISIBLE } + + onBackPressedDispatcher.addCallback(onBackPressedCallback) } /** @@ -243,7 +255,7 @@ class AccountActivity : else -> throw AssertionError() } val accountListIntent = AccountListActivityIntent(this, kind, viewModel.accountId) - startActivityWithSlideInAnimation(accountListIntent) + startActivityWithDefaultTransition(accountListIntent) } binding.accountFollowers.setOnClickListener(accountListClickListener) binding.accountFollowing.setOnClickListener(accountListClickListener) @@ -299,7 +311,10 @@ class AccountActivity : override fun onTabUnselected(tab: TabLayout.Tab?) {} - override fun onTabSelected(tab: TabLayout.Tab?) {} + override fun onTabSelected(tab: TabLayout.Tab?) { + tab?.position ?: return + onBackPressedCallback.isEnabled = tab.position > 0 + } }, ) } @@ -561,7 +576,7 @@ class AccountActivity : } private fun viewImage(view: View, uri: String) { - view.transitionName = uri + ViewCompat.setTransitionName(view, uri) startActivity( ViewMediaActivityIntent(view.context, uri), ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle(), @@ -630,7 +645,7 @@ class AccountActivity : binding.accountFollowButton.setOnClickListener { if (viewModel.isSelf) { val intent = EditProfileActivityIntent(this@AccountActivity) - startActivity(intent) + startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) return@setOnClickListener } @@ -959,12 +974,12 @@ class AccountActivity : override fun onViewTag(tag: String) { val intent = TimelineActivityIntent.hashtag(this, tag) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } override fun onViewAccount(id: String) { val intent = AccountActivityIntent(this, id) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } override fun onViewUrl(url: String) { diff --git a/app/src/main/java/app/pachli/components/accountlist/AccountListFragment.kt b/app/src/main/java/app/pachli/components/accountlist/AccountListFragment.kt index 1ac829d14..8a0deba1e 100644 --- a/app/src/main/java/app/pachli/components/accountlist/AccountListFragment.kt +++ b/app/src/main/java/app/pachli/components/accountlist/AccountListFragment.kt @@ -32,9 +32,9 @@ import app.pachli.components.accountlist.adapter.FollowRequestsAdapter import app.pachli.components.accountlist.adapter.FollowRequestsHeaderAdapter import app.pachli.components.accountlist.adapter.MutesAdapter import app.pachli.core.accounts.AccountManager -import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.PostLookupFallbackBehavior +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -158,19 +158,15 @@ class AccountListFragment : } override fun onViewTag(tag: String) { - (activity as BaseActivity?) - ?.startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(requireContext(), tag)) + activity?.startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(requireContext(), tag)) } override fun onViewAccount(id: String) { - (activity as BaseActivity?)?.let { - val intent = AccountActivityIntent(it, id) - it.startActivityWithSlideInAnimation(intent) - } + activity?.startActivityWithDefaultTransition(AccountActivityIntent(requireContext(), id)) } override fun onViewUrl(url: String) { - (activity as BottomSheetActivity?)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) + (activity as? BottomSheetActivity)?.viewUrl(url, PostLookupFallbackBehavior.OPEN_IN_BROWSER) } override fun onMute(mute: Boolean, id: String, position: Int, notifications: Boolean) { diff --git a/app/src/main/java/app/pachli/components/announcements/AnnouncementsActivity.kt b/app/src/main/java/app/pachli/components/announcements/AnnouncementsActivity.kt index 7ec49b7d2..cbf580c85 100644 --- a/app/src/main/java/app/pachli/components/announcements/AnnouncementsActivity.kt +++ b/app/src/main/java/app/pachli/components/announcements/AnnouncementsActivity.kt @@ -29,6 +29,7 @@ import app.pachli.R import app.pachli.adapter.EmojiAdapter import app.pachli.adapter.OnEmojiSelectedListener import app.pachli.core.activity.BottomSheetActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -187,7 +188,7 @@ class AnnouncementsActivity : override fun onViewTag(tag: String) { val intent = TimelineActivityIntent.hashtag(this, tag) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } override fun onViewAccount(id: String) { diff --git a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt index f2adb6654..ffd924a3f 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeActivity.kt @@ -17,6 +17,7 @@ package app.pachli.components.compose import android.Manifest +import android.annotation.SuppressLint import android.app.ProgressDialog import android.content.ClipData import android.content.Intent @@ -30,7 +31,6 @@ import android.os.Bundle import android.provider.MediaStore import android.text.InputFilter import android.text.Spanned -import android.text.style.URLSpan import android.view.KeyEvent import android.view.MenuItem import android.view.View @@ -94,7 +94,6 @@ import app.pachli.core.network.model.Status import app.pachli.core.preferences.AppTheme import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.SharedPreferencesRepository -import app.pachli.core.ui.MentionSpan import app.pachli.databinding.ActivityComposeBinding import app.pachli.util.PickMediaFiles import app.pachli.util.getInitialLanguages @@ -155,9 +154,9 @@ class ComposeActivity : @VisibleForTesting var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT - var charactersReservedPerUrl = InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL - private val viewModel: ComposeViewModel by viewModels() + @VisibleForTesting + val viewModel: ComposeViewModel by viewModels() private val binding by viewBinding(ActivityComposeBinding::inflate) @@ -206,6 +205,28 @@ class ComposeActivity : viewModel.cropImageItemOld = null } + /** + * Pressing back either (a) closes an open bottom sheet, or (b) goes + * back, if no bottom sheets are open. + */ + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || + scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED + ) { + composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN + addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN + emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN + scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN + return + } + + handleCloseButton() + } + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -268,7 +289,7 @@ class ComposeActivity : } setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) - setupComposeField(sharedPreferencesRepository, viewModel.startingText, composeOptions) + setupComposeField(sharedPreferencesRepository, viewModel.initialContent, composeOptions) setupContentWarningField(composeOptions?.contentWarning) setupPollView() applyShareIntent(intent, savedInstanceState) @@ -282,7 +303,7 @@ class ComposeActivity : } it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply { - viewModel.contentWarningChanged(this) + viewModel.showContentWarningChanged(this) } it.getString(SCHEDULED_TIME_KEY)?.let { time -> @@ -369,7 +390,9 @@ class ComposeActivity : if (startingContentWarning != null) { binding.composeContentWarningField.setText(startingContentWarning) } - binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } + binding.composeContentWarningField.doOnTextChanged { newContentWarning, _, _, _ -> + viewModel.onContentWarningChanged(newContentWarning?.toString() ?: "") + } } private fun setupComposeField( @@ -404,7 +427,7 @@ class ComposeActivity : highlightSpans(binding.composeEditField.text, mentionColour) binding.composeEditField.doAfterTextChanged { editable -> highlightSpans(editable!!, mentionColour) - updateVisibleCharactersLeft() + viewModel.onContentChanged(editable) } // work around Android platform bug -> https://issuetracker.google.com/issues/67102093 @@ -419,12 +442,19 @@ class ComposeActivity : lifecycleScope.launch { viewModel.instanceInfo.collect { instanceData -> maximumTootCharacters = instanceData.maxChars - charactersReservedPerUrl = instanceData.charactersReservedPerUrl maxUploadMediaNumber = instanceData.maxMediaAttachments - updateVisibleCharactersLeft() + updateVisibleCharactersLeft(viewModel.statusLength.value) } } + lifecycleScope.launch { + viewModel.statusLength.collect { updateVisibleCharactersLeft(it) } + } + + lifecycleScope.launch { + viewModel.closeConfirmation.collect { updateOnBackPressedCallbackState(it, bottomSheetStates()) } + } + lifecycleScope.launch { viewModel.emoji.collect(::setEmojiList) } @@ -493,6 +523,23 @@ class ComposeActivity : } } + /** @return List of states of the different bottomsheets */ + private fun bottomSheetStates() = listOf( + composeOptionsBehavior.state, + addMediaBehavior.state, + emojiBehavior.state, + scheduleBehavior.state, + ) + + /** + * Enables [onBackPressedCallback] if a confirmation is required, or any botttom sheet is + * open. Otherwise disables. + */ + private fun updateOnBackPressedCallbackState(confirmationKind: ConfirmationKind, bottomSheetStates: List) { + onBackPressedCallback.isEnabled = confirmationKind != ConfirmationKind.NONE || + bottomSheetStates.any { it != BottomSheetBehavior.STATE_HIDDEN } + } + private fun setupButtons() { binding.composeOptionsBottomSheet.listener = this @@ -501,6 +548,17 @@ class ComposeActivity : scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView) emojiBehavior = BottomSheetBehavior.from(binding.emojiView) + val bottomSheetCallback = object : BottomSheetBehavior.BottomSheetCallback() { + override fun onStateChanged(bottomSheet: View, newState: Int) { + updateOnBackPressedCallbackState(viewModel.closeConfirmation.value, bottomSheetStates()) + } + override fun onSlide(bottomSheet: View, slideOffset: Float) { } + } + composeOptionsBehavior.addBottomSheetCallback(bottomSheetCallback) + addMediaBehavior.addBottomSheetCallback(bottomSheetCallback) + scheduleBehavior.addBottomSheetCallback(bottomSheetCallback) + emojiBehavior.addBottomSheetCallback(bottomSheetCallback) + enableButton(binding.composeEmojiButton, clickable = false, colorActive = false) // Setup the interface buttons. @@ -534,26 +592,7 @@ class ComposeActivity : binding.actionPhotoPick.setOnClickListener { onMediaPick() } binding.addPollTextActionTextView.setOnClickListener { openPollDialog() } - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (composeOptionsBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - addMediaBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - emojiBehavior.state == BottomSheetBehavior.STATE_EXPANDED || - scheduleBehavior.state == BottomSheetBehavior.STATE_EXPANDED - ) { - composeOptionsBehavior.state = BottomSheetBehavior.STATE_HIDDEN - addMediaBehavior.state = BottomSheetBehavior.STATE_HIDDEN - emojiBehavior.state = BottomSheetBehavior.STATE_HIDDEN - scheduleBehavior.state = BottomSheetBehavior.STATE_HIDDEN - return - } - - handleCloseButton() - } - }, - ) + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } private fun setupLanguageSpinner(initialLanguages: List) { @@ -851,7 +890,7 @@ class ComposeActivity : maxOptionLength = instanceParams.pollMaxLength, minDuration = instanceParams.pollMinDuration, maxDuration = instanceParams.pollMaxDuration, - onUpdatePoll = viewModel::updatePoll, + onUpdatePoll = viewModel::onPollChanged, ) } @@ -881,30 +920,21 @@ class ComposeActivity : } private fun removePoll() { - viewModel.poll.value = null + viewModel.onPollChanged(null) binding.pollPreview.hide() } override fun onVisibilityChanged(visibility: Status.Visibility) { + viewModel.onStatusVisibilityChanged(visibility) composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED - viewModel.statusVisibility.value = visibility - } - - @VisibleForTesting - fun calculateTextLength(): Int { - return statusLength( - binding.composeEditField.text, - binding.composeContentWarningField.text, - charactersReservedPerUrl, - ) } @VisibleForTesting val selectedLanguage: String? get() = viewModel.postLanguage - private fun updateVisibleCharactersLeft() { - val remainingLength = maximumTootCharacters - calculateTextLength() + private fun updateVisibleCharactersLeft(textLength: Int) { + val remainingLength = maximumTootCharacters - textLength binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) val textColor = if (remainingLength < 0) { @@ -917,8 +947,7 @@ class ComposeActivity : private fun onContentWarningChanged() { val showWarning = binding.composeContentWarningBar.isGone - viewModel.contentWarningChanged(showWarning) - updateVisibleCharactersLeft() + viewModel.showContentWarningChanged(showWarning) } private fun verifyScheduledTime(): Boolean { @@ -957,11 +986,11 @@ class ComposeActivity : if (viewModel.showContentWarning.value) { spoilerText = binding.composeContentWarningField.text.toString() } - val characterCount = calculateTextLength() - if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { + val statusLength = viewModel.statusLength.value + if ((statusLength <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { binding.composeEditField.error = getString(R.string.error_empty) enableButtons(true, viewModel.editing) - } else if (characterCount <= maximumTootCharacters) { + } else if (statusLength <= maximumTootCharacters) { lifecycleScope.launch { viewModel.sendStatus(contentText, spoilerText, activeAccount.id) deleteDraftAndFinish() @@ -1008,8 +1037,9 @@ class ComposeActivity : this, BuildConfig.APPLICATION_ID + ".fileprovider", photoFile, - ) - takePicture.launch(photoUploadUri) + )?.also { + takePicture.launch(it) + } } private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { @@ -1105,6 +1135,7 @@ class ComposeActivity : return super.onOptionsItemSelected(item) } + @SuppressLint("GestureBackNavigation") override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { if (event.action == KeyEvent.ACTION_DOWN) { if (event.isCtrlPressed) { @@ -1126,10 +1157,10 @@ class ComposeActivity : private fun handleCloseButton() { val contentText = binding.composeEditField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString() - when (viewModel.handleCloseButton(contentText, contentWarning)) { + when (viewModel.closeConfirmation.value) { ConfirmationKind.NONE -> { viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } ConfirmationKind.SAVE_OR_DISCARD -> getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show() @@ -1183,7 +1214,7 @@ class ComposeActivity : } .setNegativeButton(R.string.action_discard) { _, _ -> viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } } @@ -1193,13 +1224,13 @@ class ComposeActivity : */ private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder { return AlertDialog.Builder(this) - .setMessage(R.string.compose_unsaved_changes) + .setMessage(R.string.unsaved_changes) .setPositiveButton(R.string.action_continue_edit) { _, _ -> // Do nothing, dialog will dismiss, user can continue editing } .setNegativeButton(R.string.action_discard) { _, _ -> viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } } @@ -1213,7 +1244,7 @@ class ComposeActivity : .setPositiveButton(R.string.action_delete) { _, _ -> viewModel.deleteDraft() viewModel.stopUploads() - finishWithoutSlideOutAnimation() + finish() } .setNegativeButton(R.string.action_continue_edit) { _, _ -> // Do nothing, dialog will dismiss, user can continue editing @@ -1222,7 +1253,7 @@ class ComposeActivity : private fun deleteDraftAndFinish() { viewModel.deleteDraft() - finishWithoutSlideOutAnimation() + finish() } private fun saveDraftAndFinish(contentText: String, contentWarning: String) { @@ -1240,7 +1271,7 @@ class ComposeActivity : } viewModel.saveDraft(contentText, contentWarning) dialog?.cancel() - finishWithoutSlideOutAnimation() + finish() } } @@ -1315,54 +1346,6 @@ class ComposeActivity : return mimeType != null && (mimeType.startsWith("image/") || mimeType.startsWith("video/") || mimeType.startsWith("audio/") || mimeType == "text/plain") } - /** - * Calculate the effective status length. - * - * Some text is counted differently: - * - * In the status body: - * - * - URLs always count for [urlLength] characters irrespective of their actual length - * (https://docs.joinmastodon.org/user/posting/#links) - * - Mentions ("@user@some.instance") only count the "@user" part - * (https://docs.joinmastodon.org/user/posting/#mentions) - * - Hashtags are always treated as their actual length, including the "#" - * (https://docs.joinmastodon.org/user/posting/#hashtags) - * - Emojis are treated as a single character - * - * Content warning text is always treated as its full length, URLs and other entities - * are not treated differently. - * - * @param body status body text - * @param contentWarning optional content warning text - * @param urlLength the number of characters attributed to URLs - * @return the effective status length - */ - fun statusLength(body: Spanned, contentWarning: Spanned?, urlLength: Int): Int { - var length = body.toString().mastodonLength() - body.getSpans(0, body.length, URLSpan::class.java) - .fold(0) { acc, span -> - // Accumulate a count of characters to be *ignored* in the final length - acc + when (span) { - is MentionSpan -> { - // Ignore everything from the second "@" (if present) - span.url.length - ( - span.url.indexOf("@", 1).takeIf { it >= 0 } - ?: span.url.length - ) - } - else -> { - // Expected to be negative if the URL length < maxUrlLength - span.url.mastodonLength() - urlLength - } - } - } - - // Content warning text is treated as is, URLs or mentions there are not special - contentWarning?.let { length += it.toString().mastodonLength() } - - return length - } - /** * [InputFilter] that uses the "Mastodon" length of a string, where emojis always * count as a single character. diff --git a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt index ad93dbec1..57d26453d 100644 --- a/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt +++ b/app/src/main/java/app/pachli/components/compose/ComposeViewModel.kt @@ -17,6 +17,10 @@ package app.pachli.components.compose import android.net.Uri +import android.text.Editable +import android.text.Spanned +import android.text.style.URLSpan +import androidx.annotation.VisibleForTesting import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -25,6 +29,7 @@ import app.pachli.components.compose.ComposeAutoCompleteAdapter.AutocompleteResu import app.pachli.components.drafts.DraftHelper import app.pachli.components.search.SearchType import app.pachli.core.accounts.AccountManager +import app.pachli.core.common.string.mastodonLength import app.pachli.core.common.string.randomAlphanumericString import app.pachli.core.data.model.InstanceInfo import app.pachli.core.data.repository.InstanceInfoRepository @@ -35,6 +40,7 @@ import app.pachli.core.network.model.Emoji import app.pachli.core.network.model.NewPoll import app.pachli.core.network.model.Status import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.ui.MentionSpan import app.pachli.service.MediaToSend import app.pachli.service.ServiceClient import app.pachli.service.StatusToSend @@ -49,6 +55,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.update @@ -66,20 +74,40 @@ class ComposeViewModel @Inject constructor( instanceInfoRepo: InstanceInfoRepository, ) : ViewModel() { + /** The current content */ + private var content: Editable = Editable.Factory.getInstance().newEditable("") + + /** The current content warning */ + private var contentWarning: String = "" + + /** + * The effective content warning. Either the real content warning, or the empty string + * if the content warning has been hidden + */ + private val effectiveContentWarning + get() = if (showContentWarning.value) contentWarning else "" + private var replyingStatusAuthor: String? = null private var replyingStatusContent: String? = null - internal var startingText: String? = null + + /** The initial content for this status, before any edits */ + internal var initialContent: String = "" + + /** The initial content warning for this status, before any edits */ + private var initialContentWarning: String = "" + internal var postLanguage: String? = null + + /** If editing a draft then the ID of the draft, otherwise 0 */ private var draftId: Int = 0 private var scheduledTootId: String? = null - private var startingContentWarning: String = "" private var inReplyToId: String? = null private var originalStatusId: String? = null private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var contentWarningStateChanged: Boolean = false private var modifiedInitialState: Boolean = false - private var hasScheduledTimeChanged: Boolean = false + private var scheduledTimeChanged: Boolean = false val instanceInfo: SharedFlow = instanceInfoRepo::getInstanceInfo.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) @@ -87,16 +115,27 @@ class ComposeViewModel @Inject constructor( val emoji: SharedFlow> = instanceInfoRepo::getEmojis.asFlow() .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) - val markMediaAsSensitive: MutableStateFlow = + private val _markMediaAsSensitive: MutableStateFlow = MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) + val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow() - val statusVisibility: MutableStateFlow = MutableStateFlow(Status.Visibility.UNKNOWN) - val showContentWarning: MutableStateFlow = MutableStateFlow(false) - val poll: MutableStateFlow = MutableStateFlow(null) - val scheduledAt: MutableStateFlow = MutableStateFlow(null) + private val _statusVisibility: MutableStateFlow = MutableStateFlow(Status.Visibility.UNKNOWN) + val statusVisibility = _statusVisibility.asStateFlow() + private val _showContentWarning: MutableStateFlow = MutableStateFlow(false) + val showContentWarning = _showContentWarning.asStateFlow() + private val _poll: MutableStateFlow = MutableStateFlow(null) + val poll = _poll.asStateFlow() + private val _scheduledAt: MutableStateFlow = MutableStateFlow(null) + val scheduledAt = _scheduledAt.asStateFlow() - val media: MutableStateFlow> = MutableStateFlow(emptyList()) - val uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + private val _media: MutableStateFlow> = MutableStateFlow(emptyList()) + val media = _media.asStateFlow() + private val _uploadError = MutableSharedFlow(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val uploadError = _uploadError.asSharedFlow() + private val _closeConfirmation = MutableStateFlow(ConfirmationKind.NONE) + val closeConfirmation = _closeConfirmation.asStateFlow() + private val _statusLength = MutableStateFlow(0) + val statusLength = _statusLength.asStateFlow() private lateinit var composeKind: ComposeKind @@ -133,7 +172,7 @@ class ComposeViewModel @Inject constructor( ): QueuedMedia { var stashMediaItem: QueuedMedia? = null - media.update { mediaList -> + _media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -176,12 +215,12 @@ class ComposeViewModel @Inject constructor( }, ) is UploadEvent.ErrorEvent -> { - media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } } - uploadError.emit(event.error) + _media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } } + _uploadError.emit(event.error) return@collect } } - media.update { mediaList -> + _media.update { mediaList -> mediaList.map { mediaItem -> if (mediaItem.localId == newMediaItem.localId) { newMediaItem @@ -192,11 +231,13 @@ class ComposeViewModel @Inject constructor( } } } + + updateCloseConfirmation() return mediaItem } private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { - media.update { mediaList -> + _media.update { mediaList -> val mediaItem = QueuedMedia( localId = mediaUploader.getNewLocalMediaId(), uri = uri, @@ -214,22 +255,53 @@ class ComposeViewModel @Inject constructor( fun removeMediaFromQueue(item: QueuedMedia) { mediaUploader.cancelUploadScope(item.localId) - media.update { mediaList -> mediaList.filter { it.localId != item.localId } } + _media.update { mediaList -> mediaList.filter { it.localId != item.localId } } + updateCloseConfirmation() } fun toggleMarkSensitive() { - this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true + this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true } - fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind { - return if (didChange(contentText, contentWarning)) { + /** Call this when the status' primary content changes */ + fun onContentChanged(newContent: Editable) { + content = newContent + updateStatusLength() + updateCloseConfirmation() + } + + /** Call this when the status' content warning changes */ + fun onContentWarningChanged(newContentWarning: String) { + contentWarning = newContentWarning + updateStatusLength() + updateCloseConfirmation() + } + + /** Call this to attach or clear the status' poll */ + fun onPollChanged(newPoll: NewPoll?) { + _poll.value = newPoll + updateCloseConfirmation() + } + + /** Call this to change the status' visibility */ + fun onStatusVisibilityChanged(newVisibility: Status.Visibility) { + _statusVisibility.value = newVisibility + } + + @VisibleForTesting + fun updateStatusLength() { + _statusLength.value = statusLength(content, effectiveContentWarning, instanceInfo.replayCache.last().charactersReservedPerUrl) + } + + private fun updateCloseConfirmation() { + _closeConfirmation.value = if (isDirty()) { when (composeKind) { - ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) { + ComposeKind.NEW -> if (isEmpty(content, effectiveContentWarning)) { ConfirmationKind.NONE } else { ConfirmationKind.SAVE_OR_DISCARD } - ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) { + ComposeKind.EDIT_DRAFT -> if (isEmpty(content, effectiveContentWarning)) { ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT } else { ConfirmationKind.UPDATE_OR_DISCARD @@ -242,23 +314,30 @@ class ComposeViewModel @Inject constructor( } } - private fun didChange(content: String?, contentWarning: String?): Boolean { - val textChanged = content.orEmpty() != startingText.orEmpty() - val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning + /** + * @return True if content of this status is "dirty", meaning one or more of the + * following have changed since the compose session started: content, + * content warning and content warning visibility, media, polls, or the + * scheduled time to send. + */ + private fun isDirty(): Boolean { + val contentChanged = !content.contentEquals(initialContent) + + val contentWarningChanged = effectiveContentWarning != initialContentWarning val mediaChanged = media.value.isNotEmpty() val pollChanged = poll.value != null - val didScheduledTimeChange = hasScheduledTimeChanged - return modifiedInitialState || textChanged || contentWarningChanged || mediaChanged || pollChanged || didScheduledTimeChange + return modifiedInitialState || contentChanged || contentWarningChanged || mediaChanged || pollChanged || scheduledTimeChanged } - private fun isEmpty(content: String?, contentWarning: String?): Boolean { - return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null) + private fun isEmpty(content: CharSequence, contentWarning: CharSequence): Boolean { + return !modifiedInitialState && (content.isBlank() && contentWarning.isBlank() && media.value.isEmpty() && poll.value == null) } - fun contentWarningChanged(value: Boolean) { - showContentWarning.value = value + fun showContentWarningChanged(value: Boolean) { + _showContentWarning.value = value contentWarningStateChanged = true + updateStatusLength() } fun deleteDraft() { @@ -296,7 +375,7 @@ class ComposeViewModel @Inject constructor( inReplyToId = inReplyToId, content = content, contentWarning = contentWarning, - sensitive = markMediaAsSensitive.value, + sensitive = _markMediaAsSensitive.value, visibility = statusVisibility.value, mediaUris = mediaUris, mediaDescriptions = mediaDescriptions, @@ -337,7 +416,7 @@ class ComposeViewModel @Inject constructor( text = content, warningText = spoilerText, visibility = statusVisibility.value.serverString(), - sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), + sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || showContentWarning.value), media = attachedMedia, scheduledAt = scheduledAt.value, inReplyToId = inReplyToId, @@ -356,7 +435,7 @@ class ComposeViewModel @Inject constructor( } private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) { - media.update { mediaList -> + _media.update { mediaList -> mediaList.map { mediaItem -> if (mediaItem.localId == localId) { mutator(mediaItem) @@ -438,10 +517,10 @@ class ComposeViewModel @Inject constructor( val contentWarning = composeOptions?.contentWarning if (contentWarning != null) { - startingContentWarning = contentWarning + initialContentWarning = contentWarning } if (!contentWarningStateChanged) { - showContentWarning.value = !contentWarning.isNullOrBlank() + _showContentWarning.value = !contentWarning.isNullOrBlank() } // recreate media list @@ -468,14 +547,14 @@ class ComposeViewModel @Inject constructor( draftId = composeOptions?.draftId ?: 0 scheduledTootId = composeOptions?.scheduledTootId originalStatusId = composeOptions?.statusId - startingText = composeOptions?.content + initialContent = composeOptions?.content ?: "" postLanguage = composeOptions?.language val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN if (tootVisibility != Status.Visibility.UNKNOWN) { startingVisibility = tootVisibility } - statusVisibility.value = startingVisibility + _statusVisibility.value = startingVisibility val mentionedUsernames = composeOptions?.mentionedUsernames if (mentionedUsernames != null) { val builder = StringBuilder() @@ -484,44 +563,101 @@ class ComposeViewModel @Inject constructor( builder.append(name) builder.append(' ') } - startingText = builder.toString() + initialContent = builder.toString() } - scheduledAt.value = composeOptions?.scheduledAt + _scheduledAt.value = composeOptions?.scheduledAt - composeOptions?.sensitive?.let { markMediaAsSensitive.value = it } + composeOptions?.sensitive?.let { _markMediaAsSensitive.value = it } val poll = composeOptions?.poll if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { - this.poll.value = poll + _poll.value = poll } replyingStatusContent = composeOptions?.replyingStatusContent replyingStatusAuthor = composeOptions?.replyingStatusAuthor + updateCloseConfirmation() setupComplete = true } - fun updatePoll(newPoll: NewPoll) { - poll.value = newPoll - } - fun updateScheduledAt(newScheduledAt: String?) { if (newScheduledAt != scheduledAt.value) { - hasScheduledTimeChanged = true + scheduledTimeChanged = true } - scheduledAt.value = newScheduledAt + _scheduledAt.value = newScheduledAt + updateCloseConfirmation() } val editing: Boolean get() = !originalStatusId.isNullOrEmpty() enum class ConfirmationKind { - NONE, // just close + /** No confirmation, finish */ + NONE, + + /** Content has changed and it's an un-posted status, show "save or discard" */ SAVE_OR_DISCARD, + + /** Content has changed when editing a draft, show "update draft or discard changes" */ UPDATE_OR_DISCARD, - CONTINUE_EDITING_OR_DISCARD_CHANGES, // editing post - CONTINUE_EDITING_OR_DISCARD_DRAFT, // edit draft + + /** Content has changed when editing a posted status or scheduled status */ + CONTINUE_EDITING_OR_DISCARD_CHANGES, + + /** Content has been cleared when editing a draft */ + CONTINUE_EDITING_OR_DISCARD_DRAFT, + } + + companion object { + /** + * Calculate the effective status length. + * + * Some text is counted differently: + * + * In the status body: + * + * - URLs always count for [urlLength] characters irrespective of their actual length + * (https://docs.joinmastodon.org/user/posting/#links) + * - Mentions ("@user@some.instance") only count the "@user" part + * (https://docs.joinmastodon.org/user/posting/#mentions) + * - Hashtags are always treated as their actual length, including the "#" + * (https://docs.joinmastodon.org/user/posting/#hashtags) + * - Emojis are treated as a single character + * + * Content warning text is always treated as its full length, URLs and other entities + * are not treated differently. + * + * @param body status body text + * @param contentWarning optional content warning text + * @param urlLength the number of characters attributed to URLs + * @return the effective status length + */ + fun statusLength(body: Spanned, contentWarning: String, urlLength: Int): Int { + var length = body.toString().mastodonLength() - body.getSpans(0, body.length, URLSpan::class.java) + .fold(0) { acc, span -> + // Accumulate a count of characters to be *ignored* in the final length + acc + when (span) { + is MentionSpan -> { + // Ignore everything from the second "@" (if present) + span.url.length - ( + span.url.indexOf("@", 1).takeIf { it >= 0 } + ?: span.url.length + ) + } + else -> { + // Expected to be negative if the URL length < maxUrlLength + span.url.mastodonLength() - urlLength + } + } + } + + // Content warning text is treated as is, URLs or mentions there are not special + length += contentWarning.mastodonLength() + + return length + } } } diff --git a/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt b/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt index 93059bb5a..a885205c7 100644 --- a/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt +++ b/app/src/main/java/app/pachli/components/filters/EditFilterActivity.kt @@ -1,13 +1,15 @@ package app.pachli.components.filters -import android.content.Context +import android.content.DialogInterface.BUTTON_NEGATIVE import android.content.DialogInterface.BUTTON_POSITIVE import android.os.Bundle import android.view.View import android.widget.AdapterView import android.widget.ArrayAdapter +import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible import androidx.core.view.size import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope @@ -21,6 +23,7 @@ import app.pachli.core.network.model.Filter import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterKeyword import app.pachli.core.network.retrofit.MastodonApi +import app.pachli.core.ui.extensions.await import app.pachli.databinding.ActivityEditFilterBinding import app.pachli.databinding.DialogFilterBinding import at.connyduck.calladapter.networkresult.fold @@ -28,8 +31,8 @@ import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import com.google.android.material.switchmaterial.SwitchMaterial import dagger.hilt.android.AndroidEntryPoint -import java.util.Date import javax.inject.Inject +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import retrofit2.HttpException @@ -51,11 +54,20 @@ class EditFilterActivity : BaseActivity() { private var originalFilter: Filter? = null private lateinit var filterContextSwitches: Map + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + lifecycleScope.launch { + if (showUnsavedChangesFilterDialog() == BUTTON_NEGATIVE) finish() + } + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + onBackPressedDispatcher.addCallback(onBackPressedCallback) originalFilter = EditFilterActivityIntent.getFilter(intent) - filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN, listOf()) + filter = originalFilter ?: Filter() binding.apply { filterContextSwitches = mapOf( filterContextHome to FilterContext.HOME, @@ -69,7 +81,6 @@ class EditFilterActivity : BaseActivity() { setContentView(binding.root) setSupportActionBar(binding.includedToolbar.toolbar) supportActionBar?.run { - // Back button setDisplayHomeAsUpEnabled(true) setDisplayShowHomeEnabled(true) } @@ -99,12 +110,10 @@ class EditFilterActivity : BaseActivity() { } else { viewModel.removeContext(context) } - validateSaveButton() } } binding.filterTitle.doAfterTextChanged { editable -> viewModel.setTitle(editable.toString()) - validateSaveButton() } binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> viewModel.setAction( @@ -130,13 +139,8 @@ class EditFilterActivity : BaseActivity() { viewModel.setDuration(0) } } - validateSaveButton() - if (originalFilter == null) { - binding.filterActionWarn.isChecked = true - } else { - loadFilter() - } + loadFilter() observeModel() } @@ -170,6 +174,25 @@ class EditFilterActivity : BaseActivity() { } } } + + lifecycleScope.launch { + viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it } + } + + lifecycleScope.launch { + viewModel.validationErrors.collectLatest { errors -> + binding.filterSaveButton.isEnabled = errors.isEmpty() + + binding.filterTitleWrapper.error = if (errors.contains(FilterValidationError.NO_TITLE)) { + getString(R.string.error_filter_missing_title) + } else { + null + } + + binding.keywordChipsError.isVisible = errors.contains(FilterValidationError.NO_KEYWORDS) + binding.filterContextError.isVisible = errors.contains(FilterValidationError.NO_CONTEXT) + } + } } // Populate the UI from the filter's members @@ -213,7 +236,6 @@ class EditFilterActivity : BaseActivity() { } filter = filter.copy(keywords = newKeywords) - validateSaveButton() } private fun showAddKeywordDialog() { @@ -256,9 +278,18 @@ class EditFilterActivity : BaseActivity() { .show() } - private fun validateSaveButton() { - binding.filterSaveButton.isEnabled = viewModel.validate() - } + /** + * Dialog that warns the user they have unsaved changes, and prompts + * to continue editing or discard the changes. + * + * @return [BUTTON_NEGATIVE] if the user chose to discard the changes, + * [BUTTON_POSITIVE] if the user chose to continue editing. + */ + suspend fun showUnsavedChangesFilterDialog() = AlertDialog.Builder(this) + .setMessage(R.string.unsaved_changes) + .setCancelable(true) + .create() + .await(R.string.action_continue_edit, R.string.action_discard) private fun saveChanges() { // TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? @@ -297,16 +328,4 @@ class EditFilterActivity : BaseActivity() { } } } - - companion object { - // Mastodon *stores* the absolute date in the filter, - // but create/edit take a number of seconds (relative to the time the operation is posted) - fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): String? { - return when (index) { - -1 -> default?.let { ((default.time - System.currentTimeMillis()) / 1000).toString() } - 0 -> "" - else -> context?.resources?.getStringArray(R.array.filter_duration_values)?.get(index) - } - } - } } diff --git a/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt b/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt index 138183870..28a11c649 100644 --- a/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt +++ b/app/src/main/java/app/pachli/components/filters/EditFilterViewModel.kt @@ -3,6 +3,7 @@ package app.pachli.components.filters import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.pachli.R import app.pachli.appstore.EventHub import app.pachli.appstore.FilterChangedEvent import app.pachli.core.network.model.Filter @@ -11,20 +12,38 @@ import app.pachli.core.network.model.FilterKeyword import app.pachli.core.network.retrofit.MastodonApi import at.connyduck.calladapter.networkresult.fold import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.Date import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.withContext import retrofit2.HttpException @HiltViewModel class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { - private var originalFilter: Filter? = null + private lateinit var originalFilter: Filter val title = MutableStateFlow("") val keywords = MutableStateFlow(listOf()) val action = MutableStateFlow(Filter.Action.WARN) val duration = MutableStateFlow(0) val contexts = MutableStateFlow(listOf()) + /** Track whether the duration has been modified, for use in [onChange] */ + // TODO: Rethink how duration is shown in the UI. + // Could show the actual end time with the date/time widget to set the duration, + // along with dropdown for quick settings (1h, etc). + private var durationIsDirty = false + + private val _isDirty = MutableStateFlow(false) + + /** True if the user has made unsaved changes to the filter */ + val isDirty = _isDirty.asStateFlow() + + private val _validationErrors = MutableStateFlow(emptySet()) + + /** True if the filter is valid and can be saved */ + val validationErrors = _validationErrors.asStateFlow() + fun load(filter: Filter) { originalFilter = filter title.value = filter.title @@ -40,10 +59,12 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub fun addKeyword(keyword: FilterKeyword) { keywords.value += keyword + onChange() } fun deleteKeyword(keyword: FilterKeyword) { keywords.value = keywords.value.filterNot { it == keyword } + onChange() } fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { @@ -52,35 +73,66 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub keywords.value = keywords.value.toMutableList().apply { set(index, updated) } + onChange() } } fun setTitle(title: String) { this.title.value = title + onChange() } fun setDuration(index: Int) { + if (!durationIsDirty && duration.value != index) durationIsDirty = true + duration.value = index + onChange() } fun setAction(action: Filter.Action) { this.action.value = action + onChange() } fun addContext(filterContext: FilterContext) { if (!contexts.value.contains(filterContext)) { contexts.value += filterContext + onChange() } } fun removeContext(filterContext: FilterContext) { contexts.value = contexts.value.filter { it != filterContext } + onChange() } - fun validate(): Boolean { - return title.value.isNotBlank() && - keywords.value.isNotEmpty() && - contexts.value.isNotEmpty() + private fun validate() { + _validationErrors.value = buildSet { + if (title.value.isBlank()) add(FilterValidationError.NO_TITLE) + if (keywords.value.isEmpty()) add(FilterValidationError.NO_KEYWORDS) + if (contexts.value.isEmpty()) add(FilterValidationError.NO_CONTEXT) + } + } + + /** + * Call when the contents of the filter change; recalculates validity + * and dirty state. + */ + private fun onChange() { + validate() + + if (durationIsDirty) { + _isDirty.value = true + return + } + + _isDirty.value = when { + originalFilter.title != title.value -> true + originalFilter.contexts != contexts.value -> true + originalFilter.action != action.value -> true + originalFilter.keywords.toSet() != keywords.value.toSet() -> true + else -> false + } } suspend fun saveChanges(context: Context): Boolean { @@ -90,15 +142,17 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub val action = action.value return withContext(viewModelScope.coroutineContext) { - val success = originalFilter?.let { filter -> - updateFilter(filter, title, contexts, action, durationIndex, context) - } ?: createFilter(title, contexts, action, durationIndex, context) + val success = if (originalFilter.id == "") { + createFilter(title, contexts, action, durationIndex, context) + } else { + updateFilter(originalFilter, title, contexts, action, durationIndex, context) + } // Send FilterChangedEvent for old and new contexts, to ensure that // e.g., removing a filter from "home" still notifies anything showing // the home timeline, so the timeline can be refreshed. if (success) { - val originalContexts = originalFilter?.contexts ?: emptyList() + val originalContexts = originalFilter.contexts val newFilterContexts = contexts (originalContexts + newFilterContexts).distinct().forEach { eventHub.dispatch(FilterChangedEvent(it)) @@ -109,7 +163,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub } private suspend fun createFilter(title: String, contexts: List, action: Filter.Action, durationIndex: Int, context: Context): Boolean { - val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context) api.createFilter( title = title, context = contexts, @@ -133,7 +187,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub } private suspend fun updateFilter(originalFilter: Filter, title: String, contexts: List, action: Filter.Action, durationIndex: Int, context: Context): Boolean { - val expiresInSeconds = EditFilterActivity.getSecondsForDurationIndex(durationIndex, context) + val expiresInSeconds = getSecondsForDurationIndex(durationIndex, context) api.updateFilter( id = originalFilter.id, title = title, @@ -176,7 +230,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub private suspend fun updateFilterV1(contexts: List, expiresInSeconds: String?): Boolean { val results = keywords.value.map { keyword -> - if (originalFilter == null) { + if (originalFilter.id == "") { api.createFilterV1( phrase = keyword.keyword, context = contexts, @@ -186,7 +240,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub ) } else { api.updateFilterV1( - id = originalFilter!!.id, + id = originalFilter.id, phrase = keyword.keyword, context = contexts, irreversible = false, @@ -199,4 +253,18 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub return results.none { it.isFailure } } + + companion object { + /** + * Mastodon *stores* the absolute date in the filter, + * but create/edit take a number of seconds (relative to the time the operation is posted) + */ + fun getSecondsForDurationIndex(index: Int, context: Context?, default: Date? = null): String? { + return when (index) { + -1 -> default?.let { ((default.time - System.currentTimeMillis()) / 1000).toString() } + 0 -> "" + else -> context?.resources?.getStringArray(R.array.filter_duration_values)?.get(index) + } + } + } } diff --git a/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt b/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt index 0fa5c7415..6fea2f4d4 100644 --- a/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt +++ b/app/src/main/java/app/pachli/components/filters/FilterExtensions.kt @@ -18,8 +18,10 @@ package app.pachli.components.filters import android.app.Activity +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import app.pachli.R +import app.pachli.core.network.model.Filter import app.pachli.core.ui.extensions.await internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this) @@ -27,3 +29,36 @@ internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = Aler .setCancelable(true) .create() .await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) + +/** Reasons why a filter might be invalid */ +enum class FilterValidationError { + /** Filter title is empty or blank */ + NO_TITLE, + + /** Filter has no keywords */ + NO_KEYWORDS, + + /** Filter has no contexts */ + NO_CONTEXT, +} + +/** + * @return Set of validation errors for this filter, empty set if there + * are no errors. + */ +fun Filter.validate() = buildSet { + if (title.isBlank()) add(FilterValidationError.NO_TITLE) + if (keywords.isEmpty()) add(FilterValidationError.NO_KEYWORDS) + if (contexts.isEmpty()) add(FilterValidationError.NO_CONTEXT) +} + +/** + * @return String resource containing an error message for this + * validation error. + */ +@StringRes +fun FilterValidationError.stringResource() = when (this) { + FilterValidationError.NO_TITLE -> R.string.error_filter_missing_title + FilterValidationError.NO_KEYWORDS -> R.string.error_filter_missing_keyword + FilterValidationError.NO_CONTEXT -> R.string.error_filter_missing_context +} diff --git a/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt b/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt index 8871f9501..500bd879a 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersActivity.kt @@ -6,11 +6,12 @@ import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import app.pachli.R import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible -import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.EditFilterActivityIntent import app.pachli.core.network.model.Filter import app.pachli.core.ui.BackgroundMessage @@ -94,8 +95,7 @@ class FiltersActivity : BaseActivity(), FiltersListener { private fun launchEditFilterActivity(filter: Filter? = null) { val intent = EditFilterActivityIntent(this, filter) - startActivity(intent) - overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left) + startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) } override fun deleteFilter(filter: Filter) { diff --git a/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt b/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt index 2491108b2..99f6a5b75 100644 --- a/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt +++ b/app/src/main/java/app/pachli/components/filters/FiltersAdapter.kt @@ -8,6 +8,7 @@ import app.pachli.core.network.model.Filter import app.pachli.core.ui.BindingHolder import app.pachli.databinding.ItemRemovableBinding import app.pachli.util.getRelativeTimeSpanString +import com.google.android.material.color.MaterialColors class FiltersAdapter(val listener: FiltersListener, val filters: List) : RecyclerView.Adapter>() { @@ -34,11 +35,25 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List) : ) } ?: filter.title - binding.textSecondary.text = context.getString( - R.string.filter_description_format, - actions.getOrNull(filter.action.ordinal - 1), - filter.contexts.map { filterContextNames.getOrNull(it.ordinal) }.joinToString("/"), - ) + // Secondary row shows filter actions and contexts, or errors if the filter is invalid + val errors = filter.validate() + val secondaryText: String + val secondaryTextColor: Int + + if (errors.isEmpty()) { + secondaryText = context.getString( + R.string.filter_description_format, + actions.getOrNull(filter.action.ordinal - 1), + filter.contexts.map { filterContextNames.getOrNull(it.ordinal) }.joinToString("/"), + ) + secondaryTextColor = android.R.attr.textColorTertiary + } else { + secondaryText = context.getString(errors.first().stringResource()) + secondaryTextColor = androidx.appcompat.R.attr.colorError + } + + binding.textSecondary.text = secondaryText + binding.textSecondary.setTextColor(MaterialColors.getColor(binding.textSecondary, secondaryTextColor)) binding.delete.setOnClickListener { listener.deleteFilter(filter) diff --git a/app/src/main/java/app/pachli/components/followedtags/FollowedTagsActivity.kt b/app/src/main/java/app/pachli/components/followedtags/FollowedTagsActivity.kt index edf65e30e..32b527265 100644 --- a/app/src/main/java/app/pachli/components/followedtags/FollowedTagsActivity.kt +++ b/app/src/main/java/app/pachli/components/followedtags/FollowedTagsActivity.kt @@ -16,6 +16,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator import app.pachli.R import app.pachli.components.compose.ComposeAutoCompleteAdapter import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -172,7 +173,7 @@ class FollowedTagsActivity : } override fun onViewTag(tag: String) { - startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(this, tag)) + startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(this, tag)) } override suspend fun search(token: String): List { diff --git a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt index 5200a668c..9ee83f33b 100644 --- a/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt +++ b/app/src/main/java/app/pachli/components/preference/AccountPreferencesFragment.kt @@ -26,7 +26,8 @@ import app.pachli.R import app.pachli.appstore.EventHub import app.pachli.components.notifications.currentAccountNeedsMigration import app.pachli.core.accounts.AccountManager -import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.common.util.unsafeLazy import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.AccountListActivityIntent @@ -104,11 +105,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setIcon(R.drawable.ic_add_to_tab_24) setOnPreferenceClickListener { val intent = TabPreferenceActivityIntent(context) - activity?.startActivity(intent) - activity?.overridePendingTransition( - DR.anim.slide_from_right, - DR.anim.slide_to_left, - ) + activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } } @@ -118,11 +115,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setIcon(R.drawable.ic_hashtag) setOnPreferenceClickListener { val intent = FollowedTagsActivityIntent(context) - activity?.startActivity(intent) - activity?.overridePendingTransition( - DR.anim.slide_from_right, - DR.anim.slide_to_left, - ) + activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } } @@ -132,11 +125,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setIcon(R.drawable.ic_mute_24dp) setOnPreferenceClickListener { val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.MUTES) - activity?.startActivity(intent) - activity?.overridePendingTransition( - DR.anim.slide_from_right, - DR.anim.slide_to_left, - ) + activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } } @@ -146,11 +135,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { icon = makeIcon(GoogleMaterial.Icon.gmd_block) setOnPreferenceClickListener { val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.BLOCKS) - activity?.startActivity(intent) - activity?.overridePendingTransition( - DR.anim.slide_from_right, - DR.anim.slide_to_left, - ) + activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } } @@ -160,11 +145,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setIcon(R.drawable.ic_mute_24dp) setOnPreferenceClickListener { val intent = InstanceListActivityIntent(context) - activity?.startActivity(intent) - activity?.overridePendingTransition( - DR.anim.slide_from_right, - DR.anim.slide_to_left, - ) + activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } } @@ -175,7 +156,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setIcon(R.drawable.ic_logout) setOnPreferenceClickListener { val intent = LoginActivityIntent(context, LoginMode.MIGRATION) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithTransition(intent, TransitionKind.EXPLODE) true } } @@ -185,7 +166,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { setTitle(R.string.pref_title_timeline_filters) setIcon(R.drawable.ic_filter_24dp) setOnPreferenceClickListener { - launchFilterActivity() + val intent = FiltersActivityIntent(requireContext()) + activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) true } val server = serverRepository.flow.value.getOrElse { null } @@ -298,14 +280,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { val intent = Intent() intent.action = "android.settings.APP_NOTIFICATION_SETTINGS" intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID) - startActivity(intent) + requireActivity().startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) } else { - activity?.let { - val intent = - PreferencesActivityIntent(it, PreferenceScreen.NOTIFICATION) - it.startActivity(intent) - it.overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left) - } + val intent = PreferencesActivityIntent(requireContext(), PreferenceScreen.NOTIFICATION) + requireActivity().startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) } } @@ -356,12 +334,6 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() { } } - private fun launchFilterActivity() { - val intent = FiltersActivityIntent(requireContext()) - activity?.startActivity(intent) - activity?.overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left) - } - companion object { fun newInstance() = AccountPreferencesFragment() } diff --git a/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt b/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt index df94037f4..9cc08b048 100644 --- a/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt +++ b/app/src/main/java/app/pachli/components/preference/PreferencesActivity.kt @@ -27,7 +27,8 @@ import androidx.preference.PreferenceFragmentCompat import app.pachli.R import app.pachli.appstore.EventHub import app.pachli.core.activity.BaseActivity -import app.pachli.core.designsystem.R as DR +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.navigation.MainActivityIntent import app.pachli.core.navigation.PreferencesActivityIntent import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen @@ -61,7 +62,7 @@ class PreferencesActivity : * back stack. */ val intent = MainActivityIntent(this@PreferencesActivity) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } } @@ -107,11 +108,11 @@ class PreferencesActivity : setAppNightMode(theme) restartActivitiesOnBackPressedCallback.isEnabled = true - this@PreferencesActivity.restartCurrentActivity() + this@PreferencesActivity.recreate() } PrefKeys.FONT_FAMILY, PrefKeys.UI_TEXT_SCALE_RATIO -> { restartActivitiesOnBackPressedCallback.isEnabled = true - this@PreferencesActivity.restartCurrentActivity() + this@PreferencesActivity.recreate() } PrefKeys.STATUS_TEXT_SIZE, PrefKeys.ABSOLUTE_TIME_VIEW, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.ANIMATE_GIF_AVATARS, PrefKeys.USE_BLURHASH, PrefKeys.SHOW_SELF_USERNAME, PrefKeys.SHOW_CARDS_IN_TIMELINES, PrefKeys.CONFIRM_REBLOGS, PrefKeys.CONFIRM_FAVOURITES, @@ -136,11 +137,13 @@ class PreferencesActivity : fragment.arguments = args fragment.setTargetFragment(caller, 0) supportFragmentManager.commit { + // Slide transition, as sub preference screens are "attached" to the + // parent screen. setCustomAnimations( - DR.anim.slide_from_right, - DR.anim.slide_to_left, - DR.anim.slide_from_left, - DR.anim.slide_to_right, + TransitionKind.SLIDE_FROM_END.openEnter, + TransitionKind.SLIDE_FROM_END.openExit, + TransitionKind.SLIDE_FROM_END.closeEnter, + TransitionKind.SLIDE_FROM_END.closeExit, ) replace(R.id.fragment_container, fragment) addToBackStack(null) @@ -148,25 +151,11 @@ class PreferencesActivity : return true } - private fun saveInstanceState(outState: Bundle) { - outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) - } - override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) super.onSaveInstanceState(outState) } - private fun restartCurrentActivity() { - intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK - val savedInstanceState = Bundle() - saveInstanceState(savedInstanceState) - intent.putExtras(savedInstanceState) - startActivityWithSlideInAnimation(intent) - finish() - overridePendingTransition(DR.anim.fade_in, DR.anim.fade_out) - } - companion object { private const val EXTRA_RESTART_ON_BACK = "restart" } diff --git a/app/src/main/java/app/pachli/components/search/SearchActivity.kt b/app/src/main/java/app/pachli/components/search/SearchActivity.kt index ea3ed5925..da61fa93e 100644 --- a/app/src/main/java/app/pachli/components/search/SearchActivity.kt +++ b/app/src/main/java/app/pachli/components/search/SearchActivity.kt @@ -88,10 +88,6 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe return false } - override fun finish() { - super.finishWithoutSlideOutAnimation() - } - private fun getPageTitle(position: Int): CharSequence { return when (position) { 0 -> getString(R.string.title_posts) diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt index 9cf60bd21..d9730929c 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchFragment.kt @@ -19,6 +19,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import app.pachli.R import app.pachli.components.search.SearchViewModel import app.pachli.core.activity.BottomSheetActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.visible import app.pachli.core.navigation.AccountActivityIntent @@ -140,11 +141,11 @@ abstract class SearchFragment : } override fun onViewAccount(id: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivityIntent(requireContext(), id)) + bottomSheetActivity?.startActivityWithDefaultTransition(AccountActivityIntent(requireContext(), id)) } override fun onViewTag(tag: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(requireContext(), tag)) + bottomSheetActivity?.startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(requireContext(), tag)) } override fun onViewUrl(url: String) { diff --git a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt index 8741813e8..1429ec01a 100644 --- a/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/app/pachli/components/search/fragments/SearchStatusesFragment.kt @@ -40,6 +40,7 @@ import app.pachli.R import app.pachli.components.search.adapter.SearchStatusesAdapter import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.activity.openLink import app.pachli.core.database.model.AccountEntity import app.pachli.core.navigation.AttachmentViewData @@ -187,7 +188,7 @@ class SearchStatusesFragment : SearchFragment(), StatusActionLis kind = ComposeOptions.ComposeKind.NEW, ), ) - bottomSheetActivity?.startActivityWithSlideInAnimation(intent) + bottomSheetActivity?.startActivityWithDefaultTransition(intent) } private fun more(statusViewData: StatusViewData, view: View) { diff --git a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt index 52bd1cfbd..dcd00d4dc 100644 --- a/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/app/pachli/components/timeline/TimelineFragment.kt @@ -49,8 +49,8 @@ import app.pachli.components.timeline.viewmodel.StatusAction import app.pachli.components.timeline.viewmodel.StatusActionSuccess import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.components.timeline.viewmodel.UiSuccess -import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.RefreshableFragment +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -637,12 +637,12 @@ class TimelineFragment : override fun onShowReblogs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithDefaultTransition(intent) } override fun onShowFavs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId) - (activity as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithDefaultTransition(intent) } override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { diff --git a/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt b/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt index 0e2cb897a..ac914e89a 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingActivity.kt @@ -35,8 +35,11 @@ import app.pachli.core.model.Timeline import app.pachli.core.ui.extensions.reduceSwipeSensitivity import app.pachli.databinding.ActivityTrendingBinding import app.pachli.interfaces.AppBarLayoutHost +import app.pachli.interfaces.ReselectableFragment import app.pachli.pager.MainPagerAdapter import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -55,6 +58,12 @@ class TrendingActivity : BottomSheetActivity(), AppBarLayoutHost, MenuProvider { private lateinit var adapter: MainPagerAdapter + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + binding.pager.currentItem = 0 + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(binding.root) @@ -90,14 +99,19 @@ class TrendingActivity : BottomSheetActivity(), AppBarLayoutHost, MenuProvider { } }.attach() - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.pager.currentItem != 0) binding.pager.currentItem = 0 else finish() - } - }, - ) + binding.tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + onBackPressedCallback.isEnabled = tab.position > 0 + } + + override fun onTabUnselected(tab: TabLayout.Tab) {} + + override fun onTabReselected(tab: TabLayout.Tab) { + (adapter.getFragment(tab.position) as? ReselectableFragment)?.onReselect() + } + }) + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { diff --git a/app/src/main/java/app/pachli/components/trending/TrendingTagsFragment.kt b/app/src/main/java/app/pachli/components/trending/TrendingTagsFragment.kt index cde9e7841..9de52a92e 100644 --- a/app/src/main/java/app/pachli/components/trending/TrendingTagsFragment.kt +++ b/app/src/main/java/app/pachli/components/trending/TrendingTagsFragment.kt @@ -37,8 +37,8 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import app.pachli.R import app.pachli.components.trending.viewmodel.TrendingTagsViewModel -import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.RefreshableFragment +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -173,7 +173,7 @@ class TrendingTagsFragment : } fun onViewTag(tag: String) { - (requireActivity() as BaseActivity).startActivityWithSlideInAnimation( + activity?.startActivityWithDefaultTransition( TimelineActivityIntent.hashtag( requireContext(), tag, diff --git a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt index f2d365d99..0ae9b163b 100644 --- a/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/ViewThreadFragment.kt @@ -33,7 +33,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import app.pachli.R import app.pachli.components.viewthread.edits.ViewEditsFragment -import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.activity.openLink import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show @@ -337,12 +337,12 @@ class ViewThreadFragment : override fun onShowReblogs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId) - (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithDefaultTransition(intent) } override fun onShowFavs(statusId: String) { val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId) - (requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) + activity?.startActivityWithDefaultTransition(intent) } override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { @@ -374,7 +374,12 @@ class ViewThreadFragment : val viewEditsFragment = ViewEditsFragment.newInstance(statusId) parentFragmentManager.commit { - setCustomAnimations(DR.anim.slide_from_right, DR.anim.slide_to_left, DR.anim.slide_from_left, DR.anim.slide_to_right) + setCustomAnimations( + DR.anim.activity_open_enter, + DR.anim.activity_open_exit, + DR.anim.activity_close_enter, + DR.anim.activity_close_exit, + ) replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id") addToBackStack(null) } diff --git a/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsFragment.kt b/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsFragment.kt index 3a4d997a2..88cf72e93 100644 --- a/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsFragment.kt +++ b/app/src/main/java/app/pachli/components/viewthread/edits/ViewEditsFragment.kt @@ -32,6 +32,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import app.pachli.R import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.emojify +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.activity.loadAvatar import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show @@ -183,11 +184,11 @@ class ViewEditsFragment : } override fun onViewAccount(id: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivityIntent(requireContext(), id)) + bottomSheetActivity?.startActivityWithDefaultTransition(AccountActivityIntent(requireContext(), id)) } override fun onViewTag(tag: String) { - bottomSheetActivity?.startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(requireContext(), tag)) + bottomSheetActivity?.startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(requireContext(), tag)) } override fun onViewUrl(url: String) { diff --git a/app/src/main/java/app/pachli/fragment/SFragment.kt b/app/src/main/java/app/pachli/fragment/SFragment.kt index 811845a37..ff3ddf342 100644 --- a/app/src/main/java/app/pachli/fragment/SFragment.kt +++ b/app/src/main/java/app/pachli/fragment/SFragment.kt @@ -34,6 +34,7 @@ import androidx.annotation.CallSuper import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat +import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -44,10 +45,10 @@ import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.PostLookupFallbackBehavior +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.activity.openLink import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.TranslationState -import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.AttachmentViewData import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions @@ -94,8 +95,7 @@ abstract class SFragment : Fragment(), StatusActionListener private var serverCanTranslate = false override fun startActivity(intent: Intent) { - super.startActivity(intent) - requireActivity().overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left) + requireActivity().startActivityWithDefaultTransition(intent) } override fun onAttach(context: Context) { @@ -400,7 +400,7 @@ abstract class SFragment : Fragment(), StatusActionListener val intent = ViewMediaActivityIntent(requireContext(), attachments, urlIndex) if (view != null) { val url = attachment.url - view.transitionName = url + ViewCompat.setTransitionName(view, url) val options = ActivityOptionsCompat.makeSceneTransitionAnimation( requireActivity(), view, diff --git a/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt b/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt index 4a229acba..a8786bef7 100644 --- a/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt +++ b/app/src/main/java/app/pachli/fragment/ViewImageFragment.kt @@ -30,6 +30,7 @@ import android.view.ViewGroup import android.widget.ImageView import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -62,7 +63,7 @@ class ViewImageFragment : ViewMediaFragment() { private var scheduleToolbarHide = false override fun setupMediaView(showingDescription: Boolean) { - binding.photoView.transitionName = attachment.url + ViewCompat.setTransitionName(binding.photoView, attachment.url) binding.mediaDescription.text = attachment.description binding.captionSheet.visible(showingDescription) diff --git a/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt b/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt index 04370768a..05a421538 100644 --- a/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt +++ b/app/src/main/java/app/pachli/fragment/ViewVideoFragment.kt @@ -34,6 +34,7 @@ import android.widget.FrameLayout import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.core.view.GestureDetectorCompat +import androidx.core.view.ViewCompat import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException @@ -359,7 +360,7 @@ class ViewVideoFragment : ViewMediaFragment() { // Ensure the description is visible over the video binding.mediaDescription.elevation = binding.videoView.elevation + 1 - binding.videoView.transitionName = attachment.url + ViewCompat.setTransitionName(binding.videoView, attachment.url) if (!startedTransition && shouldCallMediaReady) { startedTransition = true diff --git a/app/src/main/java/app/pachli/service/PachliTileService.kt b/app/src/main/java/app/pachli/service/PachliTileService.kt index 597dfa580..0bf838e43 100644 --- a/app/src/main/java/app/pachli/service/PachliTileService.kt +++ b/app/src/main/java/app/pachli/service/PachliTileService.kt @@ -16,14 +16,10 @@ package app.pachli.service -import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.PendingIntent -import android.content.Context -import android.content.Intent import android.os.Build import android.service.quicksettings.TileService -import app.pachli.components.notifications.pendingIntentFlags import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.MainActivityIntent @@ -33,21 +29,14 @@ import app.pachli.core.navigation.MainActivityIntent */ @TargetApi(24) class PachliTileService : TileService() { - @SuppressLint("StartActivityAndCollapseDeprecated") override fun onClick() { val intent = MainActivityIntent.openCompose(this, ComposeOptions()) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - startActivityAndCollapse(getActivityPendingIntent(this, 0, intent)) + val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE) + startActivityAndCollapse(pendingIntent) } else { + @Suppress("DEPRECATION") startActivityAndCollapse(intent) } } - - private fun getActivityPendingIntent(context: Context, requestCode: Int, intent: Intent): PendingIntent { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.getActivity(context, requestCode, intent, pendingIntentFlags(false)) - } else { - PendingIntent.getActivity(context, requestCode, intent, pendingIntentFlags(false)) - } - } } diff --git a/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt b/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt index 69d7c2dde..6ecfd65ff 100644 --- a/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt +++ b/app/src/main/java/app/pachli/viewmodel/EditProfileViewModel.kt @@ -40,8 +40,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel import java.io.File import javax.inject.Inject import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -77,6 +79,11 @@ class EditProfileViewModel @Inject constructor( private var apiProfileAccount: Account? = null + private val _isDirty = MutableStateFlow(false) + + /** True if the user has made unsaved changes to the profile */ + val isDirty = _isDirty.asStateFlow() + fun obtainProfile() = viewModelScope.launch { if (profileData.value == null || profileData.value is Error) { profileData.postValue(Loading()) @@ -170,10 +177,8 @@ class EditProfileViewModel @Inject constructor( } } - internal fun hasUnsavedChanges(newProfileData: ProfileDataInUi): Boolean { - val diff = getProfileDiff(apiProfileAccount, newProfileData) - - return diff.hasChanges() + internal fun onChange(newProfileData: ProfileDataInUi) { + _isDirty.value = getProfileDiff(apiProfileAccount, newProfileData).hasChanges() } private fun getProfileDiff(oldProfileAccount: Account?, newProfileData: ProfileDataInUi): DiffProfileData { diff --git a/app/src/main/res/layout/activity_edit_filter.xml b/app/src/main/res/layout/activity_edit_filter.xml index a2627f8e9..77bfd1979 100644 --- a/app/src/main/res/layout/activity_edit_filter.xml +++ b/app/src/main/res/layout/activity_edit_filter.xml @@ -27,6 +27,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="16dp" + app:errorEnabled="true" android:hint="@string/label_filter_title"> + + + android:entries="@array/filter_duration_names" /> + + ارسال إشعار عن شكاوى المدراء اضغط على الدائرة أو اسحبها لاختيار النقطة المحورية التي ستكون مرئية دائمًا في الصور المصغرة. حفظ المسودة؟ (سيتم رفع المرفقات مرة أخرى عند استعادة المسودة.) - لديك تعديلات لم تحفظ. + لديك تعديلات لم تحفظ. عدَّلَ %s شكوى جديدة عن %s %1$s :عدّله diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index bd56d3187..a7d13219a 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -374,7 +374,7 @@ %s пашыраных %s пашыраных - У Вас засталіся незахаваныя змены. + У Вас засталіся незахаваныя змены. Разгарнуць/згарнуць допісы Адкрыць допіс Патрэбна перазапусціць праграму diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 14ef89874..618eec0b9 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -508,7 +508,7 @@ Redacta la publicació Mostra el diàleg de confirmació abans de marcar com a preferit Deixar de seguir #%s\? - Tens canvis no desats. + Tens canvis no desats. Toqueu o arrossegueu el cercle per triar el punt focal que sempre serà visible a les miniatures. \u0020(🔗 %s) Mostra el nom d\'usuari a les barres d\'eines diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index b8be8b783..ce0ed594b 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -563,7 +563,7 @@ Golygiadau AMGEN Hepgor newidiadau - Mae gennych newidiadau heb eu cadw. + Mae gennych newidiadau heb eu cadw. Tewi hysbysiadau Llwytho trywydd Rhannu ddolen i gyfrif diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a90523f82..e7bc1a0da 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -515,7 +515,7 @@ Deaktiviert <nicht gesetzt> <ungültig> - Du hast nicht gespeicherte Änderungen. + Du hast nicht gespeicherte Änderungen. Port sollte zwischen %d und %d liegen Fehler beim Stummschalten von #%s Hochladen fehlgeschlagen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index d9d97301a..577c060db 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -526,7 +526,7 @@ Hay una nueva denuncia Descartar cambios Continuar editando - Tienes cambios sin guardar. + Tienes cambios sin guardar. Ediciones La subida falló Descartar @@ -683,4 +683,4 @@ Gestionar listas Listas - cargando… Listas - falló en cargar - \ No newline at end of file + diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 258bb72a0..22eec51d3 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -534,7 +534,7 @@ ممکن است از روش‌های تأیید خویت اضافی پشتیبانی کند؛ ولی نیازمند مرورگری پشتیبانی شده است. دور انداختن تغییرات ادامهٔ ویرایش - تغییراتی ذخیره نشده دارید. + تغییراتی ذخیره نشده دارید. هم‌رسانی پیوند به حساب هم‌رسانی نام کاربری حساب هم‌رسانی نشانی حساب به… diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index b26fae38c..0f42399d3 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -376,7 +376,7 @@ päättyneet äänestykset %ds Irrottaminen epäonnistui - Sinulla on tallentamattomia muutoksia. + Sinulla on tallentamattomia muutoksia. Seuraamista pyydetty Poista luonnos? Ilmoituksia haetaan… diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index c55c59ca4..754d01ad9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -568,7 +568,7 @@ Fonctionne dans la plupart des cas. Aucune autre application n\'aura accès à vos données. Peut permettre des méthodes d\'authentification supplémentaires, mais un navigateur compatible est nécessaire. Masquer les notifications - Il y a des modifications non enregistrées. + Il y a des modifications non enregistrées. Modifié Vous n\'avez pas encore de liste Gérer les listes diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index 19622a1d5..093f8efd6 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -548,7 +548,7 @@ %s · Tha postaichean ris, %d dhiubh Co-roinn ceangal dhan chunntas Chaidh lethbhreac a dhèanamh dhen ainm-chleachdaiche - Tha atharraichean gun sàbhaladh agad. + Tha atharraichean gun sàbhaladh agad. Dh’fhàillig luchdadh bun-tùs a’ phuist on fhrithealaiche. Dh’fhàillig luchdadh suas a’ phuist agad is chaidh a shàbhaladh ’na dhreachd. \n diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 0c4409609..a8b1d10e8 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -514,7 +514,7 @@ Creado por %1$s Desbotar cambios Continuar a edición - Hai cambios non gardados. + Hai cambios non gardados. Comparte ligazón da conta Comparte identificador da conta Compartir URL da conta en… diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index c0136ad54..0ea5cb961 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -524,7 +524,7 @@ ALT Változtatások elvetése Szerkesztés folytatása - Elmentetlen változtatásaid vannak. + Elmentetlen változtatásaid vannak. Fiókra történő hivatkozás megosztása Fiók felhasználói nevének megosztása Fiók URL megosztása vele… diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index 749ed682d..ae9e8f61c 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -507,7 +507,7 @@ Gáttin ætti að vera á milli %d og %d Mistókst að hlaða inn uppruna stöðufærslu af netþjóninum. Hleð inn þræði - Þú ert með óvistaðar breytingar. + Þú ert með óvistaðar breytingar. Óvirkt <ekki stillt> <ógilt> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e2d079718..340340307 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -542,7 +542,7 @@ %s ha segnalato %s %s · %d post allegati aggiungi reazione - Hai delle modifiche non salvate. + Hai delle modifiche non salvate. Non segui più #%s Caricamento dello status della sorgente dal server fallito. Modificato %s diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d87c74e9a..b0660afe2 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -512,7 +512,7 @@ %1$s の投稿 アカウントがロックされていなかったとしても、%1$s のスタッフは以下のアカウントのフォローリクエストを確認した方がいいと判断しました。 中心点の設定 - 保存していない変更があります。 + 保存していない変更があります。 サーバーからステータスの元情報を取得できませんでした。 無効 <設定なし> diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 7f7a5126b..cdfacba9b 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -331,7 +331,7 @@ %1$s, %2$s, %3$s un %4$d citi Multivide Vienmēr rādīt sensitīvu saturu - Tev ir nesaglabātas izmaiņas. + Tev ir nesaglabātas izmaiņas. Multivide: %s Aptauja ar izvēlēm: %1$s, %2$s, %3$s, %4$s; %5$s %1$s • %2$s diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index d598276b2..545f16c24 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -554,7 +554,7 @@ Følgeforespørsel akseptert Forkast endringer Fortsett endring - Du har ulagrede endringer. + Du har ulagrede endringer. Du har ingen lister, enda Feil under lading av lister Forvalte lister diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a6021eaf1..ed83f9e4c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -488,7 +488,7 @@ Dit ingeplande bericht verwijderen\? Kan niet vastmaken Uitgeschakeld - Er zijn niet opgeslagen wijzigingen. + Er zijn niet opgeslagen wijzigingen. Meldingen negeren Bewerkingen Standaardtaal van berichten diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 413098981..62d21b365 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -520,7 +520,7 @@ ALT Ignorar las modificacions Téner de modificar - Avètz de modificacions pas salvadas. + Avètz de modificacions pas salvadas. Cargament del fil Desactivat <pas definit> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a58d55f13..2ae723533 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -526,7 +526,7 @@ %1$s edytował %1$s stworzył Edycje - Masz niezapisane zmiany. + Masz niezapisane zmiany. Błąd wysyłania Pokaż szkice Odrzuć diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 1c680aa40..5446fa1e6 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -510,7 +510,7 @@ Pode oferecer suporte a métodos de autenticação adicionais, mas requer um navegador compatível. %1$s editou Continuar editando - Você tem alterações não salvas. + Você tem alterações não salvas. Editado Excluir este Toot agendado? Deixar de seguir #%s\? diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 3055d29f9..67e1af7d5 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -481,7 +481,7 @@ व्यक्तित्वविवरणलेखा अनुसरतु कालानुक्रमपङ्क्त्याः सूचनाः परिमिताः कुरुताम् परिमितावेदनानि प्रति ज्ञापनसूचनाः - भवतः अरक्षितानि परिवर्तनानि सन्ति। + भवतः अरक्षितानि परिवर्तनानि सन्ति। नूतनम् आवेदनमस्ति सुस्थितिः इदं कालबद्धदौत्यं विनश्येत् किम् \? diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index edff0041a..bae08b268 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -532,7 +532,7 @@ %1$s skapade Förkasta ändringar Fortsätt redigera - Du har ändringar som inte sparats. + Du har ändringar som inte sparats. Uppladdning misslyckades Ett fel inträffade när inlägget skulle laddas upp och har sparats till utkast. \n diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 9e15778d6..59e29abb3 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -524,7 +524,7 @@ %1$s oluşturdu Etiketi takip et #etiket - Kaydedilmemiş değişikliklerin var. + Kaydedilmemiş değişikliklerin var. %1$s düzenledi Düzenlemeler Açıklama diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index b57562903..73578b9f5 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -530,7 +530,7 @@ Не вдалося завантажити джерело стану з сервера. ALT Відкинути зміни - У вас є незбережені зміни. + У вас є незбережені зміни. Продовжити редагування Беззвучні сповіщення Редагування diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 6f86c4632..991db7f9f 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -499,7 +499,7 @@ Hủy bỏ thay đổi Tiếp tục sửa - Thay đổi chưa được lưu. + Thay đổi chưa được lưu. Ẩn thông báo Sửa %1$s Đăng %1$s diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 8db454007..bd3e97e09 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -513,7 +513,7 @@ ALT 放弃更改 继续编辑 - 你有未保存的更改。 + 你有未保存的更改。 %1$s 创建了 将通知静音 编辑 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed1a49cf8..e25657645 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -412,7 +412,7 @@ Delete draft? Save draft? Save draft? (Attachments will be uploaded again when you restore the draft.) - You have unsaved changes. + You have unsaved changes. Sending post… Error sending post Sending Posts @@ -703,5 +703,8 @@ Added \'%1$s\' to tabs Remove tab Manage tabs + At least one keyword or phrase is required + At least one filter context is required + Title is required diff --git a/app/src/test/java/app/pachli/FilterV1Test.kt b/app/src/test/java/app/pachli/FilterV1Test.kt index 75406d832..aae4cc108 100644 --- a/app/src/test/java/app/pachli/FilterV1Test.kt +++ b/app/src/test/java/app/pachli/FilterV1Test.kt @@ -18,7 +18,7 @@ package app.pachli import androidx.test.ext.junit.runners.AndroidJUnit4 -import app.pachli.components.filters.EditFilterActivity +import app.pachli.components.filters.EditFilterViewModel.Companion.getSecondsForDurationIndex import app.pachli.core.network.model.Attachment import app.pachli.core.network.model.Filter import app.pachli.core.network.model.FilterContext @@ -259,7 +259,7 @@ class FilterV1Test { fun unchangedExpiration_shouldBeNegative_whenFilterIsExpired() { val expiredBySeconds = 3600 val expiredDate = Date.from(Instant.now().minusSeconds(expiredBySeconds.toLong())) - val updatedDuration = EditFilterActivity.getSecondsForDurationIndex(-1, null, expiredDate) + val updatedDuration = getSecondsForDurationIndex(-1, null, expiredDate) assert(updatedDuration != null && updatedDuration.toInt() <= -expiredBySeconds) } @@ -267,7 +267,7 @@ class FilterV1Test { fun unchangedExpiration_shouldBePositive_whenFilterIsUnexpired() { val expiresInSeconds = 3600 val expiredDate = Date.from(Instant.now().plusSeconds(expiresInSeconds.toLong())) - val updatedDuration = EditFilterActivity.getSecondsForDurationIndex(-1, null, expiredDate) + val updatedDuration = getSecondsForDurationIndex(-1, null, expiredDate) assert(updatedDuration != null && updatedDuration.toInt() > (expiresInSeconds - 60)) } diff --git a/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt b/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt index a122b68b5..f79df56d8 100644 --- a/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt +++ b/app/src/test/java/app/pachli/components/compose/ComposeActivityTest.kt @@ -239,7 +239,7 @@ class ComposeActivityTest { rule.launch() rule.getScenario().onActivity { insertSomeTextInContent(it, content) - assertEquals(content.length, it.calculateTextLength()) + assertEquals(content.length, it.viewModel.statusLength.value) } } @@ -249,7 +249,7 @@ class ComposeActivityTest { rule.launch() rule.getScenario().onActivity { insertSomeTextInContent(it, content) - assertEquals(6, it.calculateTextLength()) + assertEquals(6, it.viewModel.statusLength.value) } } @@ -259,7 +259,7 @@ class ComposeActivityTest { rule.launch() rule.getScenario().onActivity { insertSomeTextInContent(it, content) - assertEquals(7, it.calculateTextLength()) + assertEquals(7, it.viewModel.statusLength.value) } } @@ -271,7 +271,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, content) assertEquals( InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } @@ -282,7 +282,7 @@ class ComposeActivityTest { rule.launch() rule.getScenario().onActivity { insertSomeTextInContent(it, content) - assertEquals(21, it.calculateTextLength()) + assertEquals(21, it.viewModel.statusLength.value) } } @@ -295,7 +295,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, additionalContent + url) assertEquals( additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } @@ -310,7 +310,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, shortUrl + additionalContent + url) assertEquals( additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } @@ -324,7 +324,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, url + additionalContent + url) assertEquals( additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } @@ -340,7 +340,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, additionalContent + url) assertEquals( additionalContent.length + customUrlLength, - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } @@ -357,7 +357,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, shortUrl + additionalContent + url) assertEquals( additionalContent.length + (customUrlLength * 2), - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } @@ -373,7 +373,7 @@ class ComposeActivityTest { insertSomeTextInContent(it, url + additionalContent + url) assertEquals( additionalContent.length + (customUrlLength * 2), - it.calculateTextLength(), + it.viewModel.statusLength.value, ) } } diff --git a/app/src/test/java/app/pachli/components/compose/StatusLengthTest.kt b/app/src/test/java/app/pachli/components/compose/StatusLengthTest.kt index bf047fcff..be4405a33 100644 --- a/app/src/test/java/app/pachli/components/compose/StatusLengthTest.kt +++ b/app/src/test/java/app/pachli/components/compose/StatusLengthTest.kt @@ -22,15 +22,15 @@ import app.pachli.util.highlightSpans import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.Parameterized +import org.robolectric.ParameterizedRobolectricTestRunner -@RunWith(Parameterized::class) +@RunWith(ParameterizedRobolectricTestRunner::class) class StatusLengthTest( private val text: String, private val expectedLength: Int, ) { companion object { - @Parameterized.Parameters(name = "{0}") + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") @JvmStatic fun data(): Iterable { return listOf( @@ -61,7 +61,7 @@ class StatusLengthTest( assertEquals( expectedLength, - ComposeActivity.statusLength(spannedText, null, 23), + ComposeViewModel.statusLength(spannedText, "", 23), ) } @@ -75,7 +75,7 @@ class StatusLengthTest( ) assertEquals( expectedLength + cwText.length, - ComposeActivity.statusLength(spannedText, cwText, 23), + ComposeViewModel.statusLength(spannedText, cwText.toString(), 23), ) } } diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt index 320212c41..23b0e553b 100644 --- a/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/BaseActivity.kt @@ -37,6 +37,9 @@ import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import app.pachli.core.accounts.AccountManager +import app.pachli.core.activity.extensions.canOverrideActivityTransitions +import app.pachli.core.activity.extensions.getTransitionKind +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.database.model.AccountEntity import app.pachli.core.designsystem.EmbeddedFontFamily import app.pachli.core.designsystem.R as DR @@ -82,6 +85,13 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (canOverrideActivityTransitions()) { + intent.getTransitionKind()?.let { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, it.openEnter, it.openExit) + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, it.closeEnter, it.closeExit) + } + } + // Set the theme from preferences val theme = AppTheme.from(sharedPreferencesRepository) Timber.d("activeTheme: %s", theme) @@ -156,11 +166,6 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { return true } - fun startActivityWithSlideInAnimation(intent: Intent) { - super.startActivity(intent) - overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left) - } - override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { onBackPressedDispatcher.onBackPressed() @@ -171,11 +176,13 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { override fun finish() { super.finish() - overridePendingTransition(DR.anim.slide_from_left, DR.anim.slide_to_right) - } - fun finishWithoutSlideOutAnimation() { - super.finish() + if (!canOverrideActivityTransitions()) { + intent.getTransitionKind()?.let { + @Suppress("DEPRECATION") + overridePendingTransition(it.closeEnter, it.closeExit) + } + } } private fun redirectIfNotLoggedIn() { @@ -183,7 +190,7 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { if (account == null) { val intent = LoginActivityIntent(this) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) finish() } } @@ -259,7 +266,7 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider { accountManager.setActiveAccount(account.id) val intent = MainActivityIntent.redirect(this, account.id, url) startActivity(intent) - finishWithoutSlideOutAnimation() + finish() } override fun onRequestPermissionsResult( diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/BottomSheetActivity.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/BottomSheetActivity.kt index 1ee6d58de..4fb2b8a14 100644 --- a/core/activity/src/main/kotlin/app/pachli/core/activity/BottomSheetActivity.kt +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/BottomSheetActivity.kt @@ -24,6 +24,9 @@ import android.widget.LinearLayout import android.widget.Toast import androidx.annotation.VisibleForTesting import androidx.lifecycle.lifecycleScope +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.navigation.AccountActivityIntent import app.pachli.core.navigation.ViewThreadActivityIntent import app.pachli.core.network.retrofit.MastodonApi @@ -105,13 +108,13 @@ abstract class BottomSheetActivity : BaseActivity() { open fun viewThread(statusId: String, url: String?) { if (!isSearching()) { val intent = ViewThreadActivityIntent(this, statusId, url) - startActivityWithSlideInAnimation(intent) + startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END) } } open fun viewAccount(id: String) { val intent = AccountActivityIntent(this, id) - startActivityWithSlideInAnimation(intent) + startActivityWithDefaultTransition(intent) } protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) { diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/extensions/ActivityExtensions.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/extensions/ActivityExtensions.kt new file mode 100644 index 000000000..86498ca75 --- /dev/null +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/extensions/ActivityExtensions.kt @@ -0,0 +1,80 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.activity.extensions + +import android.app.Activity +import android.app.Activity.OVERRIDE_TRANSITION_CLOSE +import android.app.Activity.OVERRIDE_TRANSITION_OPEN +import android.content.Intent +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.BuildConfig + +/** + * Starts the activity in [intent] (which must be a subclass of [BaseActivity]) + * using [transitionKind] as the open/close transition. + */ +fun Activity.startActivityWithTransition(intent: Intent, transitionKind: TransitionKind) { + if (BuildConfig.DEBUG) { + if (this !is BaseActivity) { + throw IllegalStateException("startActivityWithTransition must be used with BaseActivity subclass") + } + } + + intent.putExtra(EXTRA_TRANSITION_KIND, transitionKind) + startActivity(intent) + + if (canOverrideActivityTransitions()) { + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, transitionKind.openEnter, transitionKind.openExit) + } else { + @Suppress("DEPRECATION") + overridePendingTransition(transitionKind.openEnter, transitionKind.openExit) + } +} + +/** See [Activity.startActivityWithTransition] */ +fun Activity.startActivityWithDefaultTransition(intent: Intent) = startActivityWithTransition(intent, TransitionKind.DEFAULT) + +/** + * Overrides any "close" transition already set for this activity and + * replaces them with [transitionKind]. + * + * Call this after calling [Activity.finish] + */ +fun Activity.setCloseTransition(transitionKind: TransitionKind) { + if (BuildConfig.DEBUG) { + if (this !is BaseActivity) { + throw IllegalStateException("startActivityWithTransition must be used with BaseActivity subclass") + } + } + + if (canOverrideActivityTransitions()) { + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, transitionKind.closeEnter, transitionKind.closeExit) + } else { + @Suppress("DEPRECATION") + overridePendingTransition(transitionKind.closeEnter, transitionKind.closeExit) + } +} + +/** + * @return True if the Android version supports [Activity.overrideActivityTransition], + * false if [Activity.overridePendingTransition] must be used. + */ +@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +fun canOverrideActivityTransitions() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE diff --git a/core/activity/src/main/kotlin/app/pachli/core/activity/extensions/IntentExtensions.kt b/core/activity/src/main/kotlin/app/pachli/core/activity/extensions/IntentExtensions.kt new file mode 100644 index 000000000..55bbec3ea --- /dev/null +++ b/core/activity/src/main/kotlin/app/pachli/core/activity/extensions/IntentExtensions.kt @@ -0,0 +1,71 @@ +/* + * Copyright 2024 Pachli Association + * + * This file is a part of Pachli. + * + * This program is free software; you can redistribute it and/or modify it under the terms of the + * GNU General Public License as published by the Free Software Foundation; either version 3 of the + * License, or (at your option) any later version. + * + * Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even + * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General + * Public License for more details. + * + * You should have received a copy of the GNU General Public License along with Pachli; if not, + * see . + */ + +package app.pachli.core.activity.extensions + +import android.content.Intent +import android.os.Build +import androidx.annotation.AnimRes +import app.pachli.core.designsystem.R as DR + +const val EXTRA_TRANSITION_KIND = "transition_kind" + +/** + * The type of transition and animation resources to use when opening and closing + * an activity. + * + * @property openEnter When opening an activity, a resource ID of the animation resource to + * use for the incoming activity. Use 0 for no animation. + * @property openExit When opening an activity, a resource ID of the animation resource to + * use for the outgoing activity. Use 0 for no animation + * @property closeEnter When closing an activity, a resource ID of the animation resource to + * use for the incoming activity. Use 0 for no animation. + * @property closeExit When closing an activity, a resource ID of the animation resource to + * use for the outgoing activity. Use 0 for no animation + */ +enum class TransitionKind( + @AnimRes val openEnter: Int, + @AnimRes val openExit: Int, + @AnimRes val closeEnter: Int, + @AnimRes val closeExit: Int, +) { + /** Default transition */ + DEFAULT(DR.anim.activity_open_enter, DR.anim.activity_open_exit, DR.anim.activity_close_enter, DR.anim.activity_close_exit), + + /** + * Slide from the user's "end" perspective (right side for LTR text, left side for RTL text). + * Use when a spatial relationship makes sense, such as transitioning from a single status + * to the thread that contains that status. + */ + SLIDE_FROM_END(DR.anim.slide_from_end, DR.anim.slide_to_start, DR.anim.slide_from_start, DR.anim.slide_to_end), + + /** + * Explode out from the centre of the screen. Use to indicate a significant change in + * application state (e.g., changing accounts). + */ + EXPLODE(DR.anim.explode, DR.anim.activity_open_exit, 0, 0), +} + +/** @return The [TransitionKind] included in this intent, or null */ +fun Intent.getTransitionKind(): TransitionKind? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getSerializableExtra(EXTRA_TRANSITION_KIND, TransitionKind::class.java) + } else { + @Suppress("DEPRECATION") + getSerializableExtra(EXTRA_TRANSITION_KIND) as? TransitionKind + } +} diff --git a/core/designsystem/src/main/res/anim-ldrtl-v33/activity_close_enter.xml b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_close_enter.xml new file mode 100644 index 000000000..02c3f421e --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_close_enter.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl-v33/activity_close_exit.xml b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_close_exit.xml new file mode 100644 index 000000000..6be7f126f --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_close_exit.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl-v33/activity_open_enter.xml b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_open_enter.xml new file mode 100644 index 000000000..8593c0625 --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_open_enter.xml @@ -0,0 +1,53 @@ + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl-v33/activity_open_exit.xml b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_open_exit.xml new file mode 100644 index 000000000..26dd07969 --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl-v33/activity_open_exit.xml @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl/activity_close_enter.xml b/core/designsystem/src/main/res/anim-ldrtl/activity_close_enter.xml new file mode 100644 index 000000000..50bb78e85 --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl/activity_close_enter.xml @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl/activity_close_exit.xml b/core/designsystem/src/main/res/anim-ldrtl/activity_close_exit.xml new file mode 100644 index 000000000..c7402f82a --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl/activity_close_exit.xml @@ -0,0 +1,42 @@ + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl/activity_open_enter.xml b/core/designsystem/src/main/res/anim-ldrtl/activity_open_enter.xml new file mode 100644 index 000000000..dcc97ca73 --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl/activity_open_enter.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-ldrtl/activity_open_exit.xml b/core/designsystem/src/main/res/anim-ldrtl/activity_open_exit.xml new file mode 100644 index 000000000..b4699df3e --- /dev/null +++ b/core/designsystem/src/main/res/anim-ldrtl/activity_open_exit.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/anim/slide_to_right.xml b/core/designsystem/src/main/res/anim-ldrtl/slide_from_end.xml similarity index 74% rename from core/designsystem/src/main/res/anim/slide_to_right.xml rename to core/designsystem/src/main/res/anim-ldrtl/slide_from_end.xml index 8ded764f7..d038b355a 100644 --- a/core/designsystem/src/main/res/anim/slide_to_right.xml +++ b/core/designsystem/src/main/res/anim-ldrtl/slide_from_end.xml @@ -2,5 +2,5 @@ - \ No newline at end of file + android:duration="@integer/activity_slide_transition_duration_ms"/> + diff --git a/core/designsystem/src/main/res/anim/slide_to_left.xml b/core/designsystem/src/main/res/anim-ldrtl/slide_from_start.xml similarity index 74% rename from core/designsystem/src/main/res/anim/slide_to_left.xml rename to core/designsystem/src/main/res/anim-ldrtl/slide_from_start.xml index 21688e2ca..85948eed9 100644 --- a/core/designsystem/src/main/res/anim/slide_to_left.xml +++ b/core/designsystem/src/main/res/anim-ldrtl/slide_from_start.xml @@ -2,5 +2,5 @@ - \ No newline at end of file + android:duration="@integer/activity_slide_transition_duration_ms"/> + diff --git a/core/designsystem/src/main/res/anim/slide_from_right.xml b/core/designsystem/src/main/res/anim-ldrtl/slide_to_end.xml similarity index 74% rename from core/designsystem/src/main/res/anim/slide_from_right.xml rename to core/designsystem/src/main/res/anim-ldrtl/slide_to_end.xml index 3c595d04b..c54aa79e3 100644 --- a/core/designsystem/src/main/res/anim/slide_from_right.xml +++ b/core/designsystem/src/main/res/anim-ldrtl/slide_to_end.xml @@ -2,5 +2,5 @@ - \ No newline at end of file + android:duration="@integer/activity_slide_transition_duration_ms"/> + diff --git a/core/designsystem/src/main/res/anim/slide_from_left.xml b/core/designsystem/src/main/res/anim-ldrtl/slide_to_start.xml similarity index 74% rename from core/designsystem/src/main/res/anim/slide_from_left.xml rename to core/designsystem/src/main/res/anim-ldrtl/slide_to_start.xml index 5c7fe5227..69173d375 100644 --- a/core/designsystem/src/main/res/anim/slide_from_left.xml +++ b/core/designsystem/src/main/res/anim-ldrtl/slide_to_start.xml @@ -2,5 +2,5 @@ - \ No newline at end of file + android:duration="@integer/activity_slide_transition_duration_ms"/> + diff --git a/core/designsystem/src/main/res/anim-v33/activity_close_enter.xml b/core/designsystem/src/main/res/anim-v33/activity_close_enter.xml new file mode 100644 index 000000000..2defc1946 --- /dev/null +++ b/core/designsystem/src/main/res/anim-v33/activity_close_enter.xml @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-v33/activity_close_exit.xml b/core/designsystem/src/main/res/anim-v33/activity_close_exit.xml new file mode 100644 index 000000000..958873a60 --- /dev/null +++ b/core/designsystem/src/main/res/anim-v33/activity_close_exit.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-v33/activity_open_enter.xml b/core/designsystem/src/main/res/anim-v33/activity_open_enter.xml new file mode 100644 index 000000000..cc5b3142e --- /dev/null +++ b/core/designsystem/src/main/res/anim-v33/activity_open_enter.xml @@ -0,0 +1,53 @@ + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim-v33/activity_open_exit.xml b/core/designsystem/src/main/res/anim-v33/activity_open_exit.xml new file mode 100644 index 000000000..1c5fa7eef --- /dev/null +++ b/core/designsystem/src/main/res/anim-v33/activity_open_exit.xml @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/core/designsystem/src/main/res/anim/activity_close_enter.xml b/core/designsystem/src/main/res/anim/activity_close_enter.xml new file mode 100644 index 000000000..f877aff4d --- /dev/null +++ b/core/designsystem/src/main/res/anim/activity_close_enter.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/anim/activity_close_exit.xml b/core/designsystem/src/main/res/anim/activity_close_exit.xml new file mode 100644 index 000000000..66f6684ef --- /dev/null +++ b/core/designsystem/src/main/res/anim/activity_close_exit.xml @@ -0,0 +1,42 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/anim/activity_open_enter.xml b/core/designsystem/src/main/res/anim/activity_open_enter.xml new file mode 100644 index 000000000..70af5e447 --- /dev/null +++ b/core/designsystem/src/main/res/anim/activity_open_enter.xml @@ -0,0 +1,40 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/anim/activity_open_exit.xml b/core/designsystem/src/main/res/anim/activity_open_exit.xml new file mode 100644 index 000000000..398f7bed5 --- /dev/null +++ b/core/designsystem/src/main/res/anim/activity_open_exit.xml @@ -0,0 +1,41 @@ + + + + + + + + diff --git a/core/designsystem/src/main/res/anim/fade_in.xml b/core/designsystem/src/main/res/anim/fade_in.xml deleted file mode 100644 index 972e757ec..000000000 --- a/core/designsystem/src/main/res/anim/fade_in.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/core/designsystem/src/main/res/anim/fade_out.xml b/core/designsystem/src/main/res/anim/fade_out.xml deleted file mode 100644 index 9b48ae8f6..000000000 --- a/core/designsystem/src/main/res/anim/fade_out.xml +++ /dev/null @@ -1,6 +0,0 @@ - - \ No newline at end of file diff --git a/core/designsystem/src/main/res/anim/fast_out_extra_slow_in.xml b/core/designsystem/src/main/res/anim/fast_out_extra_slow_in.xml new file mode 100644 index 000000000..f419778d0 --- /dev/null +++ b/core/designsystem/src/main/res/anim/fast_out_extra_slow_in.xml @@ -0,0 +1,18 @@ + + + diff --git a/core/designsystem/src/main/res/anim/linear_interpolator.xml b/core/designsystem/src/main/res/anim/linear_interpolator.xml new file mode 100644 index 000000000..9b0cc3d47 --- /dev/null +++ b/core/designsystem/src/main/res/anim/linear_interpolator.xml @@ -0,0 +1,19 @@ + + + + diff --git a/core/designsystem/src/main/res/anim/slide_from_end.xml b/core/designsystem/src/main/res/anim/slide_from_end.xml new file mode 100644 index 000000000..c54aa79e3 --- /dev/null +++ b/core/designsystem/src/main/res/anim/slide_from_end.xml @@ -0,0 +1,6 @@ + + + + diff --git a/core/designsystem/src/main/res/anim/slide_from_start.xml b/core/designsystem/src/main/res/anim/slide_from_start.xml new file mode 100644 index 000000000..69173d375 --- /dev/null +++ b/core/designsystem/src/main/res/anim/slide_from_start.xml @@ -0,0 +1,6 @@ + + + + diff --git a/core/designsystem/src/main/res/anim/slide_to_end.xml b/core/designsystem/src/main/res/anim/slide_to_end.xml new file mode 100644 index 000000000..d038b355a --- /dev/null +++ b/core/designsystem/src/main/res/anim/slide_to_end.xml @@ -0,0 +1,6 @@ + + + + diff --git a/core/designsystem/src/main/res/anim/slide_to_start.xml b/core/designsystem/src/main/res/anim/slide_to_start.xml new file mode 100644 index 000000000..85948eed9 --- /dev/null +++ b/core/designsystem/src/main/res/anim/slide_to_start.xml @@ -0,0 +1,6 @@ + + + + diff --git a/core/designsystem/src/main/res/anim/standard_accelerate.xml b/core/designsystem/src/main/res/anim/standard_accelerate.xml new file mode 100644 index 000000000..68e2fa124 --- /dev/null +++ b/core/designsystem/src/main/res/anim/standard_accelerate.xml @@ -0,0 +1,22 @@ + + + + diff --git a/core/designsystem/src/main/res/values/integers.xml b/core/designsystem/src/main/res/values/integers.xml index e85c561cf..f3b708efd 100644 --- a/core/designsystem/src/main/res/values/integers.xml +++ b/core/designsystem/src/main/res/values/integers.xml @@ -3,4 +3,10 @@ 3 1 + + + 450 + + + 200 diff --git a/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt b/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt index 9035bc13f..dfe79568c 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/model/Filter.kt @@ -11,11 +11,11 @@ import kotlinx.parcelize.Parcelize @Parcelize @JsonClass(generateAdapter = true) data class Filter( - val id: String, - val title: String, - @Json(name = "context") val contexts: List, - @Json(name = "expires_at") val expiresAt: Date?, - @Json(name = "filter_action") val action: Action, + val id: String = "", + val title: String = "", + @Json(name = "context") val contexts: List = emptyList(), + @Json(name = "expires_at") val expiresAt: Date? = null, + @Json(name = "filter_action") val action: Action = Action.WARN, // This should not normally be empty. However, Mastodon does not include // this in a status' `filtered.filter` property (it's not null or empty, // it's missing) which breaks deserialisation. Patch this by ensuring it's diff --git a/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt b/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt index 831eff7c2..70ca5bc28 100644 --- a/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt +++ b/feature/about/src/main/kotlin/app/pachli/feature/about/AboutActivity.kt @@ -25,20 +25,30 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import app.pachli.core.activity.BottomSheetActivity +import app.pachli.core.common.extensions.viewBinding import app.pachli.core.designsystem.R as DR import app.pachli.core.ui.extensions.reduceSwipeSensitivity import app.pachli.feature.about.databinding.ActivityAboutBinding import com.bumptech.glide.request.target.FixedSizeDrawable +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.mikepenz.aboutlibraries.LibsBuilder import dagger.hilt.android.AndroidEntryPoint @AndroidEntryPoint class AboutActivity : BottomSheetActivity(), MenuProvider { + + private val binding: ActivityAboutBinding by viewBinding(ActivityAboutBinding::inflate) + + private val onBackPressedCallback = object : OnBackPressedCallback(false) { + override fun handleOnBackPressed() { + binding.pager.currentItem = 0 + } + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val binding = ActivityAboutBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) @@ -65,14 +75,17 @@ class AboutActivity : BottomSheetActivity(), MenuProvider { tab.text = adapter.title(position) }.attach() - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.pager.currentItem != 0) binding.pager.currentItem = 0 else finish() - } - }, - ) + binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { + override fun onTabSelected(tab: TabLayout.Tab) { + onBackPressedCallback.isEnabled = tab.position > 0 + } + + override fun onTabUnselected(tab: TabLayout.Tab) {} + + override fun onTabReselected(tab: TabLayout.Tab) {} + }) + + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) } } diff --git a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt index e8c2ce820..1ba51220d 100644 --- a/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt +++ b/feature/lists/src/main/kotlin/app/pachli/feature/lists/ListsActivity.kt @@ -39,6 +39,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.startActivityWithDefaultTransition import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.viewBinding @@ -261,7 +262,7 @@ class ListsActivity : BaseActivity(), MenuProvider { } private fun onListSelected(listId: String, listTitle: String) { - startActivityWithSlideInAnimation( + startActivityWithDefaultTransition( TimelineActivityIntent.list(this, listId, listTitle), ) } diff --git a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginActivity.kt b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginActivity.kt index 7083713d9..8f424417c 100644 --- a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginActivity.kt +++ b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginActivity.kt @@ -29,10 +29,12 @@ import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import app.pachli.core.activity.BaseActivity +import app.pachli.core.activity.extensions.TransitionKind +import app.pachli.core.activity.extensions.setCloseTransition +import app.pachli.core.activity.extensions.startActivityWithTransition import app.pachli.core.activity.openLinkInCustomTab import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.string.unicodeWrap -import app.pachli.core.designsystem.R as DR import app.pachli.core.navigation.LoginActivityIntent import app.pachli.core.navigation.MainActivityIntent import app.pachli.core.network.extensions.getServerErrorMessage @@ -130,13 +132,6 @@ class LoginActivity : BaseActivity() { return false } - override fun finish() { - super.finish() - if (isAdditionalLogin() || isAccountMigration()) { - overridePendingTransition(DR.anim.slide_from_left, DR.anim.slide_to_right) - } - } - override fun onCreateOptionsMenu(menu: Menu?): Boolean { menu?.add(R.string.action_browser_login)?.apply { setOnMenuItemClickListener { @@ -327,9 +322,9 @@ class LoginActivity : BaseActivity() { val intent = MainActivityIntent(this) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK - startActivity(intent) - finish() - overridePendingTransition(DR.anim.explode, DR.anim.explode) + startActivityWithTransition(intent, TransitionKind.EXPLODE) + finishAffinity() + setCloseTransition(TransitionKind.EXPLODE) }, { e -> setLoading(false) binding.domainTextInputLayout.error = diff --git a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewActivity.kt b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewActivity.kt index acf28511b..ee110d6d5 100644 --- a/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewActivity.kt +++ b/feature/login/src/main/kotlin/app/pachli/feature/login/LoginWebViewActivity.kt @@ -253,14 +253,10 @@ class LoginWebViewActivity : BaseActivity() { super.onDestroy() } - override fun finish() { - super.finishWithoutSlideOutAnimation() - } - override fun requiresLogin() = false private fun sendResult(result: LoginResult) { setResult(Activity.RESULT_OK, OauthLogin.makeResultIntent(result)) - finishWithoutSlideOutAnimation() + finish() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0fd02e53f..8c05e429f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ aboutlibraries = "11.1.3" acra = "5.11.3" agp = "8.3.2" -androidx-activity = "1.8.2" +androidx-activity = "1.9.0" androidx-appcompat = "1.6.1" androidx-browser = "1.8.0" androidx-cardview = "1.0.0" @@ -22,6 +22,7 @@ androidx-splashscreen = "1.0.1" androidx-swiperefresh-layout = "1.1.0" androidx-testing = "2.2.0" androidx-test-core-ktx = "1.5.0" +androidx-transition = "1.5.0-rc02" androidx-viewpager2 = "1.0.0" androidx-webkit = "1.8.0" androidx-work = "2.9.0" @@ -145,6 +146,7 @@ androidx-sharetarget = { module = "androidx.sharetarget:sharetarget", version.re androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "androidx-swiperefresh-layout" } androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidx-test-core-ktx" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-junit" } +androidx-transition = { module = "androidx.transition:transition-ktx", version.ref = "androidx-transition" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "androidx-viewpager2" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "androidx-webkit" } androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "androidx-work" } @@ -233,6 +235,7 @@ androidx = ["androidx-core-ktx", "androidx-appcompat", "androidx-fragment-ktx", "androidx-constraintlayout", "androidx-paging-runtime-ktx", "androidx-viewpager2", "androidx-work-runtime-ktx", "androidx-core-splashscreen", "androidx-activity", "androidx-media3-exoplayer", "androidx-media3-exoplayer-dash", "androidx-media3-exoplayer-hls", "androidx-media3-exoplayer-rtsp", "androidx-media3-datasource-okhttp", "androidx-media3-ui", + "androidx-transition", "android-material"] filemojicompat = ["filemojicompat-core", "filemojicompat-ui", "filemojicompat-defaults"] glide = ["glide-core", "glide-okhttp3-integration", "glide-animation-plugin"]