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

View File

@ -22,7 +22,8 @@
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="false" android:usesCleartextTraffic="false"
android:localeConfig="@xml/locales_config" android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config"
android:enableOnBackInvokedCallback="true">
<activity <activity
android:name=".SplashActivity" android:name=".SplashActivity"

View File

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

View File

@ -53,6 +53,7 @@ import androidx.core.view.GravityCompat
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.drawerlayout.widget.DrawerLayout.DrawerListener
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.viewpager2.widget.MarginPageTransformer import androidx.viewpager2.widget.MarginPageTransformer
import app.pachli.appstore.AnnouncementReadEvent 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.BottomSheetActivity
import app.pachli.core.activity.PostLookupFallbackBehavior import app.pachli.core.activity.PostLookupFallbackBehavior
import app.pachli.core.activity.emojify 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.di.ApplicationScope
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
@ -215,6 +219,15 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
/** Adapter for the different timeline tabs */ /** Adapter for the different timeline tabs */
private lateinit var tabAdapter: MainPagerAdapter 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") @SuppressLint("RestrictedApi")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -284,7 +297,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
// otherwise show notification tab // otherwise show notification tab
if (MainActivityIntent.getNotificationType(intent) == Notification.Type.FOLLOW_REQUEST) { if (MainActivityIntent.getNotificationType(intent) == Notification.Type.FOLLOW_REQUEST) {
val intent = AccountListActivityIntent(this, AccountListActivityIntent.Kind.FOLLOW_REQUESTS) val intent = AccountListActivityIntent(this, AccountListActivityIntent.Kind.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} else { } else {
showNotificationTab = true showNotificationTab = true
} }
@ -379,24 +392,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
selectedEmojiPack = sharedPreferencesRepository.getString(EMOJI_PREFERENCE, "") selectedEmojiPack = sharedPreferencesRepository.getString(EMOJI_PREFERENCE, "")
onBackPressedDispatcher.addCallback( onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
when {
binding.mainDrawerLayout.isOpen -> {
binding.mainDrawerLayout.close()
}
binding.viewPager.currentItem != 0 -> {
binding.viewPager.currentItem = 0
}
else -> {
finish()
}
}
}
},
)
if (Build.VERSION.SDK_INT >= TIRAMISU && ActivityCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) { if (Build.VERSION.SDK_INT >= TIRAMISU && ActivityCompat.checkSelfPermission(this, POST_NOTIFICATIONS) != PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, arrayOf(POST_NOTIFICATIONS), 1) ActivityCompat.requestPermissions(this, arrayOf(POST_NOTIFICATIONS), 1)
@ -500,7 +496,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
return true return true
} }
KeyEvent.KEYCODE_SEARCH -> { KeyEvent.KEYCODE_SEARCH -> {
startActivityWithSlideInAnimation(SearchActivityIntent(this)) startActivityWithDefaultTransition(SearchActivityIntent(this))
return true return true
} }
} }
@ -603,6 +599,20 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
refreshMainDrawerItems(addSearchButton) refreshMainDrawerItems(addSearchButton)
setSavedInstance(savedInstanceState) 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) { private fun refreshMainDrawerItems(addSearchButton: Boolean) {
@ -616,7 +626,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameText = list.title nameText = list.title
iconicsIcon = GoogleMaterial.Icon.gmd_list iconicsIcon = GoogleMaterial.Icon.gmd_list
onClick = { onClick = {
startActivityWithSlideInAnimation( startActivityWithDefaultTransition(
TimelineActivityIntent.list(this@MainActivity, list.id, list.title), TimelineActivityIntent.list(this@MainActivity, list.id, list.title),
) )
} }
@ -635,7 +645,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.title_notifications nameRes = R.string.title_notifications
iconicsIcon = GoogleMaterial.Icon.gmd_notifications iconicsIcon = GoogleMaterial.Icon.gmd_notifications
onClick = { onClick = {
startActivityWithSlideInAnimation( startActivityWithDefaultTransition(
TimelineActivityIntent.notifications(context), TimelineActivityIntent.notifications(context),
) )
} }
@ -644,7 +654,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.title_public_local nameRes = R.string.title_public_local
iconRes = R.drawable.ic_local_24dp iconRes = R.drawable.ic_local_24dp
onClick = { onClick = {
startActivityWithSlideInAnimation( startActivityWithDefaultTransition(
TimelineActivityIntent.publicLocal(context), TimelineActivityIntent.publicLocal(context),
) )
} }
@ -653,7 +663,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.title_public_federated nameRes = R.string.title_public_federated
iconRes = R.drawable.ic_public_24dp iconRes = R.drawable.ic_public_24dp
onClick = { onClick = {
startActivityWithSlideInAnimation( startActivityWithDefaultTransition(
TimelineActivityIntent.publicFederated(context), TimelineActivityIntent.publicFederated(context),
) )
} }
@ -662,7 +672,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.title_direct_messages nameRes = R.string.title_direct_messages
iconRes = R.drawable.ic_reblog_direct_24dp iconRes = R.drawable.ic_reblog_direct_24dp
onClick = { onClick = {
startActivityWithSlideInAnimation( startActivityWithDefaultTransition(
TimelineActivityIntent.conversations(context), TimelineActivityIntent.conversations(context),
) )
} }
@ -672,7 +682,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconicsIcon = GoogleMaterial.Icon.gmd_bookmark iconicsIcon = GoogleMaterial.Icon.gmd_bookmark
onClick = { onClick = {
val intent = TimelineActivityIntent.bookmarks(context) val intent = TimelineActivityIntent.bookmarks(context)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
primaryDrawerItem { primaryDrawerItem {
@ -681,21 +691,21 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconicsIcon = GoogleMaterial.Icon.gmd_star iconicsIcon = GoogleMaterial.Icon.gmd_star
onClick = { onClick = {
val intent = TimelineActivityIntent.favourites(context) val intent = TimelineActivityIntent.favourites(context)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.title_public_trending nameRes = R.string.title_public_trending
iconicsIcon = GoogleMaterial.Icon.gmd_trending_up iconicsIcon = GoogleMaterial.Icon.gmd_trending_up
onClick = { onClick = {
startActivityWithSlideInAnimation(TrendingActivityIntent(context)) startActivityWithDefaultTransition(TrendingActivityIntent(context))
} }
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.title_followed_hashtags nameRes = R.string.title_followed_hashtags
iconRes = R.drawable.ic_hashtag iconRes = R.drawable.ic_hashtag
onClick = { onClick = {
startActivityWithSlideInAnimation(FollowedTagsActivityIntent(context)) startActivityWithDefaultTransition(FollowedTagsActivityIntent(context))
} }
}, },
primaryDrawerItem { primaryDrawerItem {
@ -703,7 +713,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconicsIcon = GoogleMaterial.Icon.gmd_person_add iconicsIcon = GoogleMaterial.Icon.gmd_person_add
onClick = { onClick = {
val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.FOLLOW_REQUESTS) val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.FOLLOW_REQUESTS)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
SectionDrawerItem().apply { SectionDrawerItem().apply {
@ -714,7 +724,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.manage_lists nameRes = R.string.manage_lists
iconicsIcon = GoogleMaterial.Icon.gmd_settings iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = { onClick = {
startActivityWithSlideInAnimation(ListActivityIntent(context)) startActivityWithDefaultTransition(ListActivityIntent(context))
} }
}, },
DividerDrawerItem(), DividerDrawerItem(),
@ -723,14 +733,14 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconRes = R.drawable.ic_notebook iconRes = R.drawable.ic_notebook
onClick = { onClick = {
val intent = DraftsActivityIntent(context) val intent = DraftsActivityIntent(context)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
primaryDrawerItem { primaryDrawerItem {
nameRes = R.string.action_access_scheduled_posts nameRes = R.string.action_access_scheduled_posts
iconRes = R.drawable.ic_access_time iconRes = R.drawable.ic_access_time
onClick = { onClick = {
startActivityWithSlideInAnimation(ScheduledStatusActivityIntent(context)) startActivityWithDefaultTransition(ScheduledStatusActivityIntent(context))
} }
}, },
primaryDrawerItem { primaryDrawerItem {
@ -738,7 +748,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.title_announcements nameRes = R.string.title_announcements
iconRes = R.drawable.ic_bullhorn_24dp iconRes = R.drawable.ic_bullhorn_24dp
onClick = { onClick = {
startActivityWithSlideInAnimation(AnnouncementsActivityIntent(context)) startActivityWithDefaultTransition(AnnouncementsActivityIntent(context))
} }
badgeStyle = BadgeStyle().apply { badgeStyle = BadgeStyle().apply {
textColor = ColorHolder.fromColor(MaterialColors.getColor(binding.mainDrawer, com.google.android.material.R.attr.colorOnPrimary)) 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 iconRes = R.drawable.ic_account_settings
onClick = { onClick = {
val intent = PreferencesActivityIntent(context, PreferenceScreen.ACCOUNT) val intent = PreferencesActivityIntent(context, PreferenceScreen.ACCOUNT)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
secondaryDrawerItem { secondaryDrawerItem {
@ -759,7 +769,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconicsIcon = GoogleMaterial.Icon.gmd_settings iconicsIcon = GoogleMaterial.Icon.gmd_settings
onClick = { onClick = {
val intent = PreferencesActivityIntent(context, PreferenceScreen.GENERAL) val intent = PreferencesActivityIntent(context, PreferenceScreen.GENERAL)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
primaryDrawerItem { primaryDrawerItem {
@ -767,7 +777,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconicsIcon = GoogleMaterial.Icon.gmd_person iconicsIcon = GoogleMaterial.Icon.gmd_person
onClick = { onClick = {
val intent = EditProfileActivityIntent(context) val intent = EditProfileActivityIntent(context)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
secondaryDrawerItem { secondaryDrawerItem {
@ -775,7 +785,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
iconicsIcon = GoogleMaterial.Icon.gmd_info iconicsIcon = GoogleMaterial.Icon.gmd_info
onClick = { onClick = {
val intent = AboutActivityIntent(context) val intent = AboutActivityIntent(context)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
}, },
secondaryDrawerItem { secondaryDrawerItem {
@ -792,7 +802,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
nameRes = R.string.action_search nameRes = R.string.action_search
iconicsIcon = GoogleMaterial.Icon.gmd_search iconicsIcon = GoogleMaterial.Icon.gmd_search
onClick = { onClick = {
startActivityWithSlideInAnimation(SearchActivityIntent(context)) startActivityWithDefaultTransition(SearchActivityIntent(context))
} }
}, },
) )
@ -943,6 +953,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
onTabSelectedListener = object : OnTabSelectedListener { onTabSelectedListener = object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) { override fun onTabSelected(tab: TabLayout.Tab) {
onBackPressedCallback.isEnabled = tab.position > 0 || binding.mainDrawerLayout.isOpen
supportActionBar?.title = tabs[tab.position].title(this@MainActivity) supportActionBar?.title = tabs[tab.position].title(this@MainActivity)
refreshComposeButtonState(tabs[tab.position]) refreshComposeButtonState(tabs[tab.position])
@ -952,9 +964,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
override fun onTabReselected(tab: TabLayout.Tab) { override fun onTabReselected(tab: TabLayout.Tab) {
val fragment = tabAdapter.getFragment(tab.position) val fragment = tabAdapter.getFragment(tab.position)
if (fragment is ReselectableFragment) { (fragment as? ReselectableFragment)?.onReselect()
(fragment as ReselectableFragment).onReselect()
}
refreshComposeButtonState(tabs[tab.position]) refreshComposeButtonState(tabs[tab.position])
} }
@ -987,12 +997,12 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
// open profile when active image was clicked // open profile when active image was clicked
if (current && activeAccount != null) { if (current && activeAccount != null) {
val intent = AccountActivityIntent(this, activeAccount.accountId) val intent = AccountActivityIntent(this, activeAccount.accountId)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
return return
} }
// open LoginActivity to add new account // open LoginActivity to add new account
if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) { if (profile.identifier == DRAWER_ITEM_ADD_ACCOUNT) {
startActivityWithSlideInAnimation( startActivityWithDefaultTransition(
LoginActivityIntent(this, LoginMode.ADDITIONAL_LOGIN), LoginActivityIntent(this, LoginMode.ADDITIONAL_LOGIN),
) )
return return
@ -1012,12 +1022,8 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
intent.action = forward.action intent.action = forward.action
intent.putExtras(forward) intent.putExtras(forward)
} }
startActivity(intent) startActivityWithTransition(intent, TransitionKind.EXPLODE)
finishWithoutSlideOutAnimation() finish()
overridePendingTransition(
DR.anim.explode,
DR.anim.explode,
)
} }
private fun logout() { private fun logout() {
@ -1040,7 +1046,7 @@ class MainActivity : BottomSheetActivity(), ActionButtonActivity, MenuProvider {
LoginActivityIntent(this@MainActivity, LoginMode.DEFAULT) LoginActivityIntent(this@MainActivity, LoginMode.DEFAULT)
} }
startActivity(intent) startActivity(intent)
finishWithoutSlideOutAnimation() finish()
} }
} }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)

View File

@ -46,6 +46,7 @@ import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import app.pachli.BuildConfig.APPLICATION_ID import app.pachli.BuildConfig.APPLICATION_ID
import app.pachli.core.activity.BaseActivity 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
@ -250,7 +251,7 @@ class ViewMediaActivity : BaseActivity(), MediaActionsListener {
private fun onOpenStatus() { private fun onOpenStatus() {
val attach = attachmentViewData!![binding.viewPager.currentItem] val attach = attachmentViewData!![binding.viewPager.currentItem]
startActivityWithSlideInAnimation(ViewThreadActivityIntent(this, attach.statusId, attach.statusUrl)) startActivityWithDefaultTransition(ViewThreadActivityIntent(this, attach.statusId, attach.statusUrl))
} }
private fun copyLink() { private fun copyLink() {

View File

@ -25,7 +25,12 @@ import app.pachli.core.ui.BindingHolder
import app.pachli.databinding.ItemEditFieldBinding import app.pachli.databinding.ItemEditFieldBinding
import app.pachli.util.fixTextSelection 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 val fieldData = mutableListOf<MutableStringPair>()
private var maxNameLength: Int? = null private var maxNameLength: Int? = null
@ -84,10 +89,12 @@ class AccountFieldEditAdapter : RecyclerView.Adapter<BindingHolder<ItemEditField
holder.binding.accountFieldNameText.doAfterTextChanged { newText -> holder.binding.accountFieldNameText.doAfterTextChanged { newText ->
fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString() fieldData.getOrNull(holder.bindingAdapterPosition)?.first = newText.toString()
onChange()
} }
holder.binding.accountFieldValueText.doAfterTextChanged { newText -> holder.binding.accountFieldValueText.doAfterTextChanged { newText ->
fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString() fieldData.getOrNull(holder.bindingAdapterPosition)?.second = newText.toString()
onChange()
} }
// Ensure the textview contents are selectable // Ensure the textview contents are selectable

View File

@ -36,6 +36,7 @@ import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams import android.view.ViewGroup.MarginLayoutParams
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.annotation.ColorInt import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
@ -58,6 +59,9 @@ import app.pachli.R
import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.AccountSelectionListener
import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.activity.emojify 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.activity.loadAvatar
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
@ -180,6 +184,12 @@ class AccountActivity :
private var noteWatcher: TextWatcher? = null private var noteWatcher: TextWatcher? = null
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
binding.accountFragmentViewPager.currentItem = 0
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
loadResources() loadResources()
@ -207,6 +217,8 @@ class AccountActivity :
} else { } else {
binding.saveNoteInfo.visibility = View.INVISIBLE binding.saveNoteInfo.visibility = View.INVISIBLE
} }
onBackPressedDispatcher.addCallback(onBackPressedCallback)
} }
/** /**
@ -243,7 +255,7 @@ class AccountActivity :
else -> throw AssertionError() else -> throw AssertionError()
} }
val accountListIntent = AccountListActivityIntent(this, kind, viewModel.accountId) val accountListIntent = AccountListActivityIntent(this, kind, viewModel.accountId)
startActivityWithSlideInAnimation(accountListIntent) startActivityWithDefaultTransition(accountListIntent)
} }
binding.accountFollowers.setOnClickListener(accountListClickListener) binding.accountFollowers.setOnClickListener(accountListClickListener)
binding.accountFollowing.setOnClickListener(accountListClickListener) binding.accountFollowing.setOnClickListener(accountListClickListener)
@ -299,7 +311,10 @@ class AccountActivity :
override fun onTabUnselected(tab: TabLayout.Tab?) {} 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) { private fun viewImage(view: View, uri: String) {
view.transitionName = uri ViewCompat.setTransitionName(view, uri)
startActivity( startActivity(
ViewMediaActivityIntent(view.context, uri), ViewMediaActivityIntent(view.context, uri),
ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle(), ActivityOptionsCompat.makeSceneTransitionAnimation(this, view, uri).toBundle(),
@ -630,7 +645,7 @@ class AccountActivity :
binding.accountFollowButton.setOnClickListener { binding.accountFollowButton.setOnClickListener {
if (viewModel.isSelf) { if (viewModel.isSelf) {
val intent = EditProfileActivityIntent(this@AccountActivity) val intent = EditProfileActivityIntent(this@AccountActivity)
startActivity(intent) startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
return@setOnClickListener return@setOnClickListener
} }
@ -959,12 +974,12 @@ class AccountActivity :
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
val intent = TimelineActivityIntent.hashtag(this, tag) val intent = TimelineActivityIntent.hashtag(this, tag)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
val intent = AccountActivityIntent(this, id) val intent = AccountActivityIntent(this, id)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
override fun onViewUrl(url: String) { 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.FollowRequestsHeaderAdapter
import app.pachli.components.accountlist.adapter.MutesAdapter import app.pachli.components.accountlist.adapter.MutesAdapter
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.activity.BaseActivity
import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.activity.PostLookupFallbackBehavior 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
@ -158,19 +158,15 @@ class AccountListFragment :
} }
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
(activity as BaseActivity?) activity?.startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(requireContext(), tag))
?.startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(requireContext(), tag))
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
(activity as BaseActivity?)?.let { activity?.startActivityWithDefaultTransition(AccountActivityIntent(requireContext(), id))
val intent = AccountActivityIntent(it, id)
it.startActivityWithSlideInAnimation(intent)
}
} }
override fun onViewUrl(url: String) { 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) { 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.EmojiAdapter
import app.pachli.adapter.OnEmojiSelectedListener import app.pachli.adapter.OnEmojiSelectedListener
import app.pachli.core.activity.BottomSheetActivity 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
@ -187,7 +188,7 @@ class AnnouncementsActivity :
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
val intent = TimelineActivityIntent.hashtag(this, tag) val intent = TimelineActivityIntent.hashtag(this, tag)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {

View File

@ -17,6 +17,7 @@
package app.pachli.components.compose package app.pachli.components.compose
import android.Manifest import android.Manifest
import android.annotation.SuppressLint
import android.app.ProgressDialog import android.app.ProgressDialog
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
@ -30,7 +31,6 @@ import android.os.Bundle
import android.provider.MediaStore import android.provider.MediaStore
import android.text.InputFilter import android.text.InputFilter
import android.text.Spanned import android.text.Spanned
import android.text.style.URLSpan
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem import android.view.MenuItem
import android.view.View 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.AppTheme
import app.pachli.core.preferences.PrefKeys import app.pachli.core.preferences.PrefKeys
import app.pachli.core.preferences.SharedPreferencesRepository import app.pachli.core.preferences.SharedPreferencesRepository
import app.pachli.core.ui.MentionSpan
import app.pachli.databinding.ActivityComposeBinding import app.pachli.databinding.ActivityComposeBinding
import app.pachli.util.PickMediaFiles import app.pachli.util.PickMediaFiles
import app.pachli.util.getInitialLanguages import app.pachli.util.getInitialLanguages
@ -155,9 +154,9 @@ class ComposeActivity :
@VisibleForTesting @VisibleForTesting
var maximumTootCharacters = InstanceInfoRepository.DEFAULT_CHARACTER_LIMIT 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) private val binding by viewBinding(ActivityComposeBinding::inflate)
@ -206,6 +205,28 @@ class ComposeActivity :
viewModel.cropImageItemOld = null 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?) { public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -268,7 +289,7 @@ class ComposeActivity :
} }
setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount)) setupLanguageSpinner(getInitialLanguages(composeOptions?.language, activeAccount))
setupComposeField(sharedPreferencesRepository, viewModel.startingText, composeOptions) setupComposeField(sharedPreferencesRepository, viewModel.initialContent, composeOptions)
setupContentWarningField(composeOptions?.contentWarning) setupContentWarningField(composeOptions?.contentWarning)
setupPollView() setupPollView()
applyShareIntent(intent, savedInstanceState) applyShareIntent(intent, savedInstanceState)
@ -282,7 +303,7 @@ class ComposeActivity :
} }
it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply { it.getBoolean(CONTENT_WARNING_VISIBLE_KEY).apply {
viewModel.contentWarningChanged(this) viewModel.showContentWarningChanged(this)
} }
it.getString(SCHEDULED_TIME_KEY)?.let { time -> it.getString(SCHEDULED_TIME_KEY)?.let { time ->
@ -369,7 +390,9 @@ class ComposeActivity :
if (startingContentWarning != null) { if (startingContentWarning != null) {
binding.composeContentWarningField.setText(startingContentWarning) binding.composeContentWarningField.setText(startingContentWarning)
} }
binding.composeContentWarningField.doOnTextChanged { _, _, _, _ -> updateVisibleCharactersLeft() } binding.composeContentWarningField.doOnTextChanged { newContentWarning, _, _, _ ->
viewModel.onContentWarningChanged(newContentWarning?.toString() ?: "")
}
} }
private fun setupComposeField( private fun setupComposeField(
@ -404,7 +427,7 @@ class ComposeActivity :
highlightSpans(binding.composeEditField.text, mentionColour) highlightSpans(binding.composeEditField.text, mentionColour)
binding.composeEditField.doAfterTextChanged { editable -> binding.composeEditField.doAfterTextChanged { editable ->
highlightSpans(editable!!, mentionColour) highlightSpans(editable!!, mentionColour)
updateVisibleCharactersLeft() viewModel.onContentChanged(editable)
} }
// work around Android platform bug -> https://issuetracker.google.com/issues/67102093 // work around Android platform bug -> https://issuetracker.google.com/issues/67102093
@ -419,12 +442,19 @@ class ComposeActivity :
lifecycleScope.launch { lifecycleScope.launch {
viewModel.instanceInfo.collect { instanceData -> viewModel.instanceInfo.collect { instanceData ->
maximumTootCharacters = instanceData.maxChars maximumTootCharacters = instanceData.maxChars
charactersReservedPerUrl = instanceData.charactersReservedPerUrl
maxUploadMediaNumber = instanceData.maxMediaAttachments 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 { lifecycleScope.launch {
viewModel.emoji.collect(::setEmojiList) 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() { private fun setupButtons() {
binding.composeOptionsBottomSheet.listener = this binding.composeOptionsBottomSheet.listener = this
@ -501,6 +548,17 @@ class ComposeActivity :
scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView) scheduleBehavior = BottomSheetBehavior.from(binding.composeScheduleView)
emojiBehavior = BottomSheetBehavior.from(binding.emojiView) 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) enableButton(binding.composeEmojiButton, clickable = false, colorActive = false)
// Setup the interface buttons. // Setup the interface buttons.
@ -534,26 +592,7 @@ class ComposeActivity :
binding.actionPhotoPick.setOnClickListener { onMediaPick() } binding.actionPhotoPick.setOnClickListener { onMediaPick() }
binding.addPollTextActionTextView.setOnClickListener { openPollDialog() } binding.addPollTextActionTextView.setOnClickListener { openPollDialog() }
onBackPressedDispatcher.addCallback( onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
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()
}
},
)
} }
private fun setupLanguageSpinner(initialLanguages: List<String>) { private fun setupLanguageSpinner(initialLanguages: List<String>) {
@ -851,7 +890,7 @@ class ComposeActivity :
maxOptionLength = instanceParams.pollMaxLength, maxOptionLength = instanceParams.pollMaxLength,
minDuration = instanceParams.pollMinDuration, minDuration = instanceParams.pollMinDuration,
maxDuration = instanceParams.pollMaxDuration, maxDuration = instanceParams.pollMaxDuration,
onUpdatePoll = viewModel::updatePoll, onUpdatePoll = viewModel::onPollChanged,
) )
} }
@ -881,30 +920,21 @@ class ComposeActivity :
} }
private fun removePoll() { private fun removePoll() {
viewModel.poll.value = null viewModel.onPollChanged(null)
binding.pollPreview.hide() binding.pollPreview.hide()
} }
override fun onVisibilityChanged(visibility: Status.Visibility) { override fun onVisibilityChanged(visibility: Status.Visibility) {
viewModel.onStatusVisibilityChanged(visibility)
composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED composeOptionsBehavior.state = BottomSheetBehavior.STATE_COLLAPSED
viewModel.statusVisibility.value = visibility
}
@VisibleForTesting
fun calculateTextLength(): Int {
return statusLength(
binding.composeEditField.text,
binding.composeContentWarningField.text,
charactersReservedPerUrl,
)
} }
@VisibleForTesting @VisibleForTesting
val selectedLanguage: String? val selectedLanguage: String?
get() = viewModel.postLanguage get() = viewModel.postLanguage
private fun updateVisibleCharactersLeft() { private fun updateVisibleCharactersLeft(textLength: Int) {
val remainingLength = maximumTootCharacters - calculateTextLength() val remainingLength = maximumTootCharacters - textLength
binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength) binding.composeCharactersLeftView.text = String.format(Locale.getDefault(), "%d", remainingLength)
val textColor = if (remainingLength < 0) { val textColor = if (remainingLength < 0) {
@ -917,8 +947,7 @@ class ComposeActivity :
private fun onContentWarningChanged() { private fun onContentWarningChanged() {
val showWarning = binding.composeContentWarningBar.isGone val showWarning = binding.composeContentWarningBar.isGone
viewModel.contentWarningChanged(showWarning) viewModel.showContentWarningChanged(showWarning)
updateVisibleCharactersLeft()
} }
private fun verifyScheduledTime(): Boolean { private fun verifyScheduledTime(): Boolean {
@ -957,11 +986,11 @@ class ComposeActivity :
if (viewModel.showContentWarning.value) { if (viewModel.showContentWarning.value) {
spoilerText = binding.composeContentWarningField.text.toString() spoilerText = binding.composeContentWarningField.text.toString()
} }
val characterCount = calculateTextLength() val statusLength = viewModel.statusLength.value
if ((characterCount <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) { if ((statusLength <= 0 || contentText.isBlank()) && viewModel.media.value.isEmpty()) {
binding.composeEditField.error = getString(R.string.error_empty) binding.composeEditField.error = getString(R.string.error_empty)
enableButtons(true, viewModel.editing) enableButtons(true, viewModel.editing)
} else if (characterCount <= maximumTootCharacters) { } else if (statusLength <= maximumTootCharacters) {
lifecycleScope.launch { lifecycleScope.launch {
viewModel.sendStatus(contentText, spoilerText, activeAccount.id) viewModel.sendStatus(contentText, spoilerText, activeAccount.id)
deleteDraftAndFinish() deleteDraftAndFinish()
@ -1008,8 +1037,9 @@ class ComposeActivity :
this, this,
BuildConfig.APPLICATION_ID + ".fileprovider", BuildConfig.APPLICATION_ID + ".fileprovider",
photoFile, photoFile,
) )?.also {
takePicture.launch(photoUploadUri) takePicture.launch(it)
}
} }
private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) { private fun enableButton(button: ImageButton, clickable: Boolean, colorActive: Boolean) {
@ -1105,6 +1135,7 @@ class ComposeActivity :
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
@SuppressLint("GestureBackNavigation")
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
if (event.action == KeyEvent.ACTION_DOWN) { if (event.action == KeyEvent.ACTION_DOWN) {
if (event.isCtrlPressed) { if (event.isCtrlPressed) {
@ -1126,10 +1157,10 @@ class ComposeActivity :
private fun handleCloseButton() { private fun handleCloseButton() {
val contentText = binding.composeEditField.text.toString() val contentText = binding.composeEditField.text.toString()
val contentWarning = binding.composeContentWarningField.text.toString() val contentWarning = binding.composeContentWarningField.text.toString()
when (viewModel.handleCloseButton(contentText, contentWarning)) { when (viewModel.closeConfirmation.value) {
ConfirmationKind.NONE -> { ConfirmationKind.NONE -> {
viewModel.stopUploads() viewModel.stopUploads()
finishWithoutSlideOutAnimation() finish()
} }
ConfirmationKind.SAVE_OR_DISCARD -> ConfirmationKind.SAVE_OR_DISCARD ->
getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show() getSaveAsDraftOrDiscardDialog(contentText, contentWarning).show()
@ -1183,7 +1214,7 @@ class ComposeActivity :
} }
.setNegativeButton(R.string.action_discard) { _, _ -> .setNegativeButton(R.string.action_discard) { _, _ ->
viewModel.stopUploads() viewModel.stopUploads()
finishWithoutSlideOutAnimation() finish()
} }
} }
@ -1193,13 +1224,13 @@ class ComposeActivity :
*/ */
private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder { private fun getContinueEditingOrDiscardDialog(): AlertDialog.Builder {
return AlertDialog.Builder(this) return AlertDialog.Builder(this)
.setMessage(R.string.compose_unsaved_changes) .setMessage(R.string.unsaved_changes)
.setPositiveButton(R.string.action_continue_edit) { _, _ -> .setPositiveButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing // Do nothing, dialog will dismiss, user can continue editing
} }
.setNegativeButton(R.string.action_discard) { _, _ -> .setNegativeButton(R.string.action_discard) { _, _ ->
viewModel.stopUploads() viewModel.stopUploads()
finishWithoutSlideOutAnimation() finish()
} }
} }
@ -1213,7 +1244,7 @@ class ComposeActivity :
.setPositiveButton(R.string.action_delete) { _, _ -> .setPositiveButton(R.string.action_delete) { _, _ ->
viewModel.deleteDraft() viewModel.deleteDraft()
viewModel.stopUploads() viewModel.stopUploads()
finishWithoutSlideOutAnimation() finish()
} }
.setNegativeButton(R.string.action_continue_edit) { _, _ -> .setNegativeButton(R.string.action_continue_edit) { _, _ ->
// Do nothing, dialog will dismiss, user can continue editing // Do nothing, dialog will dismiss, user can continue editing
@ -1222,7 +1253,7 @@ class ComposeActivity :
private fun deleteDraftAndFinish() { private fun deleteDraftAndFinish() {
viewModel.deleteDraft() viewModel.deleteDraft()
finishWithoutSlideOutAnimation() finish()
} }
private fun saveDraftAndFinish(contentText: String, contentWarning: String) { private fun saveDraftAndFinish(contentText: String, contentWarning: String) {
@ -1240,7 +1271,7 @@ class ComposeActivity :
} }
viewModel.saveDraft(contentText, contentWarning) viewModel.saveDraft(contentText, contentWarning)
dialog?.cancel() 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") 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 * [InputFilter] that uses the "Mastodon" length of a string, where emojis always
* count as a single character. * count as a single character.

View File

@ -17,6 +17,10 @@
package app.pachli.components.compose package app.pachli.components.compose
import android.net.Uri 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.core.net.toUri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.drafts.DraftHelper
import app.pachli.components.search.SearchType import app.pachli.components.search.SearchType
import app.pachli.core.accounts.AccountManager import app.pachli.core.accounts.AccountManager
import app.pachli.core.common.string.mastodonLength
import app.pachli.core.common.string.randomAlphanumericString import app.pachli.core.common.string.randomAlphanumericString
import app.pachli.core.data.model.InstanceInfo import app.pachli.core.data.model.InstanceInfo
import app.pachli.core.data.repository.InstanceInfoRepository 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.NewPoll
import app.pachli.core.network.model.Status import app.pachli.core.network.model.Status
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.ui.MentionSpan
import app.pachli.service.MediaToSend import app.pachli.service.MediaToSend
import app.pachli.service.ServiceClient import app.pachli.service.ServiceClient
import app.pachli.service.StatusToSend import app.pachli.service.StatusToSend
@ -49,6 +55,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -66,20 +74,40 @@ class ComposeViewModel @Inject constructor(
instanceInfoRepo: InstanceInfoRepository, instanceInfoRepo: InstanceInfoRepository,
) : ViewModel() { ) : 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 replyingStatusAuthor: String? = null
private var replyingStatusContent: 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 internal var postLanguage: String? = null
/** If editing a draft then the ID of the draft, otherwise 0 */
private var draftId: Int = 0 private var draftId: Int = 0
private var scheduledTootId: String? = null private var scheduledTootId: String? = null
private var startingContentWarning: String = ""
private var inReplyToId: String? = null private var inReplyToId: String? = null
private var originalStatusId: String? = null private var originalStatusId: String? = null
private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN private var startingVisibility: Status.Visibility = Status.Visibility.UNKNOWN
private var contentWarningStateChanged: Boolean = false private var contentWarningStateChanged: Boolean = false
private var modifiedInitialState: Boolean = false private var modifiedInitialState: Boolean = false
private var hasScheduledTimeChanged: Boolean = false private var scheduledTimeChanged: Boolean = false
val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow() val instanceInfo: SharedFlow<InstanceInfo> = instanceInfoRepo::getInstanceInfo.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
@ -87,16 +115,27 @@ class ComposeViewModel @Inject constructor(
val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow() val emoji: SharedFlow<List<Emoji>> = instanceInfoRepo::getEmojis.asFlow()
.shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1) .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 1)
val markMediaAsSensitive: MutableStateFlow<Boolean> = private val _markMediaAsSensitive: MutableStateFlow<Boolean> =
MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false) MutableStateFlow(accountManager.activeAccount?.defaultMediaSensitivity ?: false)
val markMediaAsSensitive = _markMediaAsSensitive.asStateFlow()
val statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN) private val _statusVisibility: MutableStateFlow<Status.Visibility> = MutableStateFlow(Status.Visibility.UNKNOWN)
val showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false) val statusVisibility = _statusVisibility.asStateFlow()
val poll: MutableStateFlow<NewPoll?> = MutableStateFlow(null) private val _showContentWarning: MutableStateFlow<Boolean> = MutableStateFlow(false)
val scheduledAt: MutableStateFlow<String?> = MutableStateFlow(null) 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()) private val _media: MutableStateFlow<List<QueuedMedia>> = MutableStateFlow(emptyList())
val uploadError = MutableSharedFlow<Throwable>(replay = 0, extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) 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 private lateinit var composeKind: ComposeKind
@ -133,7 +172,7 @@ class ComposeViewModel @Inject constructor(
): QueuedMedia { ): QueuedMedia {
var stashMediaItem: QueuedMedia? = null var stashMediaItem: QueuedMedia? = null
media.update { mediaList -> _media.update { mediaList ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(), localId = mediaUploader.getNewLocalMediaId(),
uri = uri, uri = uri,
@ -176,12 +215,12 @@ class ComposeViewModel @Inject constructor(
}, },
) )
is UploadEvent.ErrorEvent -> { is UploadEvent.ErrorEvent -> {
media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } } _media.update { mediaList -> mediaList.filter { it.localId != mediaItem.localId } }
uploadError.emit(event.error) _uploadError.emit(event.error)
return@collect return@collect
} }
} }
media.update { mediaList -> _media.update { mediaList ->
mediaList.map { mediaItem -> mediaList.map { mediaItem ->
if (mediaItem.localId == newMediaItem.localId) { if (mediaItem.localId == newMediaItem.localId) {
newMediaItem newMediaItem
@ -192,11 +231,13 @@ class ComposeViewModel @Inject constructor(
} }
} }
} }
updateCloseConfirmation()
return mediaItem return mediaItem
} }
private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) { private fun addUploadedMedia(id: String, type: QueuedMedia.Type, uri: Uri, description: String?, focus: Attachment.Focus?) {
media.update { mediaList -> _media.update { mediaList ->
val mediaItem = QueuedMedia( val mediaItem = QueuedMedia(
localId = mediaUploader.getNewLocalMediaId(), localId = mediaUploader.getNewLocalMediaId(),
uri = uri, uri = uri,
@ -214,22 +255,53 @@ class ComposeViewModel @Inject constructor(
fun removeMediaFromQueue(item: QueuedMedia) { fun removeMediaFromQueue(item: QueuedMedia) {
mediaUploader.cancelUploadScope(item.localId) 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() { fun toggleMarkSensitive() {
this.markMediaAsSensitive.value = this.markMediaAsSensitive.value != true this._markMediaAsSensitive.value = this._markMediaAsSensitive.value != true
} }
fun handleCloseButton(contentText: String?, contentWarning: String?): ConfirmationKind { /** Call this when the status' primary content changes */
return if (didChange(contentText, contentWarning)) { 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) { when (composeKind) {
ComposeKind.NEW -> if (isEmpty(contentText, contentWarning)) { ComposeKind.NEW -> if (isEmpty(content, effectiveContentWarning)) {
ConfirmationKind.NONE ConfirmationKind.NONE
} else { } else {
ConfirmationKind.SAVE_OR_DISCARD ConfirmationKind.SAVE_OR_DISCARD
} }
ComposeKind.EDIT_DRAFT -> if (isEmpty(contentText, contentWarning)) { ComposeKind.EDIT_DRAFT -> if (isEmpty(content, effectiveContentWarning)) {
ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT ConfirmationKind.CONTINUE_EDITING_OR_DISCARD_DRAFT
} else { } else {
ConfirmationKind.UPDATE_OR_DISCARD 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() * @return True if content of this status is "dirty", meaning one or more of the
val contentWarningChanged = contentWarning.orEmpty() != startingContentWarning * 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 mediaChanged = media.value.isNotEmpty()
val pollChanged = poll.value != null 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 { private fun isEmpty(content: CharSequence, contentWarning: CharSequence): Boolean {
return !modifiedInitialState && (content.isNullOrBlank() && contentWarning.isNullOrBlank() && media.value.isEmpty() && poll.value == null) return !modifiedInitialState && (content.isBlank() && contentWarning.isBlank() && media.value.isEmpty() && poll.value == null)
} }
fun contentWarningChanged(value: Boolean) { fun showContentWarningChanged(value: Boolean) {
showContentWarning.value = value _showContentWarning.value = value
contentWarningStateChanged = true contentWarningStateChanged = true
updateStatusLength()
} }
fun deleteDraft() { fun deleteDraft() {
@ -296,7 +375,7 @@ class ComposeViewModel @Inject constructor(
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
content = content, content = content,
contentWarning = contentWarning, contentWarning = contentWarning,
sensitive = markMediaAsSensitive.value, sensitive = _markMediaAsSensitive.value,
visibility = statusVisibility.value, visibility = statusVisibility.value,
mediaUris = mediaUris, mediaUris = mediaUris,
mediaDescriptions = mediaDescriptions, mediaDescriptions = mediaDescriptions,
@ -337,7 +416,7 @@ class ComposeViewModel @Inject constructor(
text = content, text = content,
warningText = spoilerText, warningText = spoilerText,
visibility = statusVisibility.value.serverString(), visibility = statusVisibility.value.serverString(),
sensitive = attachedMedia.isNotEmpty() && (markMediaAsSensitive.value || showContentWarning.value), sensitive = attachedMedia.isNotEmpty() && (_markMediaAsSensitive.value || showContentWarning.value),
media = attachedMedia, media = attachedMedia,
scheduledAt = scheduledAt.value, scheduledAt = scheduledAt.value,
inReplyToId = inReplyToId, inReplyToId = inReplyToId,
@ -356,7 +435,7 @@ class ComposeViewModel @Inject constructor(
} }
private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) { private fun updateMediaItem(localId: Int, mutator: (QueuedMedia) -> QueuedMedia) {
media.update { mediaList -> _media.update { mediaList ->
mediaList.map { mediaItem -> mediaList.map { mediaItem ->
if (mediaItem.localId == localId) { if (mediaItem.localId == localId) {
mutator(mediaItem) mutator(mediaItem)
@ -438,10 +517,10 @@ class ComposeViewModel @Inject constructor(
val contentWarning = composeOptions?.contentWarning val contentWarning = composeOptions?.contentWarning
if (contentWarning != null) { if (contentWarning != null) {
startingContentWarning = contentWarning initialContentWarning = contentWarning
} }
if (!contentWarningStateChanged) { if (!contentWarningStateChanged) {
showContentWarning.value = !contentWarning.isNullOrBlank() _showContentWarning.value = !contentWarning.isNullOrBlank()
} }
// recreate media list // recreate media list
@ -468,14 +547,14 @@ class ComposeViewModel @Inject constructor(
draftId = composeOptions?.draftId ?: 0 draftId = composeOptions?.draftId ?: 0
scheduledTootId = composeOptions?.scheduledTootId scheduledTootId = composeOptions?.scheduledTootId
originalStatusId = composeOptions?.statusId originalStatusId = composeOptions?.statusId
startingText = composeOptions?.content initialContent = composeOptions?.content ?: ""
postLanguage = composeOptions?.language postLanguage = composeOptions?.language
val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN val tootVisibility = composeOptions?.visibility ?: Status.Visibility.UNKNOWN
if (tootVisibility != Status.Visibility.UNKNOWN) { if (tootVisibility != Status.Visibility.UNKNOWN) {
startingVisibility = tootVisibility startingVisibility = tootVisibility
} }
statusVisibility.value = startingVisibility _statusVisibility.value = startingVisibility
val mentionedUsernames = composeOptions?.mentionedUsernames val mentionedUsernames = composeOptions?.mentionedUsernames
if (mentionedUsernames != null) { if (mentionedUsernames != null) {
val builder = StringBuilder() val builder = StringBuilder()
@ -484,44 +563,101 @@ class ComposeViewModel @Inject constructor(
builder.append(name) builder.append(name)
builder.append(' ') 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 val poll = composeOptions?.poll
if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) { if (poll != null && composeOptions.mediaAttachments.isNullOrEmpty()) {
this.poll.value = poll _poll.value = poll
} }
replyingStatusContent = composeOptions?.replyingStatusContent replyingStatusContent = composeOptions?.replyingStatusContent
replyingStatusAuthor = composeOptions?.replyingStatusAuthor replyingStatusAuthor = composeOptions?.replyingStatusAuthor
updateCloseConfirmation()
setupComplete = true setupComplete = true
} }
fun updatePoll(newPoll: NewPoll) {
poll.value = newPoll
}
fun updateScheduledAt(newScheduledAt: String?) { fun updateScheduledAt(newScheduledAt: String?) {
if (newScheduledAt != scheduledAt.value) { if (newScheduledAt != scheduledAt.value) {
hasScheduledTimeChanged = true scheduledTimeChanged = true
} }
scheduledAt.value = newScheduledAt _scheduledAt.value = newScheduledAt
updateCloseConfirmation()
} }
val editing: Boolean val editing: Boolean
get() = !originalStatusId.isNullOrEmpty() get() = !originalStatusId.isNullOrEmpty()
enum class ConfirmationKind { 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, SAVE_OR_DISCARD,
/** Content has changed when editing a draft, show "update draft or discard changes" */
UPDATE_OR_DISCARD, 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 package app.pachli.components.filters
import android.content.Context import android.content.DialogInterface.BUTTON_NEGATIVE
import android.content.DialogInterface.BUTTON_POSITIVE import android.content.DialogInterface.BUTTON_POSITIVE
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.core.view.size import androidx.core.view.size
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope 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.FilterContext
import app.pachli.core.network.model.FilterKeyword import app.pachli.core.network.model.FilterKeyword
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
import app.pachli.core.ui.extensions.await
import app.pachli.databinding.ActivityEditFilterBinding import app.pachli.databinding.ActivityEditFilterBinding
import app.pachli.databinding.DialogFilterBinding import app.pachli.databinding.DialogFilterBinding
import at.connyduck.calladapter.networkresult.fold 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.snackbar.Snackbar
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import retrofit2.HttpException import retrofit2.HttpException
@ -51,11 +54,20 @@ class EditFilterActivity : BaseActivity() {
private var originalFilter: Filter? = null private var originalFilter: Filter? = null
private lateinit var filterContextSwitches: Map<SwitchMaterial, FilterContext> 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(onBackPressedCallback)
originalFilter = EditFilterActivityIntent.getFilter(intent) originalFilter = EditFilterActivityIntent.getFilter(intent)
filter = originalFilter ?: Filter("", "", listOf(), null, Filter.Action.WARN, listOf()) filter = originalFilter ?: Filter()
binding.apply { binding.apply {
filterContextSwitches = mapOf( filterContextSwitches = mapOf(
filterContextHome to FilterContext.HOME, filterContextHome to FilterContext.HOME,
@ -69,7 +81,6 @@ class EditFilterActivity : BaseActivity() {
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.includedToolbar.toolbar) setSupportActionBar(binding.includedToolbar.toolbar)
supportActionBar?.run { supportActionBar?.run {
// Back button
setDisplayHomeAsUpEnabled(true) setDisplayHomeAsUpEnabled(true)
setDisplayShowHomeEnabled(true) setDisplayShowHomeEnabled(true)
} }
@ -99,12 +110,10 @@ class EditFilterActivity : BaseActivity() {
} else { } else {
viewModel.removeContext(context) viewModel.removeContext(context)
} }
validateSaveButton()
} }
} }
binding.filterTitle.doAfterTextChanged { editable -> binding.filterTitle.doAfterTextChanged { editable ->
viewModel.setTitle(editable.toString()) viewModel.setTitle(editable.toString())
validateSaveButton()
} }
binding.filterActionWarn.setOnCheckedChangeListener { _, checked -> binding.filterActionWarn.setOnCheckedChangeListener { _, checked ->
viewModel.setAction( viewModel.setAction(
@ -130,13 +139,8 @@ class EditFilterActivity : BaseActivity() {
viewModel.setDuration(0) viewModel.setDuration(0)
} }
} }
validateSaveButton()
if (originalFilter == null) { loadFilter()
binding.filterActionWarn.isChecked = true
} else {
loadFilter()
}
observeModel() 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 // Populate the UI from the filter's members
@ -213,7 +236,6 @@ class EditFilterActivity : BaseActivity() {
} }
filter = filter.copy(keywords = newKeywords) filter = filter.copy(keywords = newKeywords)
validateSaveButton()
} }
private fun showAddKeywordDialog() { private fun showAddKeywordDialog() {
@ -256,9 +278,18 @@ class EditFilterActivity : BaseActivity() {
.show() .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() { private fun saveChanges() {
// TODO use a progress bar here (see EditProfileActivity/activity_edit_profile.xml for example)? // 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 android.content.Context
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.pachli.R
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.FilterChangedEvent import app.pachli.appstore.FilterChangedEvent
import app.pachli.core.network.model.Filter 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 app.pachli.core.network.retrofit.MastodonApi
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import retrofit2.HttpException import retrofit2.HttpException
@HiltViewModel @HiltViewModel
class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub: EventHub) : ViewModel() { 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 title = MutableStateFlow("")
val keywords = MutableStateFlow(listOf<FilterKeyword>()) val keywords = MutableStateFlow(listOf<FilterKeyword>())
val action = MutableStateFlow(Filter.Action.WARN) val action = MutableStateFlow(Filter.Action.WARN)
val duration = MutableStateFlow(0) val duration = MutableStateFlow(0)
val contexts = MutableStateFlow(listOf<FilterContext>()) 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) { fun load(filter: Filter) {
originalFilter = filter originalFilter = filter
title.value = filter.title title.value = filter.title
@ -40,10 +59,12 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
fun addKeyword(keyword: FilterKeyword) { fun addKeyword(keyword: FilterKeyword) {
keywords.value += keyword keywords.value += keyword
onChange()
} }
fun deleteKeyword(keyword: FilterKeyword) { fun deleteKeyword(keyword: FilterKeyword) {
keywords.value = keywords.value.filterNot { it == keyword } keywords.value = keywords.value.filterNot { it == keyword }
onChange()
} }
fun modifyKeyword(original: FilterKeyword, updated: FilterKeyword) { 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 { keywords.value = keywords.value.toMutableList().apply {
set(index, updated) set(index, updated)
} }
onChange()
} }
} }
fun setTitle(title: String) { fun setTitle(title: String) {
this.title.value = title this.title.value = title
onChange()
} }
fun setDuration(index: Int) { fun setDuration(index: Int) {
if (!durationIsDirty && duration.value != index) durationIsDirty = true
duration.value = index duration.value = index
onChange()
} }
fun setAction(action: Filter.Action) { fun setAction(action: Filter.Action) {
this.action.value = action this.action.value = action
onChange()
} }
fun addContext(filterContext: FilterContext) { fun addContext(filterContext: FilterContext) {
if (!contexts.value.contains(filterContext)) { if (!contexts.value.contains(filterContext)) {
contexts.value += filterContext contexts.value += filterContext
onChange()
} }
} }
fun removeContext(filterContext: FilterContext) { fun removeContext(filterContext: FilterContext) {
contexts.value = contexts.value.filter { it != filterContext } contexts.value = contexts.value.filter { it != filterContext }
onChange()
} }
fun validate(): Boolean { private fun validate() {
return title.value.isNotBlank() && _validationErrors.value = buildSet {
keywords.value.isNotEmpty() && if (title.value.isBlank()) add(FilterValidationError.NO_TITLE)
contexts.value.isNotEmpty() 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 { suspend fun saveChanges(context: Context): Boolean {
@ -90,15 +142,17 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
val action = action.value val action = action.value
return withContext(viewModelScope.coroutineContext) { return withContext(viewModelScope.coroutineContext) {
val success = originalFilter?.let { filter -> val success = if (originalFilter.id == "") {
updateFilter(filter, title, contexts, action, durationIndex, context) createFilter(title, contexts, action, durationIndex, context)
} ?: createFilter(title, contexts, action, durationIndex, context) } else {
updateFilter(originalFilter, title, contexts, action, durationIndex, context)
}
// Send FilterChangedEvent for old and new contexts, to ensure that // Send FilterChangedEvent for old and new contexts, to ensure that
// e.g., removing a filter from "home" still notifies anything showing // e.g., removing a filter from "home" still notifies anything showing
// the home timeline, so the timeline can be refreshed. // the home timeline, so the timeline can be refreshed.
if (success) { if (success) {
val originalContexts = originalFilter?.contexts ?: emptyList() val originalContexts = originalFilter.contexts
val newFilterContexts = contexts val newFilterContexts = contexts
(originalContexts + newFilterContexts).distinct().forEach { (originalContexts + newFilterContexts).distinct().forEach {
eventHub.dispatch(FilterChangedEvent(it)) 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 { 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( api.createFilter(
title = title, title = title,
context = contexts, 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 { 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( api.updateFilter(
id = originalFilter.id, id = originalFilter.id,
title = title, 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 { private suspend fun updateFilterV1(contexts: List<FilterContext>, expiresInSeconds: String?): Boolean {
val results = keywords.value.map { keyword -> val results = keywords.value.map { keyword ->
if (originalFilter == null) { if (originalFilter.id == "") {
api.createFilterV1( api.createFilterV1(
phrase = keyword.keyword, phrase = keyword.keyword,
context = contexts, context = contexts,
@ -186,7 +240,7 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
) )
} else { } else {
api.updateFilterV1( api.updateFilterV1(
id = originalFilter!!.id, id = originalFilter.id,
phrase = keyword.keyword, phrase = keyword.keyword,
context = contexts, context = contexts,
irreversible = false, irreversible = false,
@ -199,4 +253,18 @@ class EditFilterViewModel @Inject constructor(val api: MastodonApi, val eventHub
return results.none { it.isFailure } 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 package app.pachli.components.filters
import android.app.Activity import android.app.Activity
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import app.pachli.R import app.pachli.R
import app.pachli.core.network.model.Filter
import app.pachli.core.ui.extensions.await import app.pachli.core.ui.extensions.await
internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this) internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = AlertDialog.Builder(this)
@ -27,3 +29,36 @@ internal suspend fun Activity.showDeleteFilterDialog(filterTitle: String) = Aler
.setCancelable(true) .setCancelable(true)
.create() .create()
.await(R.string.dialog_delete_filter_positive_action, android.R.string.cancel) .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 androidx.lifecycle.lifecycleScope
import app.pachli.R import app.pachli.R
import app.pachli.core.activity.BaseActivity 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
import app.pachli.core.common.extensions.visible 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.navigation.EditFilterActivityIntent
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
import app.pachli.core.ui.BackgroundMessage import app.pachli.core.ui.BackgroundMessage
@ -94,8 +95,7 @@ class FiltersActivity : BaseActivity(), FiltersListener {
private fun launchEditFilterActivity(filter: Filter? = null) { private fun launchEditFilterActivity(filter: Filter? = null) {
val intent = EditFilterActivityIntent(this, filter) val intent = EditFilterActivityIntent(this, filter)
startActivity(intent) startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left)
} }
override fun deleteFilter(filter: Filter) { 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.core.ui.BindingHolder
import app.pachli.databinding.ItemRemovableBinding import app.pachli.databinding.ItemRemovableBinding
import app.pachli.util.getRelativeTimeSpanString import app.pachli.util.getRelativeTimeSpanString
import com.google.android.material.color.MaterialColors
class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) : class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
RecyclerView.Adapter<BindingHolder<ItemRemovableBinding>>() { RecyclerView.Adapter<BindingHolder<ItemRemovableBinding>>() {
@ -34,11 +35,25 @@ class FiltersAdapter(val listener: FiltersListener, val filters: List<Filter>) :
) )
} ?: filter.title } ?: filter.title
binding.textSecondary.text = context.getString( // Secondary row shows filter actions and contexts, or errors if the filter is invalid
R.string.filter_description_format, val errors = filter.validate()
actions.getOrNull(filter.action.ordinal - 1), val secondaryText: String
filter.contexts.map { filterContextNames.getOrNull(it.ordinal) }.joinToString("/"), 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 { binding.delete.setOnClickListener {
listener.deleteFilter(filter) listener.deleteFilter(filter)

View File

@ -16,6 +16,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import app.pachli.R import app.pachli.R
import app.pachli.components.compose.ComposeAutoCompleteAdapter import app.pachli.components.compose.ComposeAutoCompleteAdapter
import app.pachli.core.activity.BaseActivity 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
@ -172,7 +173,7 @@ class FollowedTagsActivity :
} }
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(this, tag)) startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(this, tag))
} }
override suspend fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> { 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.appstore.EventHub
import app.pachli.components.notifications.currentAccountNeedsMigration import app.pachli.components.notifications.currentAccountNeedsMigration
import app.pachli.core.accounts.AccountManager 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.common.util.unsafeLazy
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
import app.pachli.core.navigation.AccountListActivityIntent import app.pachli.core.navigation.AccountListActivityIntent
@ -104,11 +105,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setIcon(R.drawable.ic_add_to_tab_24) setIcon(R.drawable.ic_add_to_tab_24)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = TabPreferenceActivityIntent(context) val intent = TabPreferenceActivityIntent(context)
activity?.startActivity(intent) activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
activity?.overridePendingTransition(
DR.anim.slide_from_right,
DR.anim.slide_to_left,
)
true true
} }
} }
@ -118,11 +115,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setIcon(R.drawable.ic_hashtag) setIcon(R.drawable.ic_hashtag)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = FollowedTagsActivityIntent(context) val intent = FollowedTagsActivityIntent(context)
activity?.startActivity(intent) activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
activity?.overridePendingTransition(
DR.anim.slide_from_right,
DR.anim.slide_to_left,
)
true true
} }
} }
@ -132,11 +125,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setIcon(R.drawable.ic_mute_24dp) setIcon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.MUTES) val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.MUTES)
activity?.startActivity(intent) activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
activity?.overridePendingTransition(
DR.anim.slide_from_right,
DR.anim.slide_to_left,
)
true true
} }
} }
@ -146,11 +135,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
icon = makeIcon(GoogleMaterial.Icon.gmd_block) icon = makeIcon(GoogleMaterial.Icon.gmd_block)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.BLOCKS) val intent = AccountListActivityIntent(context, AccountListActivityIntent.Kind.BLOCKS)
activity?.startActivity(intent) activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
activity?.overridePendingTransition(
DR.anim.slide_from_right,
DR.anim.slide_to_left,
)
true true
} }
} }
@ -160,11 +145,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setIcon(R.drawable.ic_mute_24dp) setIcon(R.drawable.ic_mute_24dp)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = InstanceListActivityIntent(context) val intent = InstanceListActivityIntent(context)
activity?.startActivity(intent) activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
activity?.overridePendingTransition(
DR.anim.slide_from_right,
DR.anim.slide_to_left,
)
true true
} }
} }
@ -175,7 +156,7 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setIcon(R.drawable.ic_logout) setIcon(R.drawable.ic_logout)
setOnPreferenceClickListener { setOnPreferenceClickListener {
val intent = LoginActivityIntent(context, LoginMode.MIGRATION) val intent = LoginActivityIntent(context, LoginMode.MIGRATION)
(activity as BaseActivity).startActivityWithSlideInAnimation(intent) activity?.startActivityWithTransition(intent, TransitionKind.EXPLODE)
true true
} }
} }
@ -185,7 +166,8 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
setTitle(R.string.pref_title_timeline_filters) setTitle(R.string.pref_title_timeline_filters)
setIcon(R.drawable.ic_filter_24dp) setIcon(R.drawable.ic_filter_24dp)
setOnPreferenceClickListener { setOnPreferenceClickListener {
launchFilterActivity() val intent = FiltersActivityIntent(requireContext())
activity?.startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
true true
} }
val server = serverRepository.flow.value.getOrElse { null } val server = serverRepository.flow.value.getOrElse { null }
@ -298,14 +280,10 @@ class AccountPreferencesFragment : PreferenceFragmentCompat() {
val intent = Intent() val intent = Intent()
intent.action = "android.settings.APP_NOTIFICATION_SETTINGS" intent.action = "android.settings.APP_NOTIFICATION_SETTINGS"
intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID) intent.putExtra("android.provider.extra.APP_PACKAGE", BuildConfig.APPLICATION_ID)
startActivity(intent) requireActivity().startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
} else { } else {
activity?.let { val intent = PreferencesActivityIntent(requireContext(), PreferenceScreen.NOTIFICATION)
val intent = requireActivity().startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
PreferencesActivityIntent(it, PreferenceScreen.NOTIFICATION)
it.startActivity(intent)
it.overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left)
}
} }
} }
@ -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 { companion object {
fun newInstance() = AccountPreferencesFragment() fun newInstance() = AccountPreferencesFragment()
} }

View File

@ -27,7 +27,8 @@ import androidx.preference.PreferenceFragmentCompat
import app.pachli.R import app.pachli.R
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.core.activity.BaseActivity 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.MainActivityIntent
import app.pachli.core.navigation.PreferencesActivityIntent import app.pachli.core.navigation.PreferencesActivityIntent
import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen import app.pachli.core.navigation.PreferencesActivityIntent.PreferenceScreen
@ -61,7 +62,7 @@ class PreferencesActivity :
* back stack. */ * back stack. */
val intent = MainActivityIntent(this@PreferencesActivity) val intent = MainActivityIntent(this@PreferencesActivity)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK 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) setAppNightMode(theme)
restartActivitiesOnBackPressedCallback.isEnabled = true restartActivitiesOnBackPressedCallback.isEnabled = true
this@PreferencesActivity.restartCurrentActivity() this@PreferencesActivity.recreate()
} }
PrefKeys.FONT_FAMILY, PrefKeys.UI_TEXT_SCALE_RATIO -> { PrefKeys.FONT_FAMILY, PrefKeys.UI_TEXT_SCALE_RATIO -> {
restartActivitiesOnBackPressedCallback.isEnabled = true 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.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, 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.arguments = args
fragment.setTargetFragment(caller, 0) fragment.setTargetFragment(caller, 0)
supportFragmentManager.commit { supportFragmentManager.commit {
// Slide transition, as sub preference screens are "attached" to the
// parent screen.
setCustomAnimations( setCustomAnimations(
DR.anim.slide_from_right, TransitionKind.SLIDE_FROM_END.openEnter,
DR.anim.slide_to_left, TransitionKind.SLIDE_FROM_END.openExit,
DR.anim.slide_from_left, TransitionKind.SLIDE_FROM_END.closeEnter,
DR.anim.slide_to_right, TransitionKind.SLIDE_FROM_END.closeExit,
) )
replace(R.id.fragment_container, fragment) replace(R.id.fragment_container, fragment)
addToBackStack(null) addToBackStack(null)
@ -148,25 +151,11 @@ class PreferencesActivity :
return true return true
} }
private fun saveInstanceState(outState: Bundle) {
outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled)
}
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled) outState.putBoolean(EXTRA_RESTART_ON_BACK, restartActivitiesOnBackPressedCallback.isEnabled)
super.onSaveInstanceState(outState) 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 { companion object {
private const val EXTRA_RESTART_ON_BACK = "restart" private const val EXTRA_RESTART_ON_BACK = "restart"
} }

View File

@ -88,10 +88,6 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
return false return false
} }
override fun finish() {
super.finishWithoutSlideOutAnimation()
}
private fun getPageTitle(position: Int): CharSequence { private fun getPageTitle(position: Int): CharSequence {
return when (position) { return when (position) {
0 -> getString(R.string.title_posts) 0 -> getString(R.string.title_posts)

View File

@ -19,6 +19,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import app.pachli.R import app.pachli.R
import app.pachli.components.search.SearchViewModel import app.pachli.components.search.SearchViewModel
import app.pachli.core.activity.BottomSheetActivity 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.viewBinding
import app.pachli.core.common.extensions.visible import app.pachli.core.common.extensions.visible
import app.pachli.core.navigation.AccountActivityIntent import app.pachli.core.navigation.AccountActivityIntent
@ -140,11 +141,11 @@ abstract class SearchFragment<T : Any> :
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivityIntent(requireContext(), id)) bottomSheetActivity?.startActivityWithDefaultTransition(AccountActivityIntent(requireContext(), id))
} }
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(requireContext(), tag)) bottomSheetActivity?.startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(requireContext(), tag))
} }
override fun onViewUrl(url: String) { 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.components.search.adapter.SearchStatusesAdapter
import app.pachli.core.activity.AccountSelectionListener import app.pachli.core.activity.AccountSelectionListener
import app.pachli.core.activity.BaseActivity import app.pachli.core.activity.BaseActivity
import app.pachli.core.activity.extensions.startActivityWithDefaultTransition
import app.pachli.core.activity.openLink import app.pachli.core.activity.openLink
import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.AccountEntity
import app.pachli.core.navigation.AttachmentViewData import app.pachli.core.navigation.AttachmentViewData
@ -187,7 +188,7 @@ class SearchStatusesFragment : SearchFragment<StatusViewData>(), StatusActionLis
kind = ComposeOptions.ComposeKind.NEW, kind = ComposeOptions.ComposeKind.NEW,
), ),
) )
bottomSheetActivity?.startActivityWithSlideInAnimation(intent) bottomSheetActivity?.startActivityWithDefaultTransition(intent)
} }
private fun more(statusViewData: StatusViewData, view: View) { 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.StatusActionSuccess
import app.pachli.components.timeline.viewmodel.TimelineViewModel import app.pachli.components.timeline.viewmodel.TimelineViewModel
import app.pachli.components.timeline.viewmodel.UiSuccess import app.pachli.components.timeline.viewmodel.UiSuccess
import app.pachli.core.activity.BaseActivity
import app.pachli.core.activity.RefreshableFragment 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
@ -637,12 +637,12 @@ class TimelineFragment :
override fun onShowReblogs(statusId: String) { override fun onShowReblogs(statusId: String) {
val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId) val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId)
(activity as BaseActivity).startActivityWithSlideInAnimation(intent) activity?.startActivityWithDefaultTransition(intent)
} }
override fun onShowFavs(statusId: String) { override fun onShowFavs(statusId: String) {
val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId) val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId)
(activity as BaseActivity).startActivityWithSlideInAnimation(intent) activity?.startActivityWithDefaultTransition(intent)
} }
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { 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.core.ui.extensions.reduceSwipeSensitivity
import app.pachli.databinding.ActivityTrendingBinding import app.pachli.databinding.ActivityTrendingBinding
import app.pachli.interfaces.AppBarLayoutHost import app.pachli.interfaces.AppBarLayoutHost
import app.pachli.interfaces.ReselectableFragment
import app.pachli.pager.MainPagerAdapter import app.pachli.pager.MainPagerAdapter
import com.google.android.material.appbar.AppBarLayout 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 com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject import javax.inject.Inject
@ -55,6 +58,12 @@ class TrendingActivity : BottomSheetActivity(), AppBarLayoutHost, MenuProvider {
private lateinit var adapter: MainPagerAdapter private lateinit var adapter: MainPagerAdapter
private val onBackPressedCallback = object : OnBackPressedCallback(false) {
override fun handleOnBackPressed() {
binding.pager.currentItem = 0
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(binding.root) setContentView(binding.root)
@ -90,14 +99,19 @@ class TrendingActivity : BottomSheetActivity(), AppBarLayoutHost, MenuProvider {
} }
}.attach() }.attach()
onBackPressedDispatcher.addCallback( binding.tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener {
this, override fun onTabSelected(tab: TabLayout.Tab) {
object : OnBackPressedCallback(true) { onBackPressedCallback.isEnabled = tab.position > 0
override fun handleOnBackPressed() { }
if (binding.pager.currentItem != 0) binding.pager.currentItem = 0 else finish()
} 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) { 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 androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import app.pachli.R import app.pachli.R
import app.pachli.components.trending.viewmodel.TrendingTagsViewModel import app.pachli.components.trending.viewmodel.TrendingTagsViewModel
import app.pachli.core.activity.BaseActivity
import app.pachli.core.activity.RefreshableFragment 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.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
import app.pachli.core.common.extensions.viewBinding import app.pachli.core.common.extensions.viewBinding
@ -173,7 +173,7 @@ class TrendingTagsFragment :
} }
fun onViewTag(tag: String) { fun onViewTag(tag: String) {
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation( activity?.startActivityWithDefaultTransition(
TimelineActivityIntent.hashtag( TimelineActivityIntent.hashtag(
requireContext(), requireContext(),
tag, tag,

View File

@ -33,7 +33,7 @@ import androidx.recyclerview.widget.SimpleItemAnimator
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import app.pachli.R import app.pachli.R
import app.pachli.components.viewthread.edits.ViewEditsFragment 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.activity.openLink
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
@ -337,12 +337,12 @@ class ViewThreadFragment :
override fun onShowReblogs(statusId: String) { override fun onShowReblogs(statusId: String) {
val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId) val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.REBLOGGED, statusId)
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) activity?.startActivityWithDefaultTransition(intent)
} }
override fun onShowFavs(statusId: String) { override fun onShowFavs(statusId: String) {
val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId) val intent = AccountListActivityIntent(requireContext(), AccountListActivityIntent.Kind.FAVOURITED, statusId)
(requireActivity() as BaseActivity).startActivityWithSlideInAnimation(intent) activity?.startActivityWithDefaultTransition(intent)
} }
override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) { override fun onContentCollapsedChange(viewData: StatusViewData, isCollapsed: Boolean) {
@ -374,7 +374,12 @@ class ViewThreadFragment :
val viewEditsFragment = ViewEditsFragment.newInstance(statusId) val viewEditsFragment = ViewEditsFragment.newInstance(statusId)
parentFragmentManager.commit { 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") replace(R.id.fragment_container, viewEditsFragment, "ViewEditsFragment_$id")
addToBackStack(null) addToBackStack(null)
} }

View File

@ -32,6 +32,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener
import app.pachli.R import app.pachli.R
import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.activity.emojify import app.pachli.core.activity.emojify
import app.pachli.core.activity.extensions.startActivityWithDefaultTransition
import app.pachli.core.activity.loadAvatar import app.pachli.core.activity.loadAvatar
import app.pachli.core.common.extensions.hide import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show import app.pachli.core.common.extensions.show
@ -183,11 +184,11 @@ class ViewEditsFragment :
} }
override fun onViewAccount(id: String) { override fun onViewAccount(id: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(AccountActivityIntent(requireContext(), id)) bottomSheetActivity?.startActivityWithDefaultTransition(AccountActivityIntent(requireContext(), id))
} }
override fun onViewTag(tag: String) { override fun onViewTag(tag: String) {
bottomSheetActivity?.startActivityWithSlideInAnimation(TimelineActivityIntent.hashtag(requireContext(), tag)) bottomSheetActivity?.startActivityWithDefaultTransition(TimelineActivityIntent.hashtag(requireContext(), tag))
} }
override fun onViewUrl(url: String) { override fun onViewUrl(url: String) {

View File

@ -34,6 +34,7 @@ import androidx.annotation.CallSuper
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.view.ViewCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope 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.BaseActivity
import app.pachli.core.activity.BottomSheetActivity import app.pachli.core.activity.BottomSheetActivity
import app.pachli.core.activity.PostLookupFallbackBehavior import app.pachli.core.activity.PostLookupFallbackBehavior
import app.pachli.core.activity.extensions.startActivityWithDefaultTransition
import app.pachli.core.activity.openLink import app.pachli.core.activity.openLink
import app.pachli.core.database.model.AccountEntity import app.pachli.core.database.model.AccountEntity
import app.pachli.core.database.model.TranslationState 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.AttachmentViewData
import app.pachli.core.navigation.ComposeActivityIntent import app.pachli.core.navigation.ComposeActivityIntent
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
@ -94,8 +95,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
private var serverCanTranslate = false private var serverCanTranslate = false
override fun startActivity(intent: Intent) { override fun startActivity(intent: Intent) {
super.startActivity(intent) requireActivity().startActivityWithDefaultTransition(intent)
requireActivity().overridePendingTransition(DR.anim.slide_from_right, DR.anim.slide_to_left)
} }
override fun onAttach(context: Context) { override fun onAttach(context: Context) {
@ -400,7 +400,7 @@ abstract class SFragment<T : IStatusViewData> : Fragment(), StatusActionListener
val intent = ViewMediaActivityIntent(requireContext(), attachments, urlIndex) val intent = ViewMediaActivityIntent(requireContext(), attachments, urlIndex)
if (view != null) { if (view != null) {
val url = attachment.url val url = attachment.url
view.transitionName = url ViewCompat.setTransitionName(view, url)
val options = ActivityOptionsCompat.makeSceneTransitionAnimation( val options = ActivityOptionsCompat.makeSceneTransitionAnimation(
requireActivity(), requireActivity(),
view, view,

View File

@ -30,6 +30,7 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
@ -62,7 +63,7 @@ class ViewImageFragment : ViewMediaFragment() {
private var scheduleToolbarHide = false private var scheduleToolbarHide = false
override fun setupMediaView(showingDescription: Boolean) { override fun setupMediaView(showingDescription: Boolean) {
binding.photoView.transitionName = attachment.url ViewCompat.setTransitionName(binding.photoView, attachment.url)
binding.mediaDescription.text = attachment.description binding.mediaDescription.text = attachment.description
binding.captionSheet.visible(showingDescription) binding.captionSheet.visible(showingDescription)

View File

@ -34,6 +34,7 @@ import android.widget.FrameLayout
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.view.GestureDetectorCompat import androidx.core.view.GestureDetectorCompat
import androidx.core.view.ViewCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
@ -359,7 +360,7 @@ class ViewVideoFragment : ViewMediaFragment() {
// Ensure the description is visible over the video // Ensure the description is visible over the video
binding.mediaDescription.elevation = binding.videoView.elevation + 1 binding.mediaDescription.elevation = binding.videoView.elevation + 1
binding.videoView.transitionName = attachment.url ViewCompat.setTransitionName(binding.videoView, attachment.url)
if (!startedTransition && shouldCallMediaReady) { if (!startedTransition && shouldCallMediaReady) {
startedTransition = true startedTransition = true

View File

@ -16,14 +16,10 @@
package app.pachli.service package app.pachli.service
import android.annotation.SuppressLint
import android.annotation.TargetApi import android.annotation.TargetApi
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build import android.os.Build
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import app.pachli.components.notifications.pendingIntentFlags
import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions import app.pachli.core.navigation.ComposeActivityIntent.ComposeOptions
import app.pachli.core.navigation.MainActivityIntent import app.pachli.core.navigation.MainActivityIntent
@ -33,21 +29,14 @@ import app.pachli.core.navigation.MainActivityIntent
*/ */
@TargetApi(24) @TargetApi(24)
class PachliTileService : TileService() { class PachliTileService : TileService() {
@SuppressLint("StartActivityAndCollapseDeprecated")
override fun onClick() { override fun onClick() {
val intent = MainActivityIntent.openCompose(this, ComposeOptions()) val intent = MainActivityIntent.openCompose(this, ComposeOptions())
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { 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 { } else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent) 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 java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
@ -77,6 +79,11 @@ class EditProfileViewModel @Inject constructor(
private var apiProfileAccount: Account? = null 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 { fun obtainProfile() = viewModelScope.launch {
if (profileData.value == null || profileData.value is Error) { if (profileData.value == null || profileData.value is Error) {
profileData.postValue(Loading()) profileData.postValue(Loading())
@ -170,10 +177,8 @@ class EditProfileViewModel @Inject constructor(
} }
} }
internal fun hasUnsavedChanges(newProfileData: ProfileDataInUi): Boolean { internal fun onChange(newProfileData: ProfileDataInUi) {
val diff = getProfileDiff(apiProfileAccount, newProfileData) _isDirty.value = getProfileDiff(apiProfileAccount, newProfileData).hasChanges()
return diff.hasChanges()
} }
private fun getProfileDiff(oldProfileAccount: Account?, newProfileData: ProfileDataInUi): DiffProfileData { private fun getProfileDiff(oldProfileAccount: Account?, newProfileData: ProfileDataInUi): DiffProfileData {

View File

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

View File

@ -503,7 +503,7 @@
<string name="notification_report_description">ارسال إشعار عن شكاوى المدراء</string> <string name="notification_report_description">ارسال إشعار عن شكاوى المدراء</string>
<string name="set_focus_description">اضغط على الدائرة أو اسحبها لاختيار النقطة المحورية التي ستكون مرئية دائمًا في الصور المصغرة.</string> <string name="set_focus_description">اضغط على الدائرة أو اسحبها لاختيار النقطة المحورية التي ستكون مرئية دائمًا في الصور المصغرة.</string>
<string name="compose_save_draft_loses_media">حفظ المسودة؟ (سيتم رفع المرفقات مرة أخرى عند استعادة المسودة.)</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="post_edited">عدَّلَ %s</string>
<string name="notification_report_format">شكوى جديدة عن %s</string> <string name="notification_report_format">شكوى جديدة عن %s</string>
<string name="status_edit_info">%1$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="many"><b>%s</b> пашыраных</item>
<item quantity="other"><b>%s</b> пашыраных</item> <item quantity="other"><b>%s</b> пашыраных</item>
</plurals> </plurals>
<string name="compose_unsaved_changes">У Вас засталіся незахаваныя змены.</string> <string name="unsaved_changes">У Вас засталіся незахаваныя змены.</string>
<string name="expand_collapse_all_posts">Разгарнуць/згарнуць допісы</string> <string name="expand_collapse_all_posts">Разгарнуць/згарнуць допісы</string>
<string name="action_open_post">Адкрыць допіс</string> <string name="action_open_post">Адкрыць допіс</string>
<string name="restart_required">Патрэбна перазапусціць праграму</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="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="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="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="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="url_domain_notifier">\u0020(🔗 %s)</string>
<string name="pref_title_show_self_username">Mostra el nom d\'usuari a les barres d\'eines</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="title_edits">Golygiadau</string>
<string name="post_media_alt">AMGEN</string> <string name="post_media_alt">AMGEN</string>
<string name="action_discard">Hepgor newidiadau</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="mute_notifications_switch">Tewi hysbysiadau</string>
<string name="a11y_label_loading_thread">Llwytho trywydd</string> <string name="a11y_label_loading_thread">Llwytho trywydd</string>
<string name="action_share_account_link">Rhannu ddolen i gyfrif</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_disabled">Deaktiviert</string>
<string name="pref_summary_http_proxy_missing">&lt;nicht gesetzt&gt;</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="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="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="error_muting_hashtag_format">Fehler beim Stummschalten von #%s</string>
<string name="action_post_failed">Hochladen fehlgeschlagen</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="pref_title_notification_filter_reports">Hay una nueva denuncia</string>
<string name="action_discard">Descartar cambios</string> <string name="action_discard">Descartar cambios</string>
<string name="action_continue_edit">Continuar editando</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="title_edits">Ediciones</string>
<string name="action_post_failed">La subida falló</string> <string name="action_post_failed">La subida falló</string>
<string name="action_post_failed_do_nothing">Descartar</string> <string name="action_post_failed_do_nothing">Descartar</string>
@ -683,4 +683,4 @@
<string name="manage_lists">Gestionar listas</string> <string name="manage_lists">Gestionar listas</string>
<string name="title_lists_loading">Listas - cargando…</string> <string name="title_lists_loading">Listas - cargando…</string>
<string name="title_lists_failed">Listas - falló en cargar</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="description_browser_login">ممکن است از روش‌های تأیید خویت اضافی پشتیبانی کند؛ ولی نیازمند مرورگری پشتیبانی شده است.</string>
<string name="action_discard">دور انداختن تغییرات</string> <string name="action_discard">دور انداختن تغییرات</string>
<string name="action_continue_edit">ادامهٔ ویرایش</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_link">هم‌رسانی پیوند به حساب</string>
<string name="action_share_account_username">هم‌رسانی نام کاربری حساب</string> <string name="action_share_account_username">هم‌رسانی نام کاربری حساب</string>
<string name="send_account_link_to">هم‌رسانی نشانی حساب به…</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="pref_title_notification_filter_poll">päättyneet äänestykset</string>
<string name="abbreviated_in_seconds">%ds</string> <string name="abbreviated_in_seconds">%ds</string>
<string name="failed_to_unpin">Irrottaminen epäonnistui</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="state_follow_requested">Seuraamista pyydetty</string>
<string name="compose_delete_draft">Poista luonnos?</string> <string name="compose_delete_draft">Poista luonnos?</string>
<string name="notification_notification_worker">Ilmoituksia haetaan…</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_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="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="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="description_post_edited">Modifié</string>
<string name="select_list_empty">Vous n\'avez pas encore de liste</string> <string name="select_list_empty">Vous n\'avez pas encore de liste</string>
<string name="select_list_manage">Gérer les listes</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="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="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="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="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. <string name="action_post_failed_detail">Dhfhàillig luchdadh suas a phuist agad is chaidh a shàbhaladh na dhreachd.
\n \n

View File

@ -514,7 +514,7 @@
<string name="status_created_info">Creado por %1$s</string> <string name="status_created_info">Creado por %1$s</string>
<string name="action_discard">Desbotar cambios</string> <string name="action_discard">Desbotar cambios</string>
<string name="action_continue_edit">Continuar a edición</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_link">Comparte ligazón da conta</string>
<string name="action_share_account_username">Comparte identificador 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> <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="post_media_alt">ALT</string>
<string name="action_discard">Változtatások elvetése</string> <string name="action_discard">Változtatások elvetése</string>
<string name="action_continue_edit">Szerkesztés folytatása</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_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="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> <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="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="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="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_disabled">Óvirkt</string>
<string name="pref_summary_http_proxy_missing">&lt;ekki stillt&gt;</string> <string name="pref_summary_http_proxy_missing">&lt;ekki stillt&gt;</string>
<string name="pref_summary_http_proxy_invalid">&lt;ógilt&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_header_report_format">%s ha segnalato %s</string>
<string name="notification_summary_report_format">%s · %d post allegati</string> <string name="notification_summary_report_format">%s · %d post allegati</string>
<string name="action_add_reaction">aggiungi reazione</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="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="error_status_source_load">Caricamento dello status della sorgente dal server fallito.</string>
<string name="post_edited">Modificato %s</string> <string name="post_edited">Modificato %s</string>

View File

@ -512,7 +512,7 @@
<string name="status_created_info">%1$s の投稿</string> <string name="status_created_info">%1$s の投稿</string>
<string name="follow_requests_info">アカウントがロックされていなかったとしても、%1$s のスタッフは以下のアカウントのフォローリクエストを確認した方がいいと判断しました。</string> <string name="follow_requests_info">アカウントがロックされていなかったとしても、%1$s のスタッフは以下のアカウントのフォローリクエストを確認した方がいいと判断しました。</string>
<string name="action_set_focus">中心点の設定</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="error_status_source_load">サーバーからステータスの元情報を取得できませんでした。</string>
<string name="pref_summary_http_proxy_disabled">無効</string> <string name="pref_summary_http_proxy_disabled">無効</string>
<string name="pref_summary_http_proxy_missing">&lt;設定なし&gt;</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="notification_summary_large">%1$s, %2$s, %3$s un %4$d citi</string>
<string name="title_media">Multivide</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="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_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="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> <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="ui_success_accepted_follow_request">Følgeforespørsel akseptert</string>
<string name="action_discard">Forkast endringer</string> <string name="action_discard">Forkast endringer</string>
<string name="action_continue_edit">Fortsett endring</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="select_list_empty">Du har ingen lister, enda</string>
<string name="error_list_load">Feil under lading av lister</string> <string name="error_list_load">Feil under lading av lister</string>
<string name="select_list_manage">Forvalte 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="delete_scheduled_post_warning">Dit ingeplande bericht verwijderen\?</string>
<string name="failed_to_pin">Kan niet vastmaken</string> <string name="failed_to_pin">Kan niet vastmaken</string>
<string name="pref_summary_http_proxy_disabled">Uitgeschakeld</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="mute_notifications_switch">Meldingen negeren</string>
<string name="title_edits">Bewerkingen</string> <string name="title_edits">Bewerkingen</string>
<string name="pref_default_post_language">Standaardtaal van berichten</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="post_media_alt">ALT</string>
<string name="action_discard">Ignorar las modificacions</string> <string name="action_discard">Ignorar las modificacions</string>
<string name="action_continue_edit">Téner de modificar</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="a11y_label_loading_thread">Cargament del fil</string>
<string name="pref_summary_http_proxy_disabled">Desactivat</string> <string name="pref_summary_http_proxy_disabled">Desactivat</string>
<string name="pref_summary_http_proxy_missing">&lt;pas definit&gt;</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_edit_info">%1$s edytował</string>
<string name="status_created_info">%1$s stworzył</string> <string name="status_created_info">%1$s stworzył</string>
<string name="title_edits">Edycje</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">Błąd wysyłania</string>
<string name="action_post_failed_show_drafts">Pokaż szkice</string> <string name="action_post_failed_show_drafts">Pokaż szkice</string>
<string name="action_post_failed_do_nothing">Odrzuć</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="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="status_edit_info">%1$s editou</string>
<string name="action_continue_edit">Continuar editando</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="description_post_edited">Editado</string>
<string name="delete_scheduled_post_warning">Excluir este Toot agendado?</string> <string name="delete_scheduled_post_warning">Excluir este Toot agendado?</string>
<string name="action_unfollow_hashtag_format">Deixar de seguir #%s\?</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="dialog_follow_hashtag_title">व्यक्तित्वविवरणलेखा अनुसरतु</string>
<string name="limit_notifications">कालानुक्रमपङ्क्त्याः सूचनाः परिमिताः कुरुताम्</string> <string name="limit_notifications">कालानुक्रमपङ्क्त्याः सूचनाः परिमिताः कुरुताम्</string>
<string name="notification_report_description">परिमितावेदनानि प्रति ज्ञापनसूचनाः</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_notification_filter_reports">नूतनम् आवेदनमस्ति</string>
<string name="pref_title_wellbeing_mode">सुस्थितिः</string> <string name="pref_title_wellbeing_mode">सुस्थितिः</string>
<string name="delete_scheduled_post_warning">इदं कालबद्धदौत्यं विनश्येत् किम् \?</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="status_created_info">%1$s skapade</string>
<string name="action_discard">Förkasta ändringar</string> <string name="action_discard">Förkasta ändringar</string>
<string name="action_continue_edit">Fortsätt redigera</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">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. <string name="action_post_failed_detail">Ett fel inträffade när inlägget skulle laddas upp och har sparats till utkast.
\n \n

View File

@ -524,7 +524,7 @@
<string name="status_created_info">%1$s oluşturdu</string> <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_title">Etiketi takip et</string>
<string name="dialog_follow_hashtag_hint">#etiket</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="status_edit_info">%1$s düzenledi</string>
<string name="title_edits">Düzenlemeler</string> <string name="title_edits">Düzenlemeler</string>
<string name="hint_description">ıklama</string> <string name="hint_description">ıklama</string>

View File

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

View File

@ -499,7 +499,7 @@
<string name="post_media_alt"></string> <string name="post_media_alt"></string>
<string name="action_discard">Hủy bỏ thay đổi</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="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="mute_notifications_switch">Ẩn thông báo</string>
<string name="status_edit_info">Sửa %1$s</string> <string name="status_edit_info">Sửa %1$s</string>
<string name="status_created_info">Đăng %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="post_media_alt">ALT</string>
<string name="action_discard">放弃更改</string> <string name="action_discard">放弃更改</string>
<string name="action_continue_edit">继续编辑</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="status_created_info">%1$s 创建了</string>
<string name="mute_notifications_switch">将通知静音</string> <string name="mute_notifications_switch">将通知静音</string>
<string name="title_edits">编辑</string> <string name="title_edits">编辑</string>

View File

@ -412,7 +412,7 @@
<string name="compose_delete_draft">Delete draft?</string> <string name="compose_delete_draft">Delete draft?</string>
<string name="compose_save_draft">Save 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_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_title">Sending post…</string>
<string name="send_post_notification_error_title">Error sending post</string> <string name="send_post_notification_error_title">Error sending post</string>
<string name="send_post_notification_channel_name">Sending Posts</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_add_to_tab_success">Added \'%1$s\' to tabs</string>
<string name="action_remove_tab">Remove tab</string> <string name="action_remove_tab">Remove tab</string>
<string name="action_manage_tabs">Manage tabs</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> </resources>

View File

@ -18,7 +18,7 @@
package app.pachli package app.pachli
import androidx.test.ext.junit.runners.AndroidJUnit4 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.Attachment
import app.pachli.core.network.model.Filter import app.pachli.core.network.model.Filter
import app.pachli.core.network.model.FilterContext import app.pachli.core.network.model.FilterContext
@ -259,7 +259,7 @@ class FilterV1Test {
fun unchangedExpiration_shouldBeNegative_whenFilterIsExpired() { fun unchangedExpiration_shouldBeNegative_whenFilterIsExpired() {
val expiredBySeconds = 3600 val expiredBySeconds = 3600
val expiredDate = Date.from(Instant.now().minusSeconds(expiredBySeconds.toLong())) 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) assert(updatedDuration != null && updatedDuration.toInt() <= -expiredBySeconds)
} }
@ -267,7 +267,7 @@ class FilterV1Test {
fun unchangedExpiration_shouldBePositive_whenFilterIsUnexpired() { fun unchangedExpiration_shouldBePositive_whenFilterIsUnexpired() {
val expiresInSeconds = 3600 val expiresInSeconds = 3600
val expiredDate = Date.from(Instant.now().plusSeconds(expiresInSeconds.toLong())) 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)) assert(updatedDuration != null && updatedDuration.toInt() > (expiresInSeconds - 60))
} }

View File

@ -239,7 +239,7 @@ class ComposeActivityTest {
rule.launch() rule.launch()
rule.getScenario().onActivity { rule.getScenario().onActivity {
insertSomeTextInContent(it, content) insertSomeTextInContent(it, content)
assertEquals(content.length, it.calculateTextLength()) assertEquals(content.length, it.viewModel.statusLength.value)
} }
} }
@ -249,7 +249,7 @@ class ComposeActivityTest {
rule.launch() rule.launch()
rule.getScenario().onActivity { rule.getScenario().onActivity {
insertSomeTextInContent(it, content) insertSomeTextInContent(it, content)
assertEquals(6, it.calculateTextLength()) assertEquals(6, it.viewModel.statusLength.value)
} }
} }
@ -259,7 +259,7 @@ class ComposeActivityTest {
rule.launch() rule.launch()
rule.getScenario().onActivity { rule.getScenario().onActivity {
insertSomeTextInContent(it, content) insertSomeTextInContent(it, content)
assertEquals(7, it.calculateTextLength()) assertEquals(7, it.viewModel.statusLength.value)
} }
} }
@ -271,7 +271,7 @@ class ComposeActivityTest {
insertSomeTextInContent(it, content) insertSomeTextInContent(it, content)
assertEquals( assertEquals(
InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL,
it.calculateTextLength(), it.viewModel.statusLength.value,
) )
} }
} }
@ -282,7 +282,7 @@ class ComposeActivityTest {
rule.launch() rule.launch()
rule.getScenario().onActivity { rule.getScenario().onActivity {
insertSomeTextInContent(it, content) insertSomeTextInContent(it, content)
assertEquals(21, it.calculateTextLength()) assertEquals(21, it.viewModel.statusLength.value)
} }
} }
@ -295,7 +295,7 @@ class ComposeActivityTest {
insertSomeTextInContent(it, additionalContent + url) insertSomeTextInContent(it, additionalContent + url)
assertEquals( assertEquals(
additionalContent.length + InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL, 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) insertSomeTextInContent(it, shortUrl + additionalContent + url)
assertEquals( assertEquals(
additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), 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) insertSomeTextInContent(it, url + additionalContent + url)
assertEquals( assertEquals(
additionalContent.length + (InstanceInfoRepository.DEFAULT_CHARACTERS_RESERVED_PER_URL * 2), 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) insertSomeTextInContent(it, additionalContent + url)
assertEquals( assertEquals(
additionalContent.length + customUrlLength, additionalContent.length + customUrlLength,
it.calculateTextLength(), it.viewModel.statusLength.value,
) )
} }
} }
@ -357,7 +357,7 @@ class ComposeActivityTest {
insertSomeTextInContent(it, shortUrl + additionalContent + url) insertSomeTextInContent(it, shortUrl + additionalContent + url)
assertEquals( assertEquals(
additionalContent.length + (customUrlLength * 2), additionalContent.length + (customUrlLength * 2),
it.calculateTextLength(), it.viewModel.statusLength.value,
) )
} }
} }
@ -373,7 +373,7 @@ class ComposeActivityTest {
insertSomeTextInContent(it, url + additionalContent + url) insertSomeTextInContent(it, url + additionalContent + url)
assertEquals( assertEquals(
additionalContent.length + (customUrlLength * 2), 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.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.runners.Parameterized import org.robolectric.ParameterizedRobolectricTestRunner
@RunWith(Parameterized::class) @RunWith(ParameterizedRobolectricTestRunner::class)
class StatusLengthTest( class StatusLengthTest(
private val text: String, private val text: String,
private val expectedLength: Int, private val expectedLength: Int,
) { ) {
companion object { companion object {
@Parameterized.Parameters(name = "{0}") @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
@JvmStatic @JvmStatic
fun data(): Iterable<Any> { fun data(): Iterable<Any> {
return listOf( return listOf(
@ -61,7 +61,7 @@ class StatusLengthTest(
assertEquals( assertEquals(
expectedLength, expectedLength,
ComposeActivity.statusLength(spannedText, null, 23), ComposeViewModel.statusLength(spannedText, "", 23),
) )
} }
@ -75,7 +75,7 @@ class StatusLengthTest(
) )
assertEquals( assertEquals(
expectedLength + cwText.length, 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.content.ContextCompat
import androidx.core.view.MenuProvider import androidx.core.view.MenuProvider
import app.pachli.core.accounts.AccountManager 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.database.model.AccountEntity
import app.pachli.core.designsystem.EmbeddedFontFamily import app.pachli.core.designsystem.EmbeddedFontFamily
import app.pachli.core.designsystem.R as DR import app.pachli.core.designsystem.R as DR
@ -82,6 +85,13 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 // Set the theme from preferences
val theme = AppTheme.from(sharedPreferencesRepository) val theme = AppTheme.from(sharedPreferencesRepository)
Timber.d("activeTheme: %s", theme) Timber.d("activeTheme: %s", theme)
@ -156,11 +166,6 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider {
return true 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 { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) { if (item.itemId == android.R.id.home) {
onBackPressedDispatcher.onBackPressed() onBackPressedDispatcher.onBackPressed()
@ -171,11 +176,13 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider {
override fun finish() { override fun finish() {
super.finish() super.finish()
overridePendingTransition(DR.anim.slide_from_left, DR.anim.slide_to_right)
}
fun finishWithoutSlideOutAnimation() { if (!canOverrideActivityTransitions()) {
super.finish() intent.getTransitionKind()?.let {
@Suppress("DEPRECATION")
overridePendingTransition(it.closeEnter, it.closeExit)
}
}
} }
private fun redirectIfNotLoggedIn() { private fun redirectIfNotLoggedIn() {
@ -183,7 +190,7 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider {
if (account == null) { if (account == null) {
val intent = LoginActivityIntent(this) val intent = LoginActivityIntent(this)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
finish() finish()
} }
} }
@ -259,7 +266,7 @@ abstract class BaseActivity : AppCompatActivity(), MenuProvider {
accountManager.setActiveAccount(account.id) accountManager.setActiveAccount(account.id)
val intent = MainActivityIntent.redirect(this, account.id, url) val intent = MainActivityIntent.redirect(this, account.id, url)
startActivity(intent) startActivity(intent)
finishWithoutSlideOutAnimation() finish()
} }
override fun onRequestPermissionsResult( override fun onRequestPermissionsResult(

View File

@ -24,6 +24,9 @@ import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.lifecycleScope 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.AccountActivityIntent
import app.pachli.core.navigation.ViewThreadActivityIntent import app.pachli.core.navigation.ViewThreadActivityIntent
import app.pachli.core.network.retrofit.MastodonApi import app.pachli.core.network.retrofit.MastodonApi
@ -105,13 +108,13 @@ abstract class BottomSheetActivity : BaseActivity() {
open fun viewThread(statusId: String, url: String?) { open fun viewThread(statusId: String, url: String?) {
if (!isSearching()) { if (!isSearching()) {
val intent = ViewThreadActivityIntent(this, statusId, url) val intent = ViewThreadActivityIntent(this, statusId, url)
startActivityWithSlideInAnimation(intent) startActivityWithTransition(intent, TransitionKind.SLIDE_FROM_END)
} }
} }
open fun viewAccount(id: String) { open fun viewAccount(id: String) {
val intent = AccountActivityIntent(this, id) val intent = AccountActivityIntent(this, id)
startActivityWithSlideInAnimation(intent) startActivityWithDefaultTransition(intent)
} }
protected open fun performUrlFallbackAction(url: String, fallbackBehavior: PostLookupFallbackBehavior) { 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"> <set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="0" android:toXDelta="100%p" <translate android:fromXDelta="0" android:toXDelta="100%p"
android:interpolator="@android:anim/accelerate_decelerate_interpolator" android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="300"/> android:duration="@integer/activity_slide_transition_duration_ms"/>
</set> </set>

View File

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

View File

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

View File

@ -2,5 +2,5 @@
<set xmlns:android="http://schemas.android.com/apk/res/android"> <set xmlns:android="http://schemas.android.com/apk/res/android">
<translate android:fromXDelta="-100%p" android:toXDelta="0" <translate android:fromXDelta="-100%p" android:toXDelta="0"
android:interpolator="@android:anim/accelerate_decelerate_interpolator" android:interpolator="@android:anim/accelerate_decelerate_interpolator"
android:duration="300"/> android:duration="@integer/activity_slide_transition_duration_ms"/>
</set> </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="profile_media_column_count">3</integer>
<integer name="trending_column_count">1</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> </resources>

View File

@ -11,11 +11,11 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class Filter( data class Filter(
val id: String, val id: String = "",
val title: String, val title: String = "",
@Json(name = "context") val contexts: List<FilterContext>, @Json(name = "context") val contexts: List<FilterContext> = emptyList(),
@Json(name = "expires_at") val expiresAt: Date?, @Json(name = "expires_at") val expiresAt: Date? = null,
@Json(name = "filter_action") val action: Action, @Json(name = "filter_action") val action: Action = Action.WARN,
// This should not normally be empty. However, Mastodon does not include // This should not normally be empty. However, Mastodon does not include
// this in a status' `filtered.filter` property (it's not null or empty, // 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 // 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.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter import androidx.viewpager2.adapter.FragmentStateAdapter
import app.pachli.core.activity.BottomSheetActivity 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.designsystem.R as DR
import app.pachli.core.ui.extensions.reduceSwipeSensitivity import app.pachli.core.ui.extensions.reduceSwipeSensitivity
import app.pachli.feature.about.databinding.ActivityAboutBinding import app.pachli.feature.about.databinding.ActivityAboutBinding
import com.bumptech.glide.request.target.FixedSizeDrawable import com.bumptech.glide.request.target.FixedSizeDrawable
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.mikepenz.aboutlibraries.LibsBuilder import com.mikepenz.aboutlibraries.LibsBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class AboutActivity : BottomSheetActivity(), MenuProvider { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val binding = ActivityAboutBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
@ -65,14 +75,17 @@ class AboutActivity : BottomSheetActivity(), MenuProvider {
tab.text = adapter.title(position) tab.text = adapter.title(position)
}.attach() }.attach()
onBackPressedDispatcher.addCallback( binding.tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
this, override fun onTabSelected(tab: TabLayout.Tab) {
object : OnBackPressedCallback(true) { onBackPressedCallback.isEnabled = tab.position > 0
override fun handleOnBackPressed() { }
if (binding.pager.currentItem != 0) binding.pager.currentItem = 0 else finish()
} 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