feat: Update activity transitions, prepare for predictive-back (#650)

Previous code used a single animation type (slide) when transitioning,
the transition was quite slow, and didn't behave appropriately if the
device was set to a RTL writing system.

In addition, handling the back affordance didn't work well with the new
"Predictive Back" feature in Android 14

(https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture).

Fix this.

## Transitions

To update the transitions the `startActivityWithSlideInAnimation()`
implementation (and associated `finishWithoutSlideOutAnimation()`) have
been replaced.

There are three transitions; `default`, `slide`, and `explode`,
represented as an enum passed to the activity intent.

The `default` transition is the activity transition from Android 14,
from the Android open source project

(https://cs.android.com/android/platform/superproject/+/android-14.0.0_r18:frameworks/base/core/res/res/anim/;bpv=1).
This is used for most transitions.

The `slide` transition is the pre-existing slide transition, with a
shorter transition time so it feels more responsive, and an RTL
implementation. This is used when there is a strong spatial component to
the navigation, for example, when going from:

- a status to its thread
- a preference menu item to its subscreen
- a filter in a list to the "edit filter" screen
- viewing your profile to editing your profile

The `explode` transition is used when the state of the app changes
significantly, such as when switching accounts.

Activities are now started with `startActivityWithTransition()` which
sets the intent and prepares the transition. `BaseActivity` checks the
intent for the transition type and makes further changes to the
transition as necessary.

## Predictive back

"Predictive back" needs to know what the back button would do before the
user interacts with it with an `onBackPressedCallback` that is either
enabled or disabled. This required refactoring some code (particularly
in `ComposeActivity`) to gather data ahead of time and enable/disable
the callback appropriately.

## Fixed bugs

- Back button wasn't stepping back through the tabs in AccountActivity
- Modifying a filter and pressing back without saving wasn't prompting
the user to save the changes
- Writing a content warning and then hiding it would still count the
text of the content warning toward's the post's length

## Other cleanups

- Use `ViewCompat.setTransitionName()` instead of setting the
`transitionName` property
- Delete the unused `fade_in` and `fade_out` animations.
- Use androidx-activity 1.9.0 to get the latest predictive back support
library code
- Show validation errors when creating / editing filters
This commit is contained in:
Nik Clayton 2024-04-26 23:18:30 +02:00 committed by GitHub
parent 2b3cbb6465
commit 93e6b38d43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
104 changed files with 1826 additions and 519 deletions

View File

@ -54,7 +54,7 @@
<issue
id="UnusedAttribute"
message="Attribute `networkSecurityConfig` is only used in API level 24 and higher (current min is 23)"
errorLine1=" android:networkSecurityConfig=&quot;@xml/network_security_config&quot;>"
errorLine1=" android:networkSecurityConfig=&quot;@xml/network_security_config&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
@ -62,6 +62,17 @@
column="9"/>
</issue>
<issue
id="UnusedAttribute"
message="Attribute `enableOnBackInvokedCallback` is only used in API level 33 and higher (current min is 23)"
errorLine1=" android:enableOnBackInvokedCallback=&quot;true&quot;>"
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/AndroidManifest.xml"
line="26"
column="9"/>
</issue>
<issue
id="SelectedPhotoAccess"
message="Your app is currently not handling Selected Photos Access introduced in Android 14+"
@ -883,7 +894,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/components/account/AccountActivity.kt"
line="493"
line="515"
column="9"/>
</issue>
@ -905,7 +916,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt"
line="44"
line="49"
column="9"/>
</issue>
@ -916,7 +927,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/java/app/pachli/adapter/AccountFieldEditAdapter.kt"
line="50"
line="55"
column="9"/>
</issue>

View File

@ -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">
<activity
android:name=".SplashActivity"

View File

@ -30,6 +30,7 @@ import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@ -59,6 +60,7 @@ import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import com.mikepenz.iconics.utils.colorInt
import com.mikepenz.iconics.utils.sizeDp
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
@ -75,7 +77,9 @@ class EditProfileActivity : BaseActivity() {
private val binding by viewBinding(ActivityEditProfileBinding::inflate)
private val accountFieldEditAdapter = AccountFieldEditAdapter()
private val accountFieldEditAdapter: AccountFieldEditAdapter = AccountFieldEditAdapter {
viewModel.onChange(currentProfileData)
}
private var maxAccountFields = InstanceInfoRepository.DEFAULT_MAX_ACCOUNT_FIELDS
@ -108,6 +112,12 @@ class EditProfileActivity : BaseActivity() {
fields = accountFieldEditAdapter.getFieldData(),
)
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
showUnsavedChangesDialog()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -216,19 +226,19 @@ class EditProfileActivity : BaseActivity() {
}
}
val onBackCallback = object : OnBackPressedCallback(enabled = true) {
override fun handleOnBackPressed() = checkForUnsavedChanges()
lifecycleScope.launch {
viewModel.isDirty.collectLatest { onBackPressedCallback.isEnabled = it }
}
onBackPressedDispatcher.addCallback(this, onBackCallback)
}
fun checkForUnsavedChanges() {
if (viewModel.hasUnsavedChanges(currentProfileData)) {
showUnsavedChangesDialog()
} else {
finish()
binding.displayNameEditText.doAfterTextChanged {
viewModel.onChange(currentProfileData)
}
binding.lockedCheckBox.setOnCheckedChangeListener { _, _ ->
viewModel.onChange(currentProfileData)
}
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
}
override fun onStop() {

View File

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

View File

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

View File

@ -25,7 +25,12 @@ import app.pachli.core.ui.BindingHolder
import app.pachli.databinding.ItemEditFieldBinding
import app.pachli.util.fixTextSelection
class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
/**
* @property onChange Call this whenever data in the UI fields changes
*/
class AccountFieldEditAdapter(
val onChange: () -> Unit,
) : RecyclerView.Adapter<BindingHolder<ItemEditFieldBinding>>() {
private val fieldData = mutableListOf<MutableStringPair>()
private var maxNameLength: Int? = null
@ -84,10 +89,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
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

View File

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

View File

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

View File

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

View File

@ -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<Int>) {
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<String>) {
@ -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.

View File

@ -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<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
@ -87,16 +115,27 @@ class ComposeViewModel @Inject constructor(
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val markMediaAsSensitive: MutableStateFlow<Boolean> =
private val _markMediaAsSensitive: MutableStateFlow<Boolean> =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow()
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
private val _statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val statusVisibility = _statusVisibility.asStateFlow()
private val _showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val showContentWarning = _showContentWarning.asStateFlow()
private val _poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null)
val poll = _poll.asStateFlow()
private val _scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null)
val scheduledAt = _scheduledAt.asStateFlow()
val media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val media = _media.asStateFlow()
private val _uploadError = MutableSharedFlow<Throwable>(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
}
}
}

View File

@ -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<SwitchMaterial, FilterContext>
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)
}
}
}
}

View File

@ -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<FilterKeyword>())
val action = MutableStateFlow(Filter.Action.WARN)
val duration = MutableStateFlow(0)
val contexts = MutableStateFlow(listOf<FilterContext>())
/** 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<FilterValidationError>())
/** 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<FilterContext>, 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<FilterContext>, 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<FilterContext>, 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)
}
}
}
}

View File

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

View File

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

View File

@ -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<Filter>) :
RecyclerView.Adapter<BindingHolder<ItemRemovableBinding>>() {
@ -34,11 +35,25 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
)
} ?: 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)

View File

@ -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<ComposeAutoCompleteAdapter.AutocompleteResult> {

View File

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

View File

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

View File

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

View File

@ -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<T : Any> :
}
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) {

View File

@ -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<StatusViewData>(), StatusActionLis
kind = ComposeOptions.ComposeKind.NEW,
),
)
bottomSheetActivity?.startActivityWithSlideInAnimation(intent)
bottomSheetActivity?.startActivityWithDefaultTransition(intent)
}
private fun more(statusViewData: StatusViewData, view: View) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<T : IStatusViewData> : 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<T : IStatusViewData> : 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,

View File

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

View File

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

View File

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

View File

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

View File

@ -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">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/filterTitle"
@ -43,6 +44,15 @@
style="@style/TextAppearance.Material3.TitleSmall"
android:textColor="?attr/colorAccent" />
<TextView
android:id="@+id/keywordChipsError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/error_filter_missing_keyword"
style="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/keywordChips"
android:layout_width="match_parent"
@ -100,8 +110,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:entries="@array/filter_duration_names"
/>
android:entries="@array/filter_duration_names" />
<TextView
android:layout_width="match_parent"
@ -146,6 +155,15 @@
android:minHeight="48dp"
android:text="@string/pref_title_account_filter_keywords" />
<TextView
android:id="@+id/filterContextError"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/error_filter_missing_context"
style="@style/TextAppearance.Material3.BodySmall"
android:textColor="?attr/colorError" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"

View File

@ -503,7 +503,7 @@
<string name="notification_report_description">ارسال إشعار عن شكاوى المدراء</string>
<string name="set_focus_description">اضغط على الدائرة أو اسحبها لاختيار النقطة المحورية التي ستكون مرئية دائمًا في الصور المصغرة.</string>
<string name="compose_save_draft_loses_media">حفظ المسودة؟ (سيتم رفع المرفقات مرة أخرى عند استعادة المسودة.)</string>
<string name="compose_unsaved_changes">لديك تعديلات لم تحفظ.</string>
<string name="unsaved_changes">لديك تعديلات لم تحفظ.</string>
<string name="post_edited">عدَّلَ %s</string>
<string name="notification_report_format">شكوى جديدة عن %s</string>
<string name="status_edit_info">%1$s :عدّله</string>

View File

@ -374,7 +374,7 @@
<item quantity="many"><b>%s</b> пашыраных</item>
<item quantity="other"><b>%s</b> пашыраных</item>
</plurals>
<string name="compose_unsaved_changes">У Вас засталіся незахаваныя змены.</string>
<string name="unsaved_changes">У Вас засталіся незахаваныя змены.</string>
<string name="expand_collapse_all_posts">Разгарнуць/згарнуць допісы</string>
<string name="action_open_post">Адкрыць допіс</string>
<string name="restart_required">Патрэбна перазапусціць праграму</string>

View File

@ -508,7 +508,7 @@
<string name="pachli_compose_post_quicksetting_label">Redacta la publicació</string>
<string name="pref_title_confirm_favourites">Mostra el diàleg de confirmació abans de marcar com a preferit</string>
<string name="action_unfollow_hashtag_format">Deixar de seguir #%s\?</string>
<string name="compose_unsaved_changes">Tens canvis no desats.</string>
<string name="unsaved_changes">Tens canvis no desats.</string>
<string name="set_focus_description">Toqueu o arrossegueu el cercle per triar el punt focal que sempre serà visible a les miniatures.</string>
<string name="url_domain_notifier">\u0020(🔗 %s)</string>
<string name="pref_title_show_self_username">Mostra el nom d\'usuari a les barres d\'eines</string>

View File

@ -563,7 +563,7 @@
<string name="title_edits">Golygiadau</string>
<string name="post_media_alt">AMGEN</string>
<string name="action_discard">Hepgor newidiadau</string>
<string name="compose_unsaved_changes">Mae gennych newidiadau heb eu cadw.</string>
<string name="unsaved_changes">Mae gennych newidiadau heb eu cadw.</string>
<string name="mute_notifications_switch">Tewi hysbysiadau</string>
<string name="a11y_label_loading_thread">Llwytho trywydd</string>
<string name="action_share_account_link">Rhannu ddolen i gyfrif</string>

View File

@ -515,7 +515,7 @@
<string name="pref_summary_http_proxy_disabled">Deaktiviert</string>
<string name="pref_summary_http_proxy_missing">&lt;nicht gesetzt&gt;</string>
<string name="pref_summary_http_proxy_invalid">&lt;ungültig&gt;</string>
<string name="compose_unsaved_changes">Du hast nicht gespeicherte Änderungen.</string>
<string name="unsaved_changes">Du hast nicht gespeicherte Änderungen.</string>
<string name="pref_title_http_proxy_port_message">Port sollte zwischen %d und %d liegen</string>
<string name="error_muting_hashtag_format">Fehler beim Stummschalten von #%s</string>
<string name="action_post_failed">Hochladen fehlgeschlagen</string>

View File

@ -526,7 +526,7 @@
<string name="pref_title_notification_filter_reports">Hay una nueva denuncia</string>
<string name="action_discard">Descartar cambios</string>
<string name="action_continue_edit">Continuar editando</string>
<string name="compose_unsaved_changes">Tienes cambios sin guardar.</string>
<string name="unsaved_changes">Tienes cambios sin guardar.</string>
<string name="title_edits">Ediciones</string>
<string name="action_post_failed">La subida falló</string>
<string name="action_post_failed_do_nothing">Descartar</string>
@ -683,4 +683,4 @@
<string name="manage_lists">Gestionar listas</string>
<string name="title_lists_loading">Listas - cargando…</string>
<string name="title_lists_failed">Listas - falló en cargar</string>
</resources>
</resources>

View File

@ -534,7 +534,7 @@
<string name="description_browser_login">ممکن است از روش‌های تأیید خویت اضافی پشتیبانی کند؛ ولی نیازمند مرورگری پشتیبانی شده است.</string>
<string name="action_discard">دور انداختن تغییرات</string>
<string name="action_continue_edit">ادامهٔ ویرایش</string>
<string name="compose_unsaved_changes">تغییراتی ذخیره نشده دارید.</string>
<string name="unsaved_changes">تغییراتی ذخیره نشده دارید.</string>
<string name="action_share_account_link">هم‌رسانی پیوند به حساب</string>
<string name="action_share_account_username">هم‌رسانی نام کاربری حساب</string>
<string name="send_account_link_to">هم‌رسانی نشانی حساب به…</string>

View File

@ -376,7 +376,7 @@
<string name="pref_title_notification_filter_poll">päättyneet äänestykset</string>
<string name="abbreviated_in_seconds">%ds</string>
<string name="failed_to_unpin">Irrottaminen epäonnistui</string>
<string name="compose_unsaved_changes">Sinulla on tallentamattomia muutoksia.</string>
<string name="unsaved_changes">Sinulla on tallentamattomia muutoksia.</string>
<string name="state_follow_requested">Seuraamista pyydetty</string>
<string name="compose_delete_draft">Poista luonnos?</string>
<string name="notification_notification_worker">Ilmoituksia haetaan…</string>

View File

@ -568,7 +568,7 @@
<string name="description_login">Fonctionne dans la plupart des cas. Aucune autre application n\'aura accès à vos données.</string>
<string name="description_browser_login">Peut permettre des méthodes d\'authentification supplémentaires, mais un navigateur compatible est nécessaire.</string>
<string name="mute_notifications_switch">Masquer les notifications</string>
<string name="compose_unsaved_changes">Il y a des modifications non enregistrées.</string>
<string name="unsaved_changes">Il y a des modifications non enregistrées.</string>
<string name="description_post_edited">Modifié</string>
<string name="select_list_empty">Vous n\'avez pas encore de liste</string>
<string name="select_list_manage">Gérer les listes</string>

View File

@ -548,7 +548,7 @@
<string name="notification_summary_report_format">%s · Tha postaichean ris, %d dhiubh</string>
<string name="action_share_account_link">Co-roinn ceangal dhan chunntas</string>
<string name="account_username_copied">Chaidh lethbhreac a dhèanamh dhen ainm-chleachdaiche</string>
<string name="compose_unsaved_changes">Tha atharraichean gun sàbhaladh agad.</string>
<string name="unsaved_changes">Tha atharraichean gun sàbhaladh agad.</string>
<string name="error_status_source_load">Dhfhàillig luchdadh bun-tùs a phuist on fhrithealaiche.</string>
<string name="action_post_failed_detail">Dhfhàillig luchdadh suas a phuist agad is chaidh a shàbhaladh na dhreachd.
\n

View File

@ -514,7 +514,7 @@
<string name="status_created_info">Creado por %1$s</string>
<string name="action_discard">Desbotar cambios</string>
<string name="action_continue_edit">Continuar a edición</string>
<string name="compose_unsaved_changes">Hai cambios non gardados.</string>
<string name="unsaved_changes">Hai cambios non gardados.</string>
<string name="action_share_account_link">Comparte ligazón da conta</string>
<string name="action_share_account_username">Comparte identificador da conta</string>
<string name="send_account_link_to">Compartir URL da conta en…</string>

View File

@ -524,7 +524,7 @@
<string name="post_media_alt">ALT</string>
<string name="action_discard">Változtatások elvetése</string>
<string name="action_continue_edit">Szerkesztés folytatása</string>
<string name="compose_unsaved_changes">Elmentetlen változtatásaid vannak.</string>
<string name="unsaved_changes">Elmentetlen változtatásaid vannak.</string>
<string name="action_share_account_link">Fiókra történő hivatkozás megosztása</string>
<string name="action_share_account_username">Fiók felhasználói nevének megosztása</string>
<string name="send_account_link_to">Fiók URL megosztása vele…</string>

View File

@ -507,7 +507,7 @@
<string name="pref_title_http_proxy_port_message">Gáttin ætti að vera á milli %d og %d</string>
<string name="error_status_source_load">Mistókst að hlaða inn uppruna stöðufærslu af netþjóninum.</string>
<string name="a11y_label_loading_thread">Hleð inn þræði</string>
<string name="compose_unsaved_changes">Þú ert með óvistaðar breytingar.</string>
<string name="unsaved_changes">Þú ert með óvistaðar breytingar.</string>
<string name="pref_summary_http_proxy_disabled">Óvirkt</string>
<string name="pref_summary_http_proxy_missing">&lt;ekki stillt&gt;</string>
<string name="pref_summary_http_proxy_invalid">&lt;ógilt&gt;</string>

View File

@ -542,7 +542,7 @@
<string name="notification_header_report_format">%s ha segnalato %s</string>
<string name="notification_summary_report_format">%s · %d post allegati</string>
<string name="action_add_reaction">aggiungi reazione</string>
<string name="compose_unsaved_changes">Hai delle modifiche non salvate.</string>
<string name="unsaved_changes">Hai delle modifiche non salvate.</string>
<string name="confirmation_hashtag_unfollowed">Non segui più #%s</string>
<string name="error_status_source_load">Caricamento dello status della sorgente dal server fallito.</string>
<string name="post_edited">Modificato %s</string>

View File

@ -512,7 +512,7 @@
<string name="status_created_info">%1$s の投稿</string>
<string name="follow_requests_info">アカウントがロックされていなかったとしても、%1$s のスタッフは以下のアカウントのフォローリクエストを確認した方がいいと判断しました。</string>
<string name="action_set_focus">中心点の設定</string>
<string name="compose_unsaved_changes">保存していない変更があります。</string>
<string name="unsaved_changes">保存していない変更があります。</string>
<string name="error_status_source_load">サーバーからステータスの元情報を取得できませんでした。</string>
<string name="pref_summary_http_proxy_disabled">無効</string>
<string name="pref_summary_http_proxy_missing">&lt;設定なし&gt;</string>

View File

@ -331,7 +331,7 @@
<string name="notification_summary_large">%1$s, %2$s, %3$s un %4$d citi</string>
<string name="title_media">Multivide</string>
<string name="pref_title_alway_show_sensitive_media">Vienmēr rādīt sensitīvu saturu</string>
<string name="compose_unsaved_changes">Tev ir nesaglabātas izmaiņas.</string>
<string name="unsaved_changes">Tev ir nesaglabātas izmaiņas.</string>
<string name="description_post_media">Multivide: %s</string>
<string name="description_poll">Aptauja ar izvēlēm: %1$s, %2$s, %3$s, %4$s; %5$s</string>
<string name="poll_info_format"><!-- 15 balsis • atlikusi 1 stunda -->%1$s • %2$s</string>

View File

@ -554,7 +554,7 @@
<string name="ui_success_accepted_follow_request">Følgeforespørsel akseptert</string>
<string name="action_discard">Forkast endringer</string>
<string name="action_continue_edit">Fortsett endring</string>
<string name="compose_unsaved_changes">Du har ulagrede endringer.</string>
<string name="unsaved_changes">Du har ulagrede endringer.</string>
<string name="select_list_empty">Du har ingen lister, enda</string>
<string name="error_list_load">Feil under lading av lister</string>
<string name="select_list_manage">Forvalte lister</string>

View File

@ -488,7 +488,7 @@
<string name="delete_scheduled_post_warning">Dit ingeplande bericht verwijderen\?</string>
<string name="failed_to_pin">Kan niet vastmaken</string>
<string name="pref_summary_http_proxy_disabled">Uitgeschakeld</string>
<string name="compose_unsaved_changes">Er zijn niet opgeslagen wijzigingen.</string>
<string name="unsaved_changes">Er zijn niet opgeslagen wijzigingen.</string>
<string name="mute_notifications_switch">Meldingen negeren</string>
<string name="title_edits">Bewerkingen</string>
<string name="pref_default_post_language">Standaardtaal van berichten</string>

View File

@ -520,7 +520,7 @@
<string name="post_media_alt">ALT</string>
<string name="action_discard">Ignorar las modificacions</string>
<string name="action_continue_edit">Téner de modificar</string>
<string name="compose_unsaved_changes">Avètz de modificacions pas salvadas.</string>
<string name="unsaved_changes">Avètz de modificacions pas salvadas.</string>
<string name="a11y_label_loading_thread">Cargament del fil</string>
<string name="pref_summary_http_proxy_disabled">Desactivat</string>
<string name="pref_summary_http_proxy_missing">&lt;pas definit&gt;</string>

View File

@ -526,7 +526,7 @@
<string name="status_edit_info">%1$s edytował</string>
<string name="status_created_info">%1$s stworzył</string>
<string name="title_edits">Edycje</string>
<string name="compose_unsaved_changes">Masz niezapisane zmiany.</string>
<string name="unsaved_changes">Masz niezapisane zmiany.</string>
<string name="action_post_failed">Błąd wysyłania</string>
<string name="action_post_failed_show_drafts">Pokaż szkice</string>
<string name="action_post_failed_do_nothing">Odrzuć</string>

View File

@ -510,7 +510,7 @@
<string name="description_browser_login">Pode oferecer suporte a métodos de autenticação adicionais, mas requer um navegador compatível.</string>
<string name="status_edit_info">%1$s editou</string>
<string name="action_continue_edit">Continuar editando</string>
<string name="compose_unsaved_changes">Você tem alterações não salvas.</string>
<string name="unsaved_changes">Você tem alterações não salvas.</string>
<string name="description_post_edited">Editado</string>
<string name="delete_scheduled_post_warning">Excluir este Toot agendado?</string>
<string name="action_unfollow_hashtag_format">Deixar de seguir #%s\?</string>

View File

@ -481,7 +481,7 @@
<string name="dialog_follow_hashtag_title">व्यक्तित्वविवरणलेखा अनुसरतु</string>
<string name="limit_notifications">कालानुक्रमपङ्क्त्याः सूचनाः परिमिताः कुरुताम्</string>
<string name="notification_report_description">परिमितावेदनानि प्रति ज्ञापनसूचनाः</string>
<string name="compose_unsaved_changes">भवतः अरक्षितानि परिवर्तनानि सन्ति।</string>
<string name="unsaved_changes">भवतः अरक्षितानि परिवर्तनानि सन्ति।</string>
<string name="pref_title_notification_filter_reports">नूतनम् आवेदनमस्ति</string>
<string name="pref_title_wellbeing_mode">सुस्थितिः</string>
<string name="delete_scheduled_post_warning">इदं कालबद्धदौत्यं विनश्येत् किम् \?</string>

View File

@ -532,7 +532,7 @@
<string name="status_created_info">%1$s skapade</string>
<string name="action_discard">Förkasta ändringar</string>
<string name="action_continue_edit">Fortsätt redigera</string>
<string name="compose_unsaved_changes">Du har ändringar som inte sparats.</string>
<string name="unsaved_changes">Du har ändringar som inte sparats.</string>
<string name="action_post_failed">Uppladdning misslyckades</string>
<string name="action_post_failed_detail">Ett fel inträffade när inlägget skulle laddas upp och har sparats till utkast.
\n

View File

@ -524,7 +524,7 @@
<string name="status_created_info">%1$s oluşturdu</string>
<string name="dialog_follow_hashtag_title">Etiketi takip et</string>
<string name="dialog_follow_hashtag_hint">#etiket</string>
<string name="compose_unsaved_changes">Kaydedilmemiş değişikliklerin var.</string>
<string name="unsaved_changes">Kaydedilmemiş değişikliklerin var.</string>
<string name="status_edit_info">%1$s düzenledi</string>
<string name="title_edits">Düzenlemeler</string>
<string name="hint_description">ıklama</string>

View File

@ -530,7 +530,7 @@
<string name="error_status_source_load">Не вдалося завантажити джерело стану з сервера.</string>
<string name="post_media_alt">ALT</string>
<string name="action_discard">Відкинути зміни</string>
<string name="compose_unsaved_changes">У вас є незбережені зміни.</string>
<string name="unsaved_changes">У вас є незбережені зміни.</string>
<string name="action_continue_edit">Продовжити редагування</string>
<string name="mute_notifications_switch">Беззвучні сповіщення</string>
<string name="title_edits">Редагування</string>

View File

@ -499,7 +499,7 @@
<string name="post_media_alt"></string>
<string name="action_discard">Hủy bỏ thay đổi</string>
<string name="action_continue_edit">Tiếp tục sửa</string>
<string name="compose_unsaved_changes">Thay đổi chưa được lưu.</string>
<string name="unsaved_changes">Thay đổi chưa được lưu.</string>
<string name="mute_notifications_switch">Ẩn thông báo</string>
<string name="status_edit_info">Sửa %1$s</string>
<string name="status_created_info">Đăng %1$s</string>

View File

@ -513,7 +513,7 @@
<string name="post_media_alt">ALT</string>
<string name="action_discard">放弃更改</string>
<string name="action_continue_edit">继续编辑</string>
<string name="compose_unsaved_changes">你有未保存的更改。</string>
<string name="unsaved_changes">你有未保存的更改。</string>
<string name="status_created_info">%1$s 创建了</string>
<string name="mute_notifications_switch">将通知静音</string>
<string name="title_edits">编辑</string>

View File

@ -412,7 +412,7 @@
<string name="compose_delete_draft">Delete draft?</string>
<string name="compose_save_draft">Save draft?</string>
<string name="compose_save_draft_loses_media">Save draft? (Attachments will be uploaded again when you restore the draft.)</string>
<string name="compose_unsaved_changes">You have unsaved changes.</string>
<string name="unsaved_changes">You have unsaved changes.</string>
<string name="send_post_notification_title">Sending post…</string>
<string name="send_post_notification_error_title">Error sending post</string>
<string name="send_post_notification_channel_name">Sending Posts</string>
@ -703,5 +703,8 @@
<string name="action_add_to_tab_success">Added \'%1$s\' to tabs</string>
<string name="action_remove_tab">Remove tab</string>
<string name="action_manage_tabs">Manage tabs</string>
<string name="error_filter_missing_keyword">At least one keyword or phrase is required</string>
<string name="error_filter_missing_context">At least one filter context is required</string>
<string name="error_filter_missing_title">Title is required</string>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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

View File

@ -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 <http://www.gnu.org/licenses>.
*/
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
}
}

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="0"
android:duration="450" />
<translate
android:fromXDelta="96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="96dp"
android:fromExtendTop="0"
android:fromExtendRight="0"
android:fromExtendBottom="0"
android:toExtendLeft="96dp"
android:toExtendTop="0"
android:toExtendRight="0"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="35"
android:duration="83" />
<translate
android:fromXDelta="0"
android:toXDelta="-96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="0"
android:fromExtendTop="0"
android:fromExtendRight="96dp"
android:fromExtendBottom="0"
android:toExtendLeft="0"
android:toExtendTop="0"
android:toExtendRight="96dp"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?><!--
/*
** Copyright 2022, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="50"
android:duration="83" />
<translate
android:fromXDelta="-96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="0"
android:fromExtendTop="0"
android:fromExtendRight="96dp"
android:fromExtendBottom="0"
android:toExtendLeft="0"
android:toExtendTop="0"
android:toExtendRight="96dp"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?><!--
/*
** Copyright 2022, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/standard_accelerate"
android:startOffset="0"
android:duration="450" />
<translate
android:fromXDelta="0"
android:toXDelta="96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="9dp"
android:fromExtendTop="0"
android:fromExtendRight="0"
android:fromExtendBottom="0"
android:toExtendLeft="96dp"
android:toExtendTop="0"
android:toExtendRight="0"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2022, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="0"
android:duration="450" />
<translate
android:fromXDelta="96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2022, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="35"
android:duration="83" />
<translate
android:fromXDelta="0"
android:toXDelta="-96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?><!--
/*
** Copyright 2022, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="50"
android:duration="83" />
<translate
android:fromXDelta="-96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?><!--
/*
** Copyright 2022, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/standard_accelerate"
android:startOffset="0"
android:duration="450" />
<translate
android:fromXDelta="0"
android:toXDelta="96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -2,5 +2,5 @@
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="100%p"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="300"/>
</set>
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -2,5 +2,5 @@
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="-100%p"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="300"/>
</set>
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -2,5 +2,5 @@
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="100%p" android:toXDelta="0"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="300"/>
</set>
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -2,5 +2,5 @@
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="-100%p" android:toXDelta="0"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="300"/>
</set>
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<translate
android:fromXDelta="-96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="0"
android:fromExtendTop="0"
android:fromExtendRight="96dp"
android:fromExtendBottom="0"
android:toExtendLeft="0"
android:toExtendTop="0"
android:toExtendRight="96dp"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="35"
android:duration="83" />
<translate
android:fromXDelta="0"
android:toXDelta="96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="96dp"
android:fromExtendTop="0"
android:fromExtendRight="0"
android:fromExtendBottom="0"
android:toExtendLeft="96dp"
android:toExtendTop="0"
android:toExtendRight="0"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?><!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="50"
android:duration="83" />
<translate
android:fromXDelta="96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="96dp"
android:fromExtendTop="0"
android:fromExtendRight="0"
android:fromExtendBottom="0"
android:toExtendLeft="96dp"
android:toExtendTop="0"
android:toExtendRight="0"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?><!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/standard_accelerate"
android:startOffset="0"
android:duration="450" />
<translate
android:fromXDelta="0"
android:toXDelta="-96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<extend
android:fromExtendLeft="0"
android:fromExtendTop="0"
android:fromExtendRight="96dp"
android:fromExtendBottom="0"
android:toExtendLeft="0"
android:toExtendTop="0"
android:toExtendRight="96dp"
android:toExtendBottom="0"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="0"
android:duration="450" />
<translate
android:fromXDelta="-96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false"
android:zAdjustment="top">
<alpha
android:fromAlpha="1.0"
android:toAlpha="0.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="35"
android:duration="83" />
<translate
android:fromXDelta="0"
android:toXDelta="96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/linear_interpolator"
android:startOffset="50"
android:duration="83" />
<translate
android:fromXDelta="96dp"
android:toXDelta="0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
/*
** Copyright 2009, The Android Open Source Project
**
** Licensed under the Apache License, Version 2.0 (the "License");
** you may not use this file except in compliance with the License.
** You may obtain a copy of the License at
**
** http://www.apache.org/licenses/LICENSE-2.0
**
** Unless required by applicable law or agreed to in writing, software
** distributed under the License is distributed on an "AS IS" BASIS,
** WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
** See the License for the specific language governing permissions and
** limitations under the License.
*/
-->
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:shareInterpolator="false">
<alpha
android:fromAlpha="1.0"
android:toAlpha="1.0"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/standard_accelerate"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
<translate
android:fromXDelta="0"
android:toXDelta="-96dp"
android:fillEnabled="true"
android:fillBefore="true"
android:fillAfter="true"
android:interpolator="@anim/fast_out_extra_slow_in"
android:startOffset="0"
android:duration="@integer/activity_transition_duration_ms" />
</set>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/linear_interpolator"
android:fromAlpha="0"
android:toAlpha="1"
android:duration="300" />

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:interpolator="@android:anim/linear_interpolator"
android:fromAlpha="1"
android:toAlpha="0"
android:duration="300" />

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2017 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License
-->
<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:pathData="M 0,0 C 0.05, 0, 0.133333, 0.06, 0.166666, 0.4 C 0.208333, 0.82, 0.25, 1, 1, 1"/>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ 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 <http://www.gnu.org/licenses>.
-->
<linearInterpolator />

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="100%p" android:toXDelta="0"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="-100%p" android:toXDelta="0"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="100%p"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="-100%p"
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="@integer/activity_slide_transition_duration_ms"/>
</set>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Copyright (C) 2021 The Android Open Source Project
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<pathInterpolator xmlns:android="http://schemas.android.com/apk/res/android"
android:controlX1="0.3"
android:controlY1="0"
android:controlX2="1"
android:controlY2="1"/>

View File

@ -3,4 +3,10 @@
<integer name="profile_media_column_count">3</integer>
<integer name="trending_column_count">1</integer>
<!-- Duration for default activity transition, in ms -->
<integer name="activity_transition_duration_ms">450</integer>
<!-- Duration for slide transition, in ms -->
<integer name="activity_slide_transition_duration_ms">200</integer>
</resources>

View File

@ -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<FilterContext>,
@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<FilterContext> = 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

View File

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

Some files were not shown because too many files have changed in this diff Show More