mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-22 09:49:30 +01:00
Add Notification profiles.
This commit is contained in:
@@ -5,6 +5,7 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.EdgeEffect
|
||||
import androidx.annotation.CallSuper
|
||||
import androidx.annotation.LayoutRes
|
||||
import androidx.annotation.MenuRes
|
||||
import androidx.annotation.StringRes
|
||||
@@ -24,9 +25,10 @@ abstract class DSLSettingsFragment(
|
||||
val layoutManagerProducer: (Context) -> RecyclerView.LayoutManager = { context -> LinearLayoutManager(context) }
|
||||
) : Fragment(layoutId) {
|
||||
|
||||
private lateinit var recyclerView: RecyclerView
|
||||
private lateinit var scrollAnimationHelper: OnScrollAnimationHelper
|
||||
private var recyclerView: RecyclerView? = null
|
||||
private var scrollAnimationHelper: OnScrollAnimationHelper? = null
|
||||
|
||||
@CallSuper
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
val toolbarShadow: View = view.findViewById(R.id.toolbar_shadow)
|
||||
@@ -44,16 +46,23 @@ abstract class DSLSettingsFragment(
|
||||
toolbar.setOnMenuItemClickListener { onOptionsItemSelected(it) }
|
||||
}
|
||||
|
||||
recyclerView = view.findViewById(R.id.recycler)
|
||||
recyclerView.edgeEffectFactory = EdgeEffectFactory()
|
||||
scrollAnimationHelper = getOnScrollAnimationHelper(toolbarShadow)
|
||||
val adapter = DSLSettingsAdapter()
|
||||
val settingsAdapter = DSLSettingsAdapter()
|
||||
|
||||
recyclerView.layoutManager = layoutManagerProducer(requireContext())
|
||||
recyclerView.adapter = adapter
|
||||
recyclerView.addOnScrollListener(scrollAnimationHelper)
|
||||
recyclerView = view.findViewById<RecyclerView>(R.id.recycler).apply {
|
||||
edgeEffectFactory = EdgeEffectFactory()
|
||||
layoutManager = layoutManagerProducer(requireContext())
|
||||
adapter = settingsAdapter
|
||||
addOnScrollListener(scrollAnimationHelper!!)
|
||||
}
|
||||
|
||||
bindAdapter(adapter)
|
||||
bindAdapter(settingsAdapter)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
recyclerView = null
|
||||
scrollAnimationHelper = null
|
||||
}
|
||||
|
||||
protected open fun getOnScrollAnimationHelper(toolbarShadow: View): OnScrollAnimationHelper {
|
||||
|
||||
@@ -53,6 +53,9 @@ sealed class DSLSettingsText {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.color(textColor, charSequence)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = textColor == (other as? ColorModifier)?.textColor
|
||||
override fun hashCode(): Int = textColor
|
||||
}
|
||||
|
||||
object CenterModifier : Modifier {
|
||||
@@ -68,6 +71,9 @@ sealed class DSLSettingsText {
|
||||
override fun modify(context: Context, charSequence: CharSequence): CharSequence {
|
||||
return SpanUtil.textAppearance(context, textAppearance, charSequence)
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = textAppearance == (other as? TextAppearanceModifier)?.textAppearance
|
||||
override fun hashCode(): Int = textAppearance
|
||||
}
|
||||
|
||||
object BoldModifier : Modifier {
|
||||
|
||||
@@ -9,6 +9,7 @@ import io.reactivex.rxjava3.subjects.Subject
|
||||
import org.thoughtcrime.securesms.MainActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleFragmentArgs
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentComponent
|
||||
import org.thoughtcrime.securesms.components.settings.app.subscription.DonationPaymentRepository
|
||||
import org.thoughtcrime.securesms.help.HelpFragment
|
||||
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.util.CachedInflater
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
|
||||
private const val START_LOCATION = "app.settings.start.location"
|
||||
private const val START_ARGUMENTS = "app.settings.start.arguments"
|
||||
private const val NOTIFICATION_CATEGORY = "android.intent.category.NOTIFICATION_PREFERENCES"
|
||||
private const val STATE_WAS_CONFIGURATION_UPDATED = "app.settings.state.configuration.updated"
|
||||
|
||||
@@ -50,6 +52,11 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
StartLocation.SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToSubscriptions()
|
||||
StartLocation.BOOST -> AppSettingsFragmentDirections.actionAppSettingsFragmentToBoostsFragment()
|
||||
StartLocation.MANAGE_SUBSCRIPTIONS -> AppSettingsFragmentDirections.actionDirectToManageDonations()
|
||||
StartLocation.NOTIFICATION_PROFILES -> AppSettingsFragmentDirections.actionDirectToNotificationProfiles()
|
||||
StartLocation.CREATE_NOTIFICATION_PROFILE -> AppSettingsFragmentDirections.actionDirectToCreateNotificationProfiles()
|
||||
StartLocation.NOTIFICATION_PROFILE_DETAILS -> AppSettingsFragmentDirections.actionDirectToNotificationProfileDetails(
|
||||
EditNotificationProfileScheduleFragmentArgs.fromBundle(intent.getBundleExtra(START_ARGUMENTS)!!).profileId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +138,22 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
@JvmStatic
|
||||
fun manageSubscriptions(context: Context): Intent = getIntentForStartLocation(context, StartLocation.MANAGE_SUBSCRIPTIONS)
|
||||
|
||||
@JvmStatic
|
||||
fun notificationProfiles(context: Context): Intent = getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILES)
|
||||
|
||||
@JvmStatic
|
||||
fun createNotificationProfile(context: Context): Intent = getIntentForStartLocation(context, StartLocation.CREATE_NOTIFICATION_PROFILE)
|
||||
|
||||
@JvmStatic
|
||||
fun notificationProfileDetails(context: Context, profileId: Long): Intent {
|
||||
val arguments = EditNotificationProfileScheduleFragmentArgs.Builder(profileId, false)
|
||||
.build()
|
||||
.toBundle()
|
||||
|
||||
return getIntentForStartLocation(context, StartLocation.NOTIFICATION_PROFILE_DETAILS)
|
||||
.putExtra(START_ARGUMENTS, arguments)
|
||||
}
|
||||
|
||||
private fun getIntentForStartLocation(context: Context, startLocation: StartLocation): Intent {
|
||||
return Intent(context, AppSettingsActivity::class.java)
|
||||
.putExtra(ARG_NAV_GRAPH, R.navigation.app_settings)
|
||||
@@ -147,7 +170,10 @@ class AppSettingsActivity : DSLSettingsActivity(), DonationPaymentComponent {
|
||||
CHANGE_NUMBER(5),
|
||||
SUBSCRIPTIONS(6),
|
||||
BOOST(7),
|
||||
MANAGE_SUBSCRIPTIONS(8);
|
||||
MANAGE_SUBSCRIPTIONS(8),
|
||||
NOTIFICATION_PROFILES(9),
|
||||
CREATE_NOTIFICATION_PROFILE(10),
|
||||
NOTIFICATION_PROFILE_DETAILS(11);
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int?): StartLocation {
|
||||
|
||||
@@ -15,6 +15,7 @@ import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import androidx.preference.PreferenceManager
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
@@ -218,6 +219,18 @@ class NotificationsSettingsFragment : DSLSettingsFragment(R.string.preferences__
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__notification_profiles)
|
||||
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.NotificationsSettingsFragment__profiles),
|
||||
summary = DSLSettingsText.from(R.string.NotificationsSettingsFragment__set_up_notification_profiles),
|
||||
onClick = {
|
||||
findNavController().navigate(R.id.action_notificationsSettingsFragment_to_notificationProfilesFragment)
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.NotificationsSettingsFragment__notify_when)
|
||||
|
||||
switchPref(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
|
||||
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import org.signal.core.util.DimensionUnit
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.manual.models.NotificationProfileSelection
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
|
||||
/**
|
||||
* BottomSheetDialogFragment that allows a user to select a notification profile to manually enable/disable.
|
||||
*/
|
||||
class NotificationProfileSelectionFragment : DSLSettingsBottomSheetFragment() {
|
||||
|
||||
private val viewModel: NotificationProfileSelectionViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
NotificationProfileSelectionViewModel.Factory(NotificationProfilesRepository())
|
||||
}
|
||||
)
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
NotificationProfileSelection.register(adapter)
|
||||
|
||||
recyclerView.itemAnimator = null
|
||||
|
||||
viewModel.state.observe(viewLifecycleOwner) {
|
||||
adapter.submitList(getConfiguration(it).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(state: NotificationProfileSelectionState): DSLConfiguration {
|
||||
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(state.notificationProfiles)
|
||||
|
||||
return configure {
|
||||
|
||||
state.notificationProfiles.forEach { profile ->
|
||||
customPref(
|
||||
NotificationProfileSelection.Entry(
|
||||
isOn = profile == activeProfile,
|
||||
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else DSLSettingsText.from(R.string.NotificationProfileDetails__off),
|
||||
notificationProfile = profile,
|
||||
isExpanded = profile.id == state.expandedId,
|
||||
timeSlotB = state.timeSlotB,
|
||||
onRowClick = viewModel::toggleEnabled,
|
||||
onTimeSlotAClick = viewModel::enableForOneHour,
|
||||
onTimeSlotBClick = viewModel::enableUntil,
|
||||
onToggleClick = viewModel::setExpanded,
|
||||
onViewSettingsClick = { navigateToSettings(it) }
|
||||
)
|
||||
)
|
||||
space(DimensionUnit.DP.toPixels(16f).toInt())
|
||||
}
|
||||
|
||||
customPref(
|
||||
NotificationProfileSelection.New(
|
||||
onClick = {
|
||||
startActivity(AppSettingsActivity.createNotificationProfile(requireContext()))
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
space(DimensionUnit.DP.toPixels(20f).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
private fun navigateToSettings(notificationProfile: NotificationProfile) {
|
||||
startActivity(AppSettingsActivity.notificationProfileDetails(requireContext(), notificationProfile.id))
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
NotificationProfileSelectionFragment().show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
|
||||
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import java.util.Calendar
|
||||
|
||||
data class NotificationProfileSelectionState(
|
||||
val notificationProfiles: List<NotificationProfile> = listOf(),
|
||||
val expandedId: Long = -1L,
|
||||
val timeSlotB: Calendar
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.manual
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.NotificationProfilesRepository
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.util.livedata.Store
|
||||
import java.util.Calendar
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class NotificationProfileSelectionViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
|
||||
|
||||
private val store = Store(NotificationProfileSelectionState(timeSlotB = getTimeSlotB()))
|
||||
|
||||
val state: LiveData<NotificationProfileSelectionState> = store.stateLiveData
|
||||
|
||||
val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
disposables += repository.getProfiles().subscribeBy(onNext = { profiles -> store.update { it.copy(notificationProfiles = profiles) } })
|
||||
|
||||
disposables += Observable
|
||||
.interval(0, 1, TimeUnit.MINUTES)
|
||||
.map { getTimeSlotB() }
|
||||
.distinctUntilChanged()
|
||||
.subscribe { calendar ->
|
||||
store.update { it.copy(timeSlotB = calendar) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun setExpanded(notificationProfile: NotificationProfile) {
|
||||
store.update {
|
||||
it.copy(expandedId = if (it.expandedId == notificationProfile.id) -1L else notificationProfile.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleEnabled(profile: NotificationProfile) {
|
||||
disposables += repository.manuallyToggleProfile(profile)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
fun enableForOneHour(profile: NotificationProfile) {
|
||||
disposables += repository.manuallyEnableProfileForDuration(profile.id, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
fun enableUntil(profile: NotificationProfile, calendar: Calendar) {
|
||||
disposables += repository.manuallyEnableProfileForDuration(profile.id, calendar.timeInMillis)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private fun getTimeSlotB(): Calendar {
|
||||
val now = Calendar.getInstance()
|
||||
val sixPm = Calendar.getInstance()
|
||||
val eightAm = Calendar.getInstance()
|
||||
|
||||
sixPm.set(Calendar.HOUR_OF_DAY, 18)
|
||||
sixPm.set(Calendar.MINUTE, 0)
|
||||
sixPm.set(Calendar.SECOND, 0)
|
||||
|
||||
eightAm.set(Calendar.HOUR_OF_DAY, 8)
|
||||
eightAm.set(Calendar.MINUTE, 0)
|
||||
eightAm.set(Calendar.SECOND, 0)
|
||||
|
||||
return if (now.before(sixPm) && (now.after(eightAm) || now == eightAm)) {
|
||||
sixPm
|
||||
} else {
|
||||
eightAm
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Factory(private val notificationProfilesRepository: NotificationProfilesRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(NotificationProfileSelectionViewModel(notificationProfilesRepository))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.manual.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiImageView
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Notification Profile selection preference.
|
||||
*/
|
||||
object NotificationProfileSelection {
|
||||
|
||||
private const val TOGGLE_EXPANSION = 0
|
||||
private const val UPDATE_TIMESLOT = 1
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(New::class.java, MappingAdapter.LayoutFactory(::NewViewHolder, R.layout.new_notification_profile_pref))
|
||||
adapter.registerFactory(Entry::class.java, MappingAdapter.LayoutFactory(::EntryViewHolder, R.layout.notification_profile_entry_pref))
|
||||
}
|
||||
|
||||
class Entry(
|
||||
val isOn: Boolean,
|
||||
override val summary: DSLSettingsText,
|
||||
val notificationProfile: NotificationProfile,
|
||||
val isExpanded: Boolean,
|
||||
val timeSlotB: Calendar,
|
||||
val onRowClick: (NotificationProfile) -> Unit,
|
||||
val onTimeSlotAClick: (NotificationProfile) -> Unit,
|
||||
val onTimeSlotBClick: (NotificationProfile, Calendar) -> Unit,
|
||||
val onViewSettingsClick: (NotificationProfile) -> Unit,
|
||||
val onToggleClick: (NotificationProfile) -> Unit
|
||||
) : PreferenceModel<Entry>() {
|
||||
|
||||
override fun areItemsTheSame(newItem: Entry): Boolean {
|
||||
return notificationProfile.id == newItem.notificationProfile.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Entry): Boolean {
|
||||
return super.areContentsTheSame(newItem) &&
|
||||
isOn == newItem.isOn &&
|
||||
notificationProfile == newItem.notificationProfile &&
|
||||
isExpanded == newItem.isExpanded &&
|
||||
timeSlotB == newItem.timeSlotB
|
||||
}
|
||||
|
||||
override fun getChangePayload(newItem: Entry): Any? {
|
||||
return if (notificationProfile == newItem.notificationProfile && isExpanded != newItem.isExpanded) {
|
||||
TOGGLE_EXPANSION
|
||||
} else if (notificationProfile == newItem.notificationProfile && timeSlotB != newItem.timeSlotB) {
|
||||
UPDATE_TIMESLOT
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EntryViewHolder(itemView: View) : MappingViewHolder<Entry>(itemView) {
|
||||
|
||||
private val image: EmojiImageView = findViewById(R.id.notification_preference_image)
|
||||
private val chevron: View = findViewById(R.id.notification_preference_chevron)
|
||||
private val name: TextView = findViewById(R.id.notification_preference_name)
|
||||
private val status: TextView = findViewById(R.id.notification_preference_status)
|
||||
private val expansion: Group = findViewById(R.id.notification_preference_expanded)
|
||||
private val timeSlotA: TextView = findViewById(R.id.notification_preference_1hr)
|
||||
private val timeSlotB: TextView = findViewById(R.id.notification_preference_6pm)
|
||||
private val viewSettings: View = findViewById(R.id.notification_preference_view_settings)
|
||||
|
||||
override fun bind(model: Entry) {
|
||||
itemView.setOnClickListener { model.onRowClick(model.notificationProfile) }
|
||||
chevron.setOnClickListener { model.onToggleClick(model.notificationProfile) }
|
||||
chevron.rotation = if (model.isExpanded) 180f else 0f
|
||||
timeSlotA.setOnClickListener { model.onTimeSlotAClick(model.notificationProfile) }
|
||||
timeSlotB.setOnClickListener { model.onTimeSlotBClick(model.notificationProfile, model.timeSlotB) }
|
||||
viewSettings.setOnClickListener { model.onViewSettingsClick(model.notificationProfile) }
|
||||
|
||||
expansion.visible = model.isExpanded
|
||||
timeSlotB.text = context.getString(
|
||||
R.string.NotificationProfileSelection__until_s,
|
||||
DateUtils.getTimeString(context, Locale.getDefault(), model.timeSlotB.timeInMillis)
|
||||
)
|
||||
|
||||
if (TOGGLE_EXPANSION in payload || UPDATE_TIMESLOT in payload) {
|
||||
return
|
||||
}
|
||||
|
||||
image.background.colorFilter = SimpleColorFilter(model.notificationProfile.color.colorInt())
|
||||
if (model.notificationProfile.emoji.isNotEmpty()) {
|
||||
image.setImageEmoji(model.notificationProfile.emoji)
|
||||
} else {
|
||||
image.setImageResource(R.drawable.ic_moon_24)
|
||||
}
|
||||
|
||||
name.text = model.notificationProfile.name
|
||||
|
||||
presentStatus(model)
|
||||
|
||||
timeSlotB.text = context.getString(
|
||||
R.string.NotificationProfileSelection__until_s,
|
||||
DateUtils.getTimeString(context, Locale.getDefault(), model.timeSlotB.timeInMillis)
|
||||
)
|
||||
|
||||
itemView.isSelected = model.isOn
|
||||
}
|
||||
|
||||
private fun presentStatus(model: Entry) {
|
||||
status.isEnabled = model.isOn
|
||||
status.text = model.summary.resolve(context)
|
||||
}
|
||||
}
|
||||
|
||||
class New(val onClick: () -> Unit) : PreferenceModel<New>() {
|
||||
override fun areItemsTheSame(newItem: New): Boolean {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
class NewViewHolder(itemView: View) : MappingViewHolder<New>(itemView) {
|
||||
override fun bind(model: New) {
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.dd.CircularProgressButton
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Show and allow addition of recipients to a profile during the create flow.
|
||||
*/
|
||||
class AddAllowedMembersFragment : DSLSettingsFragment(layoutId = R.layout.fragment_add_allowed_members) {
|
||||
|
||||
private val viewModel: AddAllowedMembersViewModel by viewModels(factoryProducer = { AddAllowedMembersViewModel.Factory(profileId) })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val profileId: Long by lazy { AddAllowedMembersFragmentArgs.fromBundle(requireArguments()).profileId }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
view.findViewById<CircularProgressButton>(R.id.add_allowed_members_profile_next).apply {
|
||||
setOnClickListener {
|
||||
findNavController().navigate(AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToEditNotificationProfileScheduleFragment(profileId, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
NotificationProfileAddMembers.register(adapter)
|
||||
NotificationProfileRecipient.register(adapter)
|
||||
|
||||
lifecycleDisposable += viewModel.getProfile()
|
||||
.subscribeBy(
|
||||
onNext = { (profile, recipients) ->
|
||||
adapter.submitList(getConfiguration(profile, recipients).toMappingModelList())
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getConfiguration(profile: NotificationProfile, recipients: List<Recipient>): DSLConfiguration {
|
||||
return configure {
|
||||
sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications)
|
||||
|
||||
customPref(
|
||||
NotificationProfileAddMembers.Model(
|
||||
onClick = { id, currentSelection ->
|
||||
findNavController().navigate(
|
||||
AddAllowedMembersFragmentDirections.actionAddAllowedMembersFragmentToSelectRecipientsFragment(id)
|
||||
.setCurrentSelection(currentSelection.toTypedArray())
|
||||
)
|
||||
},
|
||||
profileId = profile.id,
|
||||
currentSelection = profile.allowedMembers
|
||||
)
|
||||
)
|
||||
|
||||
for (member in recipients) {
|
||||
customPref(
|
||||
NotificationProfileRecipient.Model(
|
||||
recipientModel = RecipientPreference.Model(
|
||||
recipient = member,
|
||||
onClick = {}
|
||||
),
|
||||
onRemoveClick = { id ->
|
||||
lifecycleDisposable += viewModel.removeMember(id)
|
||||
.subscribeBy(
|
||||
onSuccess = { removed ->
|
||||
view?.let { view ->
|
||||
Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) }
|
||||
.setActionTextColor(ContextCompat.getColor(requireContext(), R.color.core_ultramarine_light))
|
||||
.setTextColor(Color.WHITE)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun undoRemove(id: RecipientId) {
|
||||
lifecycleDisposable += viewModel.addMember(id)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class AddAllowedMembersViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
|
||||
|
||||
fun getProfile(): Observable<NotificationProfileAndRecipients> {
|
||||
return repository.getProfile(profileId)
|
||||
.map { profile ->
|
||||
NotificationProfileAndRecipients(profile, profile.allowedMembers.map { Recipient.resolved(it) })
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun addMember(id: RecipientId): Single<NotificationProfile> {
|
||||
return repository.addMember(profileId, id)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun removeMember(id: RecipientId): Single<Recipient> {
|
||||
return repository.removeMember(profileId, id)
|
||||
.map { Recipient.resolved(id) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
data class NotificationProfileAndRecipients(val profile: NotificationProfile, val recipients: List<Recipient>)
|
||||
|
||||
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(AddAllowedMembersViewModel(profileId, NotificationProfilesRepository()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextUtils
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.dd.CircularProgressButton
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.signal.core.util.BreakIteratorCompat
|
||||
import org.signal.core.util.EditTextUtil
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileViewModel.SaveNotificationProfileResult
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileNamePreset
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.text.AfterTextChanged
|
||||
|
||||
/**
|
||||
* Dual use Edit/Create notification profile fragment. Use to create in the create profile flow,
|
||||
* and then to edit from profile details. Responsible for naming and emoji.
|
||||
*/
|
||||
class EditNotificationProfileFragment : DSLSettingsFragment(layoutId = R.layout.fragment_edit_notification_profile), ReactWithAnyEmojiBottomSheetDialogFragment.Callback {
|
||||
|
||||
private val viewModel: EditNotificationProfileViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private var emojiView: ImageView? = null
|
||||
private var nameView: EditText? = null
|
||||
|
||||
private fun createFactory(): ViewModelProvider.Factory {
|
||||
val profileId = EditNotificationProfileFragmentArgs.fromBundle(requireArguments()).profileId
|
||||
return EditNotificationProfileViewModel.Factory(profileId)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.NOTIFICATION_PROFILES)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
requireActivity().onBackPressed()
|
||||
}
|
||||
|
||||
val title: TextView = view.findViewById(R.id.edit_notification_profile_title)
|
||||
val countView: TextView = view.findViewById(R.id.edit_notification_profile_count)
|
||||
val saveButton: CircularProgressButton = view.findViewById(R.id.edit_notification_profile_save)
|
||||
val emojiView: ImageView = view.findViewById(R.id.edit_notification_profile_emoji)
|
||||
val nameView: EditText = view.findViewById(R.id.edit_notification_profile_name)
|
||||
val nameTextWrapper: TextInputLayout = view.findViewById(R.id.edit_notification_profile_name_wrapper)
|
||||
|
||||
EditTextUtil.addGraphemeClusterLimitFilter(nameView, NOTIFICATION_PROFILE_NAME_MAX_GLYPHS)
|
||||
nameView.addTextChangedListener(
|
||||
AfterTextChanged { editable: Editable ->
|
||||
presentCount(countView, editable.toString())
|
||||
nameTextWrapper.error = null
|
||||
}
|
||||
)
|
||||
|
||||
emojiView.setOnClickListener {
|
||||
ReactWithAnyEmojiBottomSheetDialogFragment.createForAboutSelection()
|
||||
.show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.edit_notification_profile_clear).setOnClickListener {
|
||||
nameView.setText("")
|
||||
onEmojiSelectedInternal("")
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
saveButton.setOnClickListener {
|
||||
if (TextUtils.isEmpty(nameView.text)) {
|
||||
nameTextWrapper.error = getString(R.string.EditNotificationProfileFragment__profile_must_have_a_name)
|
||||
return@setOnClickListener
|
||||
}
|
||||
|
||||
lifecycleDisposable += viewModel.save(nameView.text.toString())
|
||||
.doOnSubscribe { CircularProgressButtonUtil.setSpinning(saveButton) }
|
||||
.doAfterTerminate { CircularProgressButtonUtil.cancelSpinning(saveButton) }
|
||||
.subscribeBy(
|
||||
onSuccess = { saveResult ->
|
||||
when (saveResult) {
|
||||
is SaveNotificationProfileResult.Success -> {
|
||||
ViewUtil.hideKeyboard(requireContext(), nameView)
|
||||
if (saveResult.createMode) {
|
||||
findNavController().navigate(EditNotificationProfileFragmentDirections.actionEditNotificationProfileFragmentToAddAllowedMembersFragment(saveResult.profile.id))
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
SaveNotificationProfileResult.DuplicateNameFailure -> {
|
||||
nameTextWrapper.error = getString(R.string.EditNotificationProfileFragment__a_profile_with_this_name_already_exists)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
lifecycleDisposable += viewModel.getInitialState()
|
||||
.subscribeBy(
|
||||
onSuccess = { initial ->
|
||||
if (initial.createMode) {
|
||||
saveButton.text = getString(R.string.EditNotificationProfileFragment__next)
|
||||
title.setText(R.string.EditNotificationProfileFragment__name_your_profile)
|
||||
} else {
|
||||
saveButton.text = getString(R.string.EditNotificationProfileFragment__save)
|
||||
title.setText(R.string.EditNotificationProfileFragment__edit_this_profile)
|
||||
}
|
||||
nameView.setText(initial.name)
|
||||
onEmojiSelectedInternal(initial.emoji)
|
||||
|
||||
ViewUtil.focusAndMoveCursorToEndAndOpenKeyboard(nameView)
|
||||
}
|
||||
)
|
||||
|
||||
this.nameView = nameView
|
||||
this.emojiView = emojiView
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
NotificationProfileNamePreset.register(adapter)
|
||||
|
||||
val onClick = { preset: NotificationProfileNamePreset.Model ->
|
||||
nameView?.apply {
|
||||
setText(preset.bodyResource)
|
||||
setSelection(length(), length())
|
||||
}
|
||||
onEmojiSelectedInternal(preset.emoji)
|
||||
}
|
||||
|
||||
adapter.submitList(
|
||||
listOf(
|
||||
NotificationProfileNamePreset.Model("\uD83D\uDCAA", R.string.EditNotificationProfileFragment__work, onClick),
|
||||
NotificationProfileNamePreset.Model("\uD83D\uDE34", R.string.EditNotificationProfileFragment__sleep, onClick),
|
||||
NotificationProfileNamePreset.Model("\uD83D\uDE97", R.string.EditNotificationProfileFragment__driving, onClick),
|
||||
NotificationProfileNamePreset.Model("\uD83D\uDE0A", R.string.EditNotificationProfileFragment__downtime, onClick),
|
||||
NotificationProfileNamePreset.Model("\uD83D\uDCA1", R.string.EditNotificationProfileFragment__focus, onClick),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onReactWithAnyEmojiSelected(emoji: String) {
|
||||
onEmojiSelectedInternal(emoji)
|
||||
}
|
||||
|
||||
override fun onReactWithAnyEmojiDialogDismissed() = Unit
|
||||
|
||||
private fun presentCount(countView: TextView, profileName: String) {
|
||||
val breakIterator = BreakIteratorCompat.getInstance()
|
||||
breakIterator.setText(profileName)
|
||||
|
||||
val glyphCount = breakIterator.countBreaks()
|
||||
if (glyphCount >= NOTIFICATION_PROFILE_NAME_LIMIT_DISPLAY_THRESHOLD) {
|
||||
countView.visibility = View.VISIBLE
|
||||
countView.text = resources.getString(R.string.EditNotificationProfileFragment__count, glyphCount, NOTIFICATION_PROFILE_NAME_MAX_GLYPHS)
|
||||
} else {
|
||||
countView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEmojiSelectedInternal(emoji: String) {
|
||||
val drawable = EmojiUtil.convertToDrawable(requireContext(), emoji)
|
||||
if (drawable != null) {
|
||||
emojiView?.setImageDrawable(drawable)
|
||||
viewModel.onEmojiSelected(emoji)
|
||||
} else {
|
||||
emojiView?.setImageResource(R.drawable.ic_add_emoji)
|
||||
viewModel.onEmojiSelected("")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_PROFILE_NAME_MAX_GLYPHS = 32
|
||||
private const val NOTIFICATION_PROFILE_NAME_LIMIT_DISPLAY_THRESHOLD = 22
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.Spannable
|
||||
import android.text.SpannableString
|
||||
import android.text.format.DateFormat
|
||||
import android.text.style.AbsoluteSizeSpan
|
||||
import android.view.View
|
||||
import android.widget.CheckedTextView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.dd.CircularProgressButton
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.EditNotificationProfileScheduleViewModel.SaveScheduleResult
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import java.time.DayOfWeek
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Can edit existing or use during create flow to setup a profile schedule.
|
||||
*/
|
||||
class EditNotificationProfileScheduleFragment : LoggingFragment(R.layout.fragment_edit_notification_profile_schedule) {
|
||||
|
||||
private val viewModel: EditNotificationProfileScheduleViewModel by viewModels(factoryProducer = { EditNotificationProfileScheduleViewModel.Factory(profileId) })
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private val profileId: Long by lazy { EditNotificationProfileScheduleFragmentArgs.fromBundle(requireArguments()).profileId }
|
||||
private val createMode: Boolean by lazy { EditNotificationProfileScheduleFragmentArgs.fromBundle(requireArguments()).createMode }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
val title: View = view.findViewById(R.id.edit_notification_profile_schedule_title)
|
||||
val description: View = view.findViewById(R.id.edit_notification_profile_schedule_description)
|
||||
|
||||
toolbar.title = if (!createMode) getString(R.string.EditNotificationProfileSchedule__schedule) else null
|
||||
title.visible = createMode
|
||||
description.visible = createMode
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
val enableToggle: SwitchMaterial = view.findViewById(R.id.edit_notification_profile_schedule_switch)
|
||||
enableToggle.setOnClickListener { viewModel.setEnabled(enableToggle.isChecked) }
|
||||
|
||||
val startTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_start_time)
|
||||
val endTime: TextView = view.findViewById(R.id.edit_notification_profile_schedule_end_time)
|
||||
|
||||
val next: CircularProgressButton = view.findViewById(R.id.edit_notification_profile_schedule__next)
|
||||
next.setOnClickListener {
|
||||
lifecycleDisposable += viewModel.save(createMode)
|
||||
.subscribeBy(
|
||||
onSuccess = { result ->
|
||||
when (result) {
|
||||
SaveScheduleResult.Success -> {
|
||||
if (createMode) {
|
||||
findNavController().navigate(EditNotificationProfileScheduleFragmentDirections.actionEditNotificationProfileScheduleFragmentToNotificationProfileCreatedFragment(profileId))
|
||||
} else {
|
||||
findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
SaveScheduleResult.NoDaysSelected -> {
|
||||
Toast.makeText(requireContext(), R.string.EditNotificationProfileSchedule__schedule_must_have_at_least_one_day, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val sunday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_sunday)
|
||||
val monday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_monday)
|
||||
val tuesday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_tuesday)
|
||||
val wednesday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_wednesday)
|
||||
val thursday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_thursday)
|
||||
val friday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_friday)
|
||||
val saturday: CheckedTextView = view.findViewById(R.id.edit_notification_profile_schedule_saturday)
|
||||
|
||||
val days: Map<CheckedTextView, DayOfWeek> = mapOf(
|
||||
sunday to DayOfWeek.SUNDAY,
|
||||
monday to DayOfWeek.MONDAY,
|
||||
tuesday to DayOfWeek.TUESDAY,
|
||||
wednesday to DayOfWeek.WEDNESDAY,
|
||||
thursday to DayOfWeek.THURSDAY,
|
||||
friday to DayOfWeek.FRIDAY,
|
||||
saturday to DayOfWeek.SATURDAY
|
||||
)
|
||||
|
||||
days.forEach { (view, day) ->
|
||||
DrawableCompat.setTintList(view.background, ContextCompat.getColorStateList(requireContext(), R.color.notification_profile_schedule_background_tint))
|
||||
view.setOnClickListener { viewModel.toggleDay(day) }
|
||||
}
|
||||
|
||||
lifecycleDisposable += viewModel.schedule()
|
||||
.subscribeBy(
|
||||
onNext = { schedule ->
|
||||
enableToggle.isChecked = schedule.enabled
|
||||
enableToggle.isEnabled = true
|
||||
|
||||
days.forEach { (view, day) ->
|
||||
view.isChecked = schedule.daysEnabled.contains(day)
|
||||
view.isEnabled = schedule.enabled
|
||||
}
|
||||
|
||||
startTime.text = schedule.startTime().formatTime()
|
||||
startTime.setOnClickListener { showTimeSelector(true, schedule.startTime()) }
|
||||
startTime.isEnabled = schedule.enabled
|
||||
|
||||
endTime.text = schedule.endTime().formatTime()
|
||||
endTime.setOnClickListener { showTimeSelector(false, schedule.endTime()) }
|
||||
endTime.isEnabled = schedule.enabled
|
||||
|
||||
if (createMode) {
|
||||
next.setText(if (schedule.enabled) R.string.EditNotificationProfileSchedule__next else R.string.EditNotificationProfileSchedule__skip)
|
||||
} else {
|
||||
next.setText(R.string.EditNotificationProfileSchedule__save)
|
||||
}
|
||||
next.isEnabled = true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun showTimeSelector(isStart: Boolean, time: LocalTime) {
|
||||
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
|
||||
val timePickerFragment = MaterialTimePicker.Builder()
|
||||
.setTimeFormat(timeFormat)
|
||||
.setHour(time.hour)
|
||||
.setMinute(time.minute)
|
||||
.setTitleText(if (isStart) R.string.EditNotificationProfileSchedule__set_start_time else R.string.EditNotificationProfileSchedule__set_end_time)
|
||||
.build()
|
||||
|
||||
timePickerFragment.addOnDismissListener {
|
||||
timePickerFragment.clearOnDismissListeners()
|
||||
timePickerFragment.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
timePickerFragment.addOnPositiveButtonClickListener {
|
||||
val hour = timePickerFragment.hour
|
||||
val minute = timePickerFragment.minute
|
||||
|
||||
if (isStart) {
|
||||
viewModel.setStartTime(hour, minute)
|
||||
} else {
|
||||
viewModel.setEndTime(hour, minute)
|
||||
}
|
||||
}
|
||||
|
||||
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
|
||||
}
|
||||
}
|
||||
|
||||
private fun LocalTime.formatTime(): SpannableString {
|
||||
val amPm = DateTimeFormatter.ofPattern("a")
|
||||
.format(this)
|
||||
|
||||
val formattedTime: String = this.formatHours()
|
||||
|
||||
return SpannableString(formattedTime).apply {
|
||||
val amPmIndex = formattedTime.indexOf(string = amPm, ignoreCase = true)
|
||||
if (amPmIndex != -1) {
|
||||
setSpan(AbsoluteSizeSpan(ViewUtil.spToPx(20f)), amPmIndex, amPmIndex + amPm.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.kotlin.plusAssign
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import java.time.DayOfWeek
|
||||
|
||||
/**
|
||||
* ViewModel for driving edit schedule UI. UI starts in a disabled state until the first schedule is loaded
|
||||
* from the database and into the [scheduleSubject] allowing the safe use of !! with [schedule].
|
||||
*/
|
||||
class EditNotificationProfileScheduleViewModel(
|
||||
profileId: Long,
|
||||
private val repository: NotificationProfilesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
private val scheduleSubject: BehaviorSubject<NotificationProfileSchedule> = BehaviorSubject.create()
|
||||
private val schedule: NotificationProfileSchedule
|
||||
get() = scheduleSubject.value!!
|
||||
|
||||
init {
|
||||
disposables += repository.getProfile(profileId)
|
||||
.take(1)
|
||||
.map { it.schedule }
|
||||
.singleOrError()
|
||||
.subscribeBy(onSuccess = { scheduleSubject.onNext(it) })
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
disposables.dispose()
|
||||
}
|
||||
|
||||
fun schedule(): Observable<NotificationProfileSchedule> {
|
||||
return scheduleSubject.subscribeOn(AndroidSchedulers.mainThread())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun toggleDay(day: DayOfWeek) {
|
||||
val newDaysEnabled = schedule.daysEnabled.toMutableSet()
|
||||
if (newDaysEnabled.contains(day)) {
|
||||
newDaysEnabled.remove(day)
|
||||
} else {
|
||||
newDaysEnabled += day
|
||||
}
|
||||
scheduleSubject.onNext(schedule.copy(daysEnabled = newDaysEnabled))
|
||||
}
|
||||
|
||||
fun setStartTime(hour: Int, minute: Int) {
|
||||
scheduleSubject.onNext(schedule.copy(start = hour * 100 + minute))
|
||||
}
|
||||
|
||||
fun setEndTime(hour: Int, minute: Int) {
|
||||
scheduleSubject.onNext(schedule.copy(end = hour * 100 + minute))
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
scheduleSubject.onNext(schedule.copy(enabled = enabled))
|
||||
}
|
||||
|
||||
fun save(createMode: Boolean): Single<SaveScheduleResult> {
|
||||
val result: Single<SaveScheduleResult> = if (schedule.enabled && schedule.daysEnabled.isEmpty()) {
|
||||
Single.just(SaveScheduleResult.NoDaysSelected)
|
||||
} else if (createMode && !schedule.enabled) {
|
||||
Single.just(SaveScheduleResult.Success)
|
||||
} else {
|
||||
repository.updateSchedule(schedule).toSingle { SaveScheduleResult.Success }
|
||||
}
|
||||
return result.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(EditNotificationProfileScheduleViewModel(profileId, NotificationProfilesRepository()))!!
|
||||
}
|
||||
}
|
||||
|
||||
enum class SaveScheduleResult {
|
||||
NoDaysSelected,
|
||||
Success
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
|
||||
class EditNotificationProfileViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
|
||||
|
||||
private val createMode: Boolean = profileId == -1L
|
||||
private var selectedEmoji: String = ""
|
||||
|
||||
fun getInitialState(): Single<InitialState> {
|
||||
val initialState = if (createMode) {
|
||||
Single.just(InitialState(createMode))
|
||||
} else {
|
||||
repository.getProfile(profileId)
|
||||
.take(1)
|
||||
.map { InitialState(createMode, it.name, it.emoji) }
|
||||
.singleOrError()
|
||||
}
|
||||
|
||||
return initialState.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun onEmojiSelected(emoji: String) {
|
||||
selectedEmoji = emoji
|
||||
}
|
||||
|
||||
fun save(name: String): Single<SaveNotificationProfileResult> {
|
||||
val save = if (createMode) repository.createProfile(name, selectedEmoji) else repository.updateProfile(profileId, name, selectedEmoji)
|
||||
|
||||
return save.map { r ->
|
||||
when (r) {
|
||||
is NotificationProfileDatabase.NotificationProfileChangeResult.Success -> SaveNotificationProfileResult.Success(r.notificationProfile, createMode)
|
||||
NotificationProfileDatabase.NotificationProfileChangeResult.DuplicateName -> SaveNotificationProfileResult.DuplicateNameFailure
|
||||
}
|
||||
}.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(EditNotificationProfileViewModel(profileId, NotificationProfilesRepository()))!!
|
||||
}
|
||||
}
|
||||
|
||||
data class InitialState(
|
||||
val createMode: Boolean,
|
||||
val name: String = "",
|
||||
val emoji: String = ""
|
||||
)
|
||||
|
||||
sealed class SaveNotificationProfileResult {
|
||||
data class Success(val profile: NotificationProfile, val createMode: Boolean) : SaveNotificationProfileResult()
|
||||
object DuplicateNameFailure : SaveNotificationProfileResult()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Shown at the end of the profile create flow.
|
||||
*/
|
||||
class NotificationProfileCreatedFragment : LoggingFragment(R.layout.fragment_notification_profile_created) {
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val profileId: Long by lazy { NotificationProfileCreatedFragmentArgs.fromBundle(requireArguments()).profileId }
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
val topIcon: ImageView = view.findViewById(R.id.notification_profile_created_top_image)
|
||||
val topText: TextView = view.findViewById(R.id.notification_profile_created_top_text)
|
||||
val bottomIcon: ImageView = view.findViewById(R.id.notification_profile_created_bottom_image)
|
||||
val bottomText: TextView = view.findViewById(R.id.notification_profile_created_bottom_text)
|
||||
|
||||
view.findViewById<View>(R.id.notification_profile_created_done).setOnClickListener {
|
||||
findNavController().navigate(NotificationProfileCreatedFragmentDirections.actionNotificationProfileCreatedFragmentToNotificationProfileDetailsFragment(profileId))
|
||||
}
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
val repository = NotificationProfilesRepository()
|
||||
lifecycleDisposable += repository.getProfile(profileId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy(
|
||||
onNext = { profile ->
|
||||
if (profile.schedule.enabled) {
|
||||
topIcon.setImageResource(R.drawable.ic_recent_20)
|
||||
topText.setText(R.string.NotificationProfileCreated__your_profile_will_turn_on_and_off_automatically_according_to_your_schedule)
|
||||
|
||||
bottomIcon.setImageResource(R.drawable.ic_more_vert_24)
|
||||
bottomText.setText(R.string.NotificationProfileCreated__you_can_turn_your_profile_on_or_off_manually_via_the_menu_on_the_chat_list)
|
||||
} else {
|
||||
topIcon.setImageResource(R.drawable.ic_more_vert_24)
|
||||
topText.setText(R.string.NotificationProfileCreated__you_can_turn_your_profile_on_or_off_manually_via_the_menu_on_the_chat_list)
|
||||
|
||||
bottomIcon.setImageResource(R.drawable.ic_recent_20)
|
||||
bottomText.setText(R.string.NotificationProfileCreated__add_a_schedule_in_settings_to_automate_your_profile)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileAddMembers
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfileRecipient
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import java.time.DayOfWeek
|
||||
import java.time.format.TextStyle
|
||||
import java.util.Locale
|
||||
|
||||
private val DAY_ORDER: List<DayOfWeek> = listOf(DayOfWeek.SUNDAY, DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY)
|
||||
|
||||
class NotificationProfileDetailsFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: NotificationProfileDetailsViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private var toolbar: Toolbar? = null
|
||||
|
||||
private fun createFactory(): ViewModelProvider.Factory {
|
||||
return NotificationProfileDetailsViewModel.Factory(NotificationProfileDetailsFragmentArgs.fromBundle(requireArguments()).profileId)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar?.inflateMenu(R.menu.notification_profile_details)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
toolbar = null
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
NotificationProfilePreference.register(adapter)
|
||||
NotificationProfileAddMembers.register(adapter)
|
||||
NotificationProfileRecipient.register(adapter)
|
||||
|
||||
lifecycleDisposable += viewModel.getProfile()
|
||||
.subscribeBy(
|
||||
onNext = { state ->
|
||||
when (state) {
|
||||
is NotificationProfileDetailsViewModel.State.Valid -> {
|
||||
toolbar?.title = state.profile.name
|
||||
toolbar?.setOnMenuItemClickListener { item ->
|
||||
if (item.itemId == R.id.action_edit) {
|
||||
findNavController().navigate(NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToEditNotificationProfileFragment().setProfileId(state.profile.id))
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
adapter.submitList(getConfiguration(state.profile, state.recipients, state.isOn).toMappingModelList())
|
||||
}
|
||||
NotificationProfileDetailsViewModel.State.Invalid -> findNavController().navigateUp()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun getConfiguration(profile: NotificationProfile, recipients: List<Recipient>, isOn: Boolean): DSLConfiguration {
|
||||
return configure {
|
||||
|
||||
customPref(
|
||||
NotificationProfilePreference.Model(
|
||||
title = DSLSettingsText.from(profile.name),
|
||||
summary = if (isOn) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null,
|
||||
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT),
|
||||
color = profile.color,
|
||||
isOn = isOn,
|
||||
showSwitch = true,
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.toggleEnabled(profile)
|
||||
.subscribe()
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
|
||||
sectionHeaderPref(R.string.AddAllowedMembers__allowed_notifications)
|
||||
customPref(
|
||||
NotificationProfileAddMembers.Model(
|
||||
onClick = { id, currentSelection ->
|
||||
findNavController().navigate(
|
||||
NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToSelectRecipientsFragment(id)
|
||||
.setCurrentSelection(currentSelection.toTypedArray())
|
||||
)
|
||||
},
|
||||
profileId = profile.id,
|
||||
currentSelection = profile.allowedMembers
|
||||
)
|
||||
)
|
||||
for (member in recipients) {
|
||||
customPref(
|
||||
NotificationProfileRecipient.Model(
|
||||
recipientModel = RecipientPreference.Model(
|
||||
recipient = member
|
||||
),
|
||||
onRemoveClick = { id ->
|
||||
lifecycleDisposable += viewModel.removeMember(id)
|
||||
.subscribeBy(
|
||||
onSuccess = { removed ->
|
||||
view?.let { view ->
|
||||
Snackbar.make(view, getString(R.string.NotificationProfileDetails__s_removed, removed.getDisplayName(requireContext())), Snackbar.LENGTH_LONG)
|
||||
.setAction(R.string.NotificationProfileDetails__undo) { undoRemove(id) }
|
||||
.setActionTextColor(ContextCompat.getColor(requireContext(), R.color.core_ultramarine_light))
|
||||
.setTextColor(Color.WHITE)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dividerPref()
|
||||
sectionHeaderPref(R.string.NotificationProfileDetails__schedule)
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(profile.schedule.describe()),
|
||||
summary = DSLSettingsText.from(if (profile.schedule.enabled) R.string.NotificationProfileDetails__on else R.string.NotificationProfileDetails__off),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_recent_20, NO_TINT),
|
||||
onClick = {
|
||||
findNavController().navigate(NotificationProfileDetailsFragmentDirections.actionNotificationProfileDetailsFragmentToEditNotificationProfileScheduleFragment(profile.id, false))
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
sectionHeaderPref(R.string.NotificationProfileDetails__exceptions)
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.NotificationProfileDetails__allow_all_calls),
|
||||
isChecked = profile.allowAllCalls,
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_phone_right_24),
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.toggleAllowAllCalls()
|
||||
.subscribe()
|
||||
}
|
||||
)
|
||||
switchPref(
|
||||
title = DSLSettingsText.from(R.string.NotificationProfileDetails__notify_for_all_mentions),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_at_24),
|
||||
isChecked = profile.allowAllMentions,
|
||||
onClick = {
|
||||
lifecycleDisposable += viewModel.toggleAllowAllMentions()
|
||||
.subscribe()
|
||||
}
|
||||
)
|
||||
|
||||
dividerPref()
|
||||
clickPref(
|
||||
title = DSLSettingsText.from(R.string.NotificationProfileDetails__delete_profile, ContextCompat.getColor(requireContext(), R.color.signal_alert_primary)),
|
||||
icon = DSLSettingsIcon.from(R.drawable.ic_delete_24, R.color.signal_alert_primary),
|
||||
onClick = {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setMessage(R.string.NotificationProfileDetails__permanently_delete_profile)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(
|
||||
SpanUtil.color(
|
||||
ContextCompat.getColor(requireContext(), R.color.signal_alert_primary),
|
||||
getString(R.string.NotificationProfileDetails__delete)
|
||||
)
|
||||
) { _, _ -> deleteProfile() }
|
||||
.show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteProfile() {
|
||||
lifecycleDisposable += viewModel.deleteProfile()
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private fun undoRemove(id: RecipientId) {
|
||||
lifecycleDisposable += viewModel.addMember(id)
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private fun NotificationProfileSchedule.describe(): String {
|
||||
if (!enabled) {
|
||||
return getString(R.string.NotificationProfileDetails__schedule)
|
||||
}
|
||||
|
||||
val startTime = startTime().formatHours()
|
||||
val endTime = endTime().formatHours()
|
||||
|
||||
val days = StringBuilder()
|
||||
if (daysEnabled.size == 7) {
|
||||
days.append(getString(R.string.NotificationProfileDetails__everyday))
|
||||
} else {
|
||||
for (day in DAY_ORDER) {
|
||||
if (daysEnabled.contains(day)) {
|
||||
if (days.isNotEmpty()) {
|
||||
days.append(", ")
|
||||
}
|
||||
days.append(day.getDisplayName(TextStyle.SHORT, Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return getString(R.string.NotificationProfileDetails__s_to_s, startTime, endTime).let { hours ->
|
||||
if (days.isNotEmpty()) "$hours\n$days" else hours
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class NotificationProfileDetailsViewModel(private val profileId: Long, private val repository: NotificationProfilesRepository) : ViewModel() {
|
||||
|
||||
fun getProfile(): Observable<State> {
|
||||
return repository.getProfiles()
|
||||
.map { profiles ->
|
||||
val profile = profiles.firstOrNull { it.id == profileId }
|
||||
if (profile == null) {
|
||||
State.Invalid
|
||||
} else {
|
||||
State.Valid(
|
||||
profile = profile,
|
||||
recipients = profile.allowedMembers.map { Recipient.resolved(it) },
|
||||
isOn = NotificationProfiles.getActiveProfile(profiles) == profile
|
||||
)
|
||||
}
|
||||
}
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun addMember(id: RecipientId): Single<NotificationProfile> {
|
||||
return repository.addMember(profileId, id)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun removeMember(id: RecipientId): Single<Recipient> {
|
||||
return repository.removeMember(profileId, id)
|
||||
.map { Recipient.resolved(id) }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun deleteProfile(): Completable {
|
||||
return repository.deleteProfile(profileId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun toggleEnabled(profile: NotificationProfile): Completable {
|
||||
return repository.manuallyToggleProfile(profile)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun toggleAllowAllMentions(): Single<NotificationProfile> {
|
||||
return repository.getProfile(profileId)
|
||||
.take(1)
|
||||
.singleOrError()
|
||||
.flatMap { repository.updateProfile(it.copy(allowAllMentions = !it.allowAllMentions)) }
|
||||
.map { (it as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun toggleAllowAllCalls(): Single<NotificationProfile> {
|
||||
return repository.getProfile(profileId)
|
||||
.take(1)
|
||||
.singleOrError()
|
||||
.flatMap { repository.updateProfile(it.copy(allowAllCalls = !it.allowAllCalls)) }
|
||||
.map { (it as NotificationProfileDatabase.NotificationProfileChangeResult.Success).notificationProfile }
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
sealed class State {
|
||||
data class Valid(
|
||||
val profile: NotificationProfile,
|
||||
val recipients: List<Recipient>,
|
||||
val isOn: Boolean
|
||||
) : State()
|
||||
object Invalid : State()
|
||||
}
|
||||
|
||||
class Factory(private val profileId: Long) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(NotificationProfileDetailsViewModel(profileId, NotificationProfilesRepository()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NoNotificationProfiles
|
||||
import org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models.NotificationProfilePreference
|
||||
import org.thoughtcrime.securesms.components.settings.configure
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.LargeIconClickPreference
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.megaphone.Megaphones
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
|
||||
/**
|
||||
* Primary entry point for Notification Profiles. When user has no profiles, shows empty state, otherwise shows
|
||||
* all current profiles.
|
||||
*/
|
||||
class NotificationProfilesFragment : DSLSettingsFragment() {
|
||||
|
||||
private val viewModel: NotificationProfilesViewModel by viewModels(
|
||||
factoryProducer = { NotificationProfilesViewModel.Factory() }
|
||||
)
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private var toolbar: Toolbar? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
ApplicationDependencies.getMegaphoneRepository().markFinished(Megaphones.Event.NOTIFICATION_PROFILES)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
toolbar = view.findViewById(R.id.toolbar)
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
toolbar = null
|
||||
}
|
||||
|
||||
override fun bindAdapter(adapter: DSLSettingsAdapter) {
|
||||
NoNotificationProfiles.register(adapter)
|
||||
LargeIconClickPreference.register(adapter)
|
||||
NotificationProfilePreference.register(adapter)
|
||||
|
||||
lifecycleDisposable += viewModel.getProfiles()
|
||||
.subscribe { profiles ->
|
||||
if (profiles.isEmpty()) {
|
||||
toolbar?.title = ""
|
||||
} else {
|
||||
toolbar?.setTitle(R.string.NotificationsSettingsFragment__notification_profiles)
|
||||
}
|
||||
adapter.submitList(getConfiguration(profiles).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun getConfiguration(profiles: List<NotificationProfile>): DSLConfiguration {
|
||||
return configure {
|
||||
if (profiles.isEmpty()) {
|
||||
customPref(
|
||||
NoNotificationProfiles.Model(
|
||||
onClick = { findNavController().navigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) }
|
||||
)
|
||||
)
|
||||
} else {
|
||||
sectionHeaderPref(R.string.NotificationProfilesFragment__profiles)
|
||||
|
||||
customPref(
|
||||
LargeIconClickPreference.Model(
|
||||
title = DSLSettingsText.from(R.string.NotificationProfilesFragment__new_profile),
|
||||
icon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
|
||||
onClick = { findNavController().navigate(R.id.action_notificationProfilesFragment_to_editNotificationProfileFragment) }
|
||||
)
|
||||
)
|
||||
|
||||
val activeProfile: NotificationProfile? = NotificationProfiles.getActiveProfile(profiles)
|
||||
for (profile: NotificationProfile in profiles) {
|
||||
customPref(
|
||||
NotificationProfilePreference.Model(
|
||||
title = DSLSettingsText.from(profile.name),
|
||||
summary = if (profile == activeProfile) DSLSettingsText.from(NotificationProfiles.getActiveProfileDescription(requireContext(), profile)) else null,
|
||||
icon = if (profile.emoji.isNotEmpty()) EmojiUtil.convertToDrawable(requireContext(), profile.emoji)?.let { DSLSettingsIcon.from(it) } else DSLSettingsIcon.from(R.drawable.ic_moon_24, NO_TINT),
|
||||
color = profile.color,
|
||||
onClick = {
|
||||
findNavController().navigate(NotificationProfilesFragmentDirections.actionNotificationProfilesFragmentToNotificationProfileDetailsFragment(profile.id))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.ObservableEmitter
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.NotificationProfileDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfileSchedule
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfiles
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* One stop shop for all your Notification Profile data needs.
|
||||
*/
|
||||
class NotificationProfilesRepository {
|
||||
private val database: NotificationProfileDatabase = SignalDatabase.notificationProfiles
|
||||
|
||||
fun getProfiles(): Observable<List<NotificationProfile>> {
|
||||
return Observable.create { emitter: ObservableEmitter<List<NotificationProfile>> ->
|
||||
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
|
||||
val profileObserver = DatabaseObserver.Observer { emitter.onNext(database.getProfiles()) }
|
||||
|
||||
databaseObserver.registerNotificationProfileObserver(profileObserver)
|
||||
|
||||
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
|
||||
emitter.onNext(database.getProfiles())
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getProfile(profileId: Long): Observable<NotificationProfile> {
|
||||
return Observable.create { emitter: ObservableEmitter<NotificationProfile> ->
|
||||
val emitProfile: () -> Unit = {
|
||||
val profile: NotificationProfile? = database.getProfile(profileId)
|
||||
if (profile != null) {
|
||||
emitter.onNext(profile)
|
||||
} else {
|
||||
emitter.onError(NotificationProfileNotFoundException())
|
||||
}
|
||||
}
|
||||
|
||||
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
|
||||
val profileObserver = DatabaseObserver.Observer { emitProfile() }
|
||||
|
||||
databaseObserver.registerNotificationProfileObserver(profileObserver)
|
||||
|
||||
emitter.setCancellable { databaseObserver.unregisterObserver(profileObserver) }
|
||||
emitProfile()
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun createProfile(name: String, selectedEmoji: String): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
|
||||
return Single.fromCallable { database.createProfile(name = name, emoji = selectedEmoji, color = AvatarColor.random(), createdAt = System.currentTimeMillis()) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateProfile(profileId: Long, name: String, selectedEmoji: String): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
|
||||
return Single.fromCallable { database.updateProfile(profileId = profileId, name = name, emoji = selectedEmoji) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateProfile(profile: NotificationProfile): Single<NotificationProfileDatabase.NotificationProfileChangeResult> {
|
||||
return Single.fromCallable { database.updateProfile(profile) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateAllowedMembers(profileId: Long, recipients: Set<RecipientId>): Single<NotificationProfile> {
|
||||
return Single.fromCallable { database.setAllowedRecipients(profileId, recipients) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun removeMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
|
||||
return Single.fromCallable { database.removeAllowedRecipient(profileId, recipientId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun addMember(profileId: Long, recipientId: RecipientId): Single<NotificationProfile> {
|
||||
return Single.fromCallable { database.addAllowedRecipient(profileId, recipientId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun deleteProfile(profileId: Long): Completable {
|
||||
return Completable.fromCallable { database.deleteProfile(profileId) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun updateSchedule(schedule: NotificationProfileSchedule): Completable {
|
||||
return Completable.fromCallable { database.updateSchedule(schedule) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun manuallyToggleProfile(profile: NotificationProfile, now: Long = System.currentTimeMillis()): Completable {
|
||||
return Completable.fromAction {
|
||||
val profiles = database.getProfiles()
|
||||
val activeProfile = NotificationProfiles.getActiveProfile(profiles, now)
|
||||
|
||||
if (profile.id == activeProfile?.id) {
|
||||
SignalStore.notificationProfileValues().manuallyEnabledProfile = 0
|
||||
SignalStore.notificationProfileValues().manuallyEnabledUntil = 0
|
||||
SignalStore.notificationProfileValues().manuallyDisabledAt = now
|
||||
SignalStore.notificationProfileValues().lastProfilePopup = 0
|
||||
SignalStore.notificationProfileValues().lastProfilePopupTime = 0
|
||||
} else {
|
||||
val inScheduledWindow = profile.schedule.isCurrentlyActive(now)
|
||||
SignalStore.notificationProfileValues().manuallyEnabledProfile = if (inScheduledWindow) 0 else profile.id
|
||||
SignalStore.notificationProfileValues().manuallyEnabledUntil = if (inScheduledWindow) 0 else Long.MAX_VALUE
|
||||
SignalStore.notificationProfileValues().manuallyDisabledAt = if (inScheduledWindow) 0 else now
|
||||
}
|
||||
}
|
||||
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun manuallyEnableProfileForDuration(profileId: Long, enableUntil: Long, now: Long = System.currentTimeMillis()): Completable {
|
||||
return Completable.fromAction {
|
||||
SignalStore.notificationProfileValues().manuallyEnabledProfile = profileId
|
||||
SignalStore.notificationProfileValues().manuallyEnabledUntil = enableUntil
|
||||
SignalStore.notificationProfileValues().manuallyDisabledAt = now
|
||||
}
|
||||
.doOnComplete { ApplicationDependencies.getDatabaseObserver().notifyNotificationProfileObservers() }
|
||||
.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
class NotificationProfileNotFoundException : Throwable()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
|
||||
class NotificationProfilesViewModel(private val repository: NotificationProfilesRepository) : ViewModel() {
|
||||
|
||||
fun getProfiles(): Observable<List<NotificationProfile>> {
|
||||
return repository.getProfiles()
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory() : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(NotificationProfilesViewModel(NotificationProfilesRepository()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import com.dd.CircularProgressButton
|
||||
import io.reactivex.rxjava3.kotlin.subscribeBy
|
||||
import org.thoughtcrime.securesms.ContactSelectionListFragment
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.ContactFilterView
|
||||
import org.thoughtcrime.securesms.contacts.ContactsCursorLoader
|
||||
import org.thoughtcrime.securesms.groups.SelectionLimits
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.CircularProgressButtonUtil
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.whispersystems.libsignal.util.guava.Optional
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* Contact Selection for adding recipients to a Notification Profile.
|
||||
*/
|
||||
class SelectRecipientsFragment : LoggingFragment(), ContactSelectionListFragment.OnContactSelectedListener {
|
||||
|
||||
private val viewModel: SelectRecipientsViewModel by viewModels(factoryProducer = this::createFactory)
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
private var addToProfile: CircularProgressButton? = null
|
||||
|
||||
private fun createFactory(): ViewModelProvider.Factory {
|
||||
val args = SelectRecipientsFragmentArgs.fromBundle(requireArguments())
|
||||
return SelectRecipientsViewModel.Factory(args.profileId, args.currentSelection?.toSet() ?: emptySet())
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
val currentSelection: Array<RecipientId>? = SelectRecipientsFragmentArgs.fromBundle(requireArguments()).currentSelection
|
||||
val selectionList = ArrayList<RecipientId>()
|
||||
if (currentSelection != null) {
|
||||
selectionList.addAll(currentSelection)
|
||||
}
|
||||
|
||||
childFragmentManager.addFragmentOnAttachListener { _, fragment ->
|
||||
fragment.arguments = Bundle().apply {
|
||||
putInt(ContactSelectionListFragment.DISPLAY_MODE, getDefaultDisplayMode())
|
||||
putBoolean(ContactSelectionListFragment.REFRESHABLE, false)
|
||||
putBoolean(ContactSelectionListFragment.RECENTS, false)
|
||||
putParcelable(ContactSelectionListFragment.SELECTION_LIMITS, SelectionLimits.NO_LIMITS)
|
||||
putParcelableArrayList(ContactSelectionListFragment.CURRENT_SELECTION, selectionList)
|
||||
putBoolean(ContactSelectionListFragment.HIDE_COUNT, true)
|
||||
putBoolean(ContactSelectionListFragment.DISPLAY_CHIPS, false)
|
||||
putBoolean(ContactSelectionListFragment.CAN_SELECT_SELF, false)
|
||||
putBoolean(ContactSelectionListFragment.RV_CLIP, false)
|
||||
putInt(ContactSelectionListFragment.RV_PADDING_BOTTOM, ViewUtil.dpToPx(60))
|
||||
}
|
||||
}
|
||||
|
||||
return inflater.inflate(R.layout.fragment_select_recipients_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val toolbar: Toolbar = view.findViewById(R.id.toolbar)
|
||||
toolbar.setTitle(R.string.AddAllowedMembers__allowed_notifications)
|
||||
toolbar.setNavigationOnClickListener { findNavController().navigateUp() }
|
||||
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner.lifecycle)
|
||||
|
||||
val contactFilterView: ContactFilterView = view.findViewById(R.id.contact_filter_edit_text)
|
||||
|
||||
val selectionFragment: ContactSelectionListFragment = childFragmentManager.findFragmentById(R.id.contact_selection_list_fragment) as ContactSelectionListFragment
|
||||
|
||||
contactFilterView.setOnFilterChangedListener {
|
||||
if (it.isNullOrEmpty()) {
|
||||
selectionFragment.resetQueryFilter()
|
||||
} else {
|
||||
selectionFragment.setQueryFilter(it)
|
||||
}
|
||||
}
|
||||
|
||||
addToProfile = view.findViewById(R.id.select_recipients_add)
|
||||
addToProfile?.setOnClickListener {
|
||||
lifecycleDisposable += viewModel.updateAllowedMembers()
|
||||
.doOnSubscribe { CircularProgressButtonUtil.setSpinning(addToProfile) }
|
||||
.doOnTerminate { CircularProgressButtonUtil.cancelSpinning(addToProfile) }
|
||||
.subscribeBy(onSuccess = { findNavController().navigateUp() })
|
||||
}
|
||||
|
||||
updateAddToProfile()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
addToProfile = null
|
||||
}
|
||||
|
||||
private fun getDefaultDisplayMode(): Int {
|
||||
var mode = ContactsCursorLoader.DisplayMode.FLAG_PUSH or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_ACTIVE_GROUPS or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_HIDE_NEW or
|
||||
ContactsCursorLoader.DisplayMode.FLAG_HIDE_RECENT_HEADER
|
||||
|
||||
if (Util.isDefaultSmsProvider(requireContext())) {
|
||||
mode = mode or ContactsCursorLoader.DisplayMode.FLAG_SMS
|
||||
}
|
||||
|
||||
return mode or ContactsCursorLoader.DisplayMode.FLAG_HIDE_GROUPS_V1
|
||||
}
|
||||
|
||||
override fun onBeforeContactSelected(recipientId: Optional<RecipientId>, number: String, callback: Consumer<Boolean>) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.select(recipientId.get())
|
||||
callback.accept(true)
|
||||
updateAddToProfile()
|
||||
} else {
|
||||
callback.accept(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onContactDeselected(recipientId: Optional<RecipientId>, number: String) {
|
||||
if (recipientId.isPresent) {
|
||||
viewModel.deselect(recipientId.get())
|
||||
updateAddToProfile()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelectionChanged() = Unit
|
||||
|
||||
private fun updateAddToProfile() {
|
||||
val enabled = viewModel.recipients.isNotEmpty()
|
||||
addToProfile?.isEnabled = enabled
|
||||
addToProfile?.alpha = if (enabled) 1f else 0.5f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class SelectRecipientsViewModel(
|
||||
private val profileId: Long,
|
||||
currentSelection: Set<RecipientId>,
|
||||
private val repository: NotificationProfilesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
val recipients: MutableSet<RecipientId> = currentSelection.toMutableSet()
|
||||
|
||||
fun select(recipientId: RecipientId) {
|
||||
recipients += recipientId
|
||||
}
|
||||
|
||||
fun deselect(recipientId: RecipientId) {
|
||||
recipients.remove(recipientId)
|
||||
}
|
||||
|
||||
fun updateAllowedMembers(): Single<NotificationProfile> {
|
||||
return repository.updateAllowedMembers(profileId, recipients)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
class Factory(private val profileId: Long, val currentSelection: Set<RecipientId>) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(SelectRecipientsViewModel(profileId, currentSelection, NotificationProfilesRepository()))!!
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* DSL custom preference for showing no profiles/empty state.
|
||||
*/
|
||||
object NoNotificationProfiles {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.notification_profiles_empty))
|
||||
}
|
||||
|
||||
class Model(val onClick: () -> Unit) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean = true
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val icon: ImageView = findViewById(R.id.notification_profiles_empty_icon)
|
||||
private val button: View = findViewById(R.id.notification_profiles_empty_create_profile)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
icon.background.colorFilter = SimpleColorFilter(AvatarColor.A100.colorInt())
|
||||
button.setOnClickListener { model.onClick() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.NO_TINT
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
|
||||
/**
|
||||
* Custom DSL preference for adding members to a profile.
|
||||
*/
|
||||
object NotificationProfileAddMembers {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.large_icon_preference_item))
|
||||
}
|
||||
|
||||
class Model(
|
||||
override val title: DSLSettingsText = DSLSettingsText.from(R.string.AddAllowedMembers__add_people_or_groups),
|
||||
override val icon: DSLSettingsIcon = DSLSettingsIcon.from(R.drawable.add_to_a_group, NO_TINT),
|
||||
val onClick: (Long, Set<RecipientId>) -> Unit,
|
||||
val profileId: Long,
|
||||
val currentSelection: Set<RecipientId>
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && profileId == newItem.profileId && currentSelection == newItem.currentSelection
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
|
||||
override fun bind(model: Model) {
|
||||
super.bind(model)
|
||||
itemView.setOnClickListener { model.onClick(model.profileId, model.currentSelection) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.StringRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* DSL custom preference for showing default emoji/name combos for create/edit profile.
|
||||
*/
|
||||
object NotificationProfileNamePreset {
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.about_preset_item))
|
||||
}
|
||||
|
||||
class Model(val emoji: String, @StringRes val bodyResource: Int, val onClick: (Model) -> Unit) : MappingModel<Model> {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return bodyResource == newItem.bodyResource
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return areItemsTheSame(newItem) && emoji == newItem.emoji
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
val emoji: ImageView = findViewById(R.id.about_preset_emoji)
|
||||
val body: TextView = findViewById(R.id.about_preset_body)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener { model.onClick(model) }
|
||||
emoji.setImageDrawable(EmojiUtil.convertToDrawable(context, model.emoji))
|
||||
body.setText(model.bodyResource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
|
||||
|
||||
import android.view.View
|
||||
import com.airbnb.lottie.SimpleColorFilter
|
||||
import com.google.android.material.switchmaterial.SwitchMaterial
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsIcon
|
||||
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceViewHolder
|
||||
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* DSL custom preference for showing Notification Profile rows.
|
||||
*/
|
||||
object NotificationProfilePreference {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory(::ViewHolder, R.layout.notification_profile_preference_item))
|
||||
}
|
||||
|
||||
class Model(
|
||||
override val title: DSLSettingsText,
|
||||
override val summary: DSLSettingsText?,
|
||||
override val icon: DSLSettingsIcon?,
|
||||
val color: AvatarColor,
|
||||
val isOn: Boolean = false,
|
||||
val showSwitch: Boolean = false,
|
||||
val onClick: () -> Unit
|
||||
) : PreferenceModel<Model>()
|
||||
|
||||
private class ViewHolder(itemView: View) : PreferenceViewHolder<Model>(itemView) {
|
||||
|
||||
private val switchWidget: SwitchMaterial = itemView.findViewById(R.id.switch_widget)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
super.bind(model)
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
switchWidget.visible = model.showSwitch
|
||||
switchWidget.isEnabled = model.isEnabled
|
||||
switchWidget.isChecked = model.isOn
|
||||
iconView.background.colorFilter = SimpleColorFilter(model.color.colorInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.components.settings.app.notifications.profiles.models
|
||||
|
||||
import android.view.View
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.settings.PreferenceModel
|
||||
import org.thoughtcrime.securesms.components.settings.conversation.preferences.RecipientPreference
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
/**
|
||||
* DSL custom preference for showing recipients in a profile. Delegates most work to [RecipientPreference].
|
||||
*/
|
||||
object NotificationProfileRecipient {
|
||||
|
||||
fun register(adapter: MappingAdapter) {
|
||||
adapter.registerFactory(Model::class.java, MappingAdapter.LayoutFactory({ ViewHolder(it) }, R.layout.notification_profile_recipient_list_item))
|
||||
}
|
||||
|
||||
class Model(val recipientModel: RecipientPreference.Model, val onRemoveClick: (RecipientId) -> Unit) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipientModel.recipient.id == newItem.recipientModel.recipient.id
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: Model): Boolean {
|
||||
return super.areContentsTheSame(newItem) && recipientModel.areContentsTheSame(newItem.recipientModel)
|
||||
}
|
||||
}
|
||||
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
|
||||
private val recipientViewHolder: RecipientPreference.ViewHolder = RecipientPreference.ViewHolder(itemView)
|
||||
private val remove: View = findViewById(R.id.recipient_remove)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
recipientViewHolder.bind(model.recipientModel)
|
||||
remove.setOnClickListener { model.onRemoveClick(model.recipientModel.recipient.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ object RecipientPreference {
|
||||
class Model(
|
||||
val recipient: Recipient,
|
||||
val isAdmin: Boolean = false,
|
||||
val onClick: () -> Unit
|
||||
val onClick: (() -> Unit)? = null
|
||||
) : PreferenceModel<Model>() {
|
||||
override fun areItemsTheSame(newItem: Model): Boolean {
|
||||
return recipient.id == newItem.recipient.id
|
||||
@@ -36,15 +36,19 @@ object RecipientPreference {
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
|
||||
private val avatar: AvatarImageView = itemView.findViewById(R.id.recipient_avatar)
|
||||
private val name: TextView = itemView.findViewById(R.id.recipient_name)
|
||||
private val about: TextView = itemView.findViewById(R.id.recipient_about)
|
||||
private val admin: View = itemView.findViewById(R.id.admin)
|
||||
private val about: TextView? = itemView.findViewById(R.id.recipient_about)
|
||||
private val admin: View? = itemView.findViewById(R.id.admin)
|
||||
private val badge: BadgeImageView = itemView.findViewById(R.id.recipient_badge)
|
||||
|
||||
override fun bind(model: Model) {
|
||||
itemView.setOnClickListener { model.onClick() }
|
||||
if (model.onClick != null) {
|
||||
itemView.setOnClickListener { model.onClick.invoke() }
|
||||
} else {
|
||||
itemView.setOnClickListener(null)
|
||||
}
|
||||
|
||||
avatar.setRecipient(model.recipient)
|
||||
badge.setBadgeFromRecipient(model.recipient)
|
||||
@@ -56,13 +60,13 @@ object RecipientPreference {
|
||||
|
||||
val aboutText = model.recipient.combinedAboutAndEmoji
|
||||
if (aboutText.isNullOrEmpty()) {
|
||||
about.visibility = View.GONE
|
||||
about?.visibility = View.GONE
|
||||
} else {
|
||||
about.text = model.recipient.combinedAboutAndEmoji
|
||||
about.visibility = View.VISIBLE
|
||||
about?.text = model.recipient.combinedAboutAndEmoji
|
||||
about?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
admin.visible = model.isAdmin
|
||||
admin?.visible = model.isAdmin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user