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"]