diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt index 49cda3c..f869fe8 100644 --- a/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt +++ b/app/src/main/java/se/ajpanton/notificationsmaster/MainActivity.kt @@ -156,6 +156,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte page == Page.SETTINGS -> SettingsFragment() page == Page.ALERTS -> AlertsFragment() page == Page.PROFILES -> ProfilesFragment() + page == Page.RULES -> RulesFragment() page == Page.LOG_DISPLAY -> LogDisplayFragment() page == Page.FILTER_LOGGING -> FilterLoggingFragment() page == Page.FILTER_APPS -> FilterAppsFragment.newInstance() @@ -349,6 +350,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte SETTINGS(R.id.nav_settings, R.string.page_settings), ALERTS(R.id.nav_alerts, R.string.page_alerts), PROFILES(R.id.nav_profiles, R.string.page_profiles), + RULES(R.id.nav_rules, R.string.page_rules), LOG_DISPLAY(R.id.nav_log_display, R.string.page_log_display), FILTER_LOGGING(R.id.nav_filter_logging, R.string.page_filter_logging), FILTER_APPS(R.id.nav_filter_apps, R.string.page_filter_apps), diff --git a/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt b/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt new file mode 100644 index 0000000..d45f231 --- /dev/null +++ b/app/src/main/java/se/ajpanton/notificationsmaster/RulesFragment.kt @@ -0,0 +1,112 @@ +package se.ajpanton.notificationsmaster + +import android.os.Bundle +import android.view.View +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.CheckBox +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import androidx.fragment.app.Fragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.switchmaterial.SwitchMaterial +import se.ajpanton.notificationsmaster.alerts.AlertConfigurationStore +import se.ajpanton.notificationsmaster.alerts.AlertRuleDefinition +import se.ajpanton.notificationsmaster.alerts.AlertRuleEditor +import se.ajpanton.notificationsmaster.alerts.AlertSource +import se.ajpanton.notificationsmaster.alerts.AlertTextField +import se.ajpanton.notificationsmaster.alerts.AlertTextMatcher +import se.ajpanton.notificationsmaster.alerts.AlertTextMode +import se.ajpanton.notificationsmaster.alerts.AlertOutcome +import se.ajpanton.notificationsmaster.databinding.FragmentRulesBinding + +class RulesFragment : Fragment(R.layout.fragment_rules) { + private data class AppChoice(val name: String, val packageName: String) { + override fun toString() = "$name ($packageName)" + } + + private var binding: FragmentRulesBinding? = null + private lateinit var store: AlertConfigurationStore + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding = FragmentRulesBinding.bind(view) + store = AlertConfigurationStore(requireContext()) + binding!!.addRule.setOnClickListener { showEditor() } + refresh() + } + + override fun onDestroyView() { binding = null; super.onDestroyView() } + + private fun refresh() { + val configuration = store.load() + binding!!.rules.removeAllViews() + AlertRuleEditor.consolidate(configuration.rules).forEach { rule -> + binding!!.rules.addView(Button(requireContext()).apply { + val profile = configuration.profiles.firstOrNull { it.id == rule.definition.profileId }?.name ?: "Missing profile" + text = profile + "\n" + rule.packageNames.joinToString() + " • " + rule.definition.sources.joinToString { it.shortName() } + isAllCaps = false + setOnClickListener { showEditor(rule.ruleIds) } + }) + } + } + + private fun showEditor(ruleIds: Set? = null) { + val configuration = store.load() + if (configuration.profiles.isEmpty()) { + android.widget.Toast.makeText(requireContext(), "Create a profile first", android.widget.Toast.LENGTH_LONG).show() + return + } + val existing = ruleIds?.let { ids -> configuration.rules.first { it.id in ids } } + val form = LinearLayout(requireContext()).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(24), 0, dp(24), 0) } + val apps = installedApps() + val app = Spinner(requireContext()).apply { adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, apps) } + val profile = Spinner(requireContext()).apply { adapter = ArrayAdapter(requireContext(), android.R.layout.simple_spinner_dropdown_item, configuration.profiles.map { it.name }) } + val post = CheckBox(requireContext()).apply { text = "Appearing"; isChecked = existing == null || AlertSource.NOTIFICATION_POST in existing.sources } + val update = CheckBox(requireContext()).apply { text = "Edits"; isChecked = existing == null || AlertSource.NOTIFICATION_UPDATE in existing.sources } + val pattern = EditText(requireContext()).apply { hint = "Text to match (optional)"; setText(existing?.matcher?.value) } + val regex = SwitchMaterial(requireContext()).apply { text = "Use regular expression"; isChecked = existing?.matcher?.mode == AlertTextMode.REGEX } + val protected = SwitchMaterial(requireContext()).apply { text = "Play to completion"; isChecked = existing?.playToCompletion == true } + val dnd = SwitchMaterial(requireContext()).apply { + text = "Allow during DND when supported" + isChecked = existing?.allowDuringDnd == true + } + listOf(app, profile, post, update, pattern, regex, protected, dnd).forEach(form::addView) + existing?.let { + app.setSelection(apps.indexOfFirst { value -> value.packageName == it.packageName }.coerceAtLeast(0)) + profile.setSelection(configuration.profiles.indexOfFirst { value -> value.id == it.profileId }.coerceAtLeast(0)) + } + val dialog = MaterialAlertDialogBuilder(requireContext()).setTitle(if (existing == null) "Add rule" else "Edit rule") + .setView(form).setNegativeButton("Cancel", null).setPositiveButton("Save", null).show() + dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener { + val sources = buildSet { + if (post.isChecked) add(AlertSource.NOTIFICATION_POST) + if (update.isChecked) add(AlertSource.NOTIFICATION_UPDATE) + } + if (sources.isEmpty()) { post.error = "Choose an event"; return@setOnClickListener } + val text = pattern.text.toString().trim() + val matcher = AlertTextMatcher( + AlertTextField.ANY_TEXT, + if (text.isEmpty()) AlertTextMode.ANY else if (regex.isChecked) AlertTextMode.REGEX else AlertTextMode.CONTAINS, + text, + ) + val selectedProfile = configuration.profiles[profile.selectedItemPosition] + val definition = AlertRuleDefinition(sources, matcher, AlertOutcome.PLAY_PROFILE, selectedProfile.id, protected.isChecked, dnd.isChecked) + val updated = if (ruleIds == null) { + AlertRuleEditor.addForApps(configuration, listOf(apps[app.selectedItemPosition].packageName), definition) + } else { + AlertRuleEditor.updateConsolidated(configuration, ruleIds, definition) + } + store.save(updated) + dialog.dismiss() + refresh() + } + } + + private fun installedApps() = requireContext().packageManager.getInstalledApplications(0).map { + AppChoice(requireContext().packageManager.getApplicationLabel(it).toString(), it.packageName) + }.sortedBy { it.name.lowercase() } + + private fun AlertSource.shortName() = if (this == AlertSource.NOTIFICATION_POST) "Appearing" else "Edits" + private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() +} diff --git a/app/src/main/res/layout/fragment_rules.xml b/app/src/main/res/layout/fragment_rules.xml new file mode 100644 index 0000000..31559d3 --- /dev/null +++ b/app/src/main/res/layout/fragment_rules.xml @@ -0,0 +1,7 @@ + + + +