Message bar

This commit is contained in:
Philipp Heckel 2025-12-23 11:46:44 -05:00
parent d3f83001ce
commit 77e58d518b
15 changed files with 739 additions and 6 deletions

View file

@ -333,6 +333,16 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
}
}
fun getMessageBarEnabled(): Boolean {
return sharedPrefs.getBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, false) // Disabled by default (show FAB)
}
fun setMessageBarEnabled(enabled: Boolean) {
sharedPrefs.edit {
putBoolean(SHARED_PREFS_MESSAGE_BAR_ENABLED, enabled)
}
}
fun getBatteryOptimizationsRemindTime(): Long {
return sharedPrefs.getLong(SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME, BATTERY_OPTIMIZATIONS_REMIND_TIME_ALWAYS)
}
@ -511,6 +521,7 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database)
const val SHARED_PREFS_UNIFIEDPUSH_ENABLED = "UnifiedPushEnabled"
const val SHARED_PREFS_INSISTENT_MAX_PRIORITY_ENABLED = "InsistentMaxPriority"
const val SHARED_PREFS_RECORD_LOGS_ENABLED = "RecordLogs"
const val SHARED_PREFS_MESSAGE_BAR_ENABLED = "MessageBarEnabled"
const val SHARED_PREFS_BATTERY_OPTIMIZATIONS_REMIND_TIME = "BatteryOptimizationsRemindTime"
const val SHARED_PREFS_WEBSOCKET_REMIND_TIME = "JsonStreamRemindTime" // "Use WebSocket" banner (used to be JSON stream deprecation banner)
const val SHARED_PREFS_WEBSOCKET_RECONNECT_REMIND_TIME = "WebSocketReconnectRemindTime"

View file

@ -57,8 +57,13 @@ import kotlin.random.Random
import androidx.core.view.size
import androidx.core.view.get
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import android.widget.ImageButton
class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSettingsListener {
class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSettingsListener, PublishFragment.PublishListener {
private val viewModel by viewModels<DetailViewModel> {
DetailViewModelFactory((application as Application).repository)
}
@ -81,6 +86,11 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
private lateinit var mainList: RecyclerView
private lateinit var mainListContainer: SwipeRefreshLayout
private lateinit var menu: Menu
private lateinit var fab: FloatingActionButton
private lateinit var messageBar: View
private lateinit var messageBarText: TextInputEditText
private lateinit var messageBarSendButton: ImageButton
private lateinit var messageBarExpandButton: ImageButton
// Action mode stuff
private var actionMode: ActionMode? = null
@ -345,6 +355,109 @@ class DetailActivity : AppCompatActivity(), NotificationFragment.NotificationSet
} catch (_: Exception) {
// Ignore errors
}
// Setup FAB and message bar
setupPublishUI()
}
private fun setupPublishUI() {
fab = findViewById(R.id.detail_fab)
messageBar = findViewById(R.id.detail_message_bar)
messageBarText = messageBar.findViewById(R.id.message_bar_text)
messageBarSendButton = messageBar.findViewById(R.id.message_bar_send_button)
messageBarExpandButton = messageBar.findViewById(R.id.message_bar_expand_button)
val messageBarEnabled = repository.getMessageBarEnabled()
if (messageBarEnabled) {
// Show message bar, hide FAB
fab.visibility = View.GONE
messageBar.visibility = View.VISIBLE
// Send button click
messageBarSendButton.setOnClickListener {
val message = messageBarText.text.toString()
if (message.isNotEmpty()) {
publishMessage(message)
}
}
// Expand button click - open full dialog
messageBarExpandButton.setOnClickListener {
openPublishDialog(messageBarText.text.toString())
}
} else {
// Show FAB, hide message bar
fab.visibility = View.VISIBLE
messageBar.visibility = View.GONE
fab.setOnClickListener {
openPublishDialog("")
}
// Add bottom padding to FAB to account for navigation bar
ViewCompat.setOnApplyWindowInsetsListener(fab) { view, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val layoutParams = view.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
layoutParams.bottomMargin = systemBars.bottom + resources.getDimensionPixelSize(R.dimen.fab_margin)
view.layoutParams = layoutParams
insets
}
}
}
private fun openPublishDialog(initialMessage: String) {
val fragment = PublishFragment.newInstance(subscriptionBaseUrl, subscriptionTopic, initialMessage)
fragment.show(supportFragmentManager, PublishFragment.TAG)
}
private fun publishMessage(message: String) {
lifecycleScope.launch(Dispatchers.IO) {
try {
val user = repository.getUser(subscriptionBaseUrl)
api.publish(
baseUrl = subscriptionBaseUrl,
topic = subscriptionTopic,
user = user,
message = message,
title = "",
priority = 3, // Default priority
tags = emptyList(),
delay = ""
)
runOnUiThread {
messageBarText.text?.clear()
Toast.makeText(this@DetailActivity, R.string.publish_dialog_message_published, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to publish message", e)
runOnUiThread {
val errorMessage = when (e) {
is ApiService.UnauthorizedException -> {
if (e.user != null) {
getString(R.string.detail_test_message_error_unauthorized_user, e.user.username)
} else {
getString(R.string.detail_test_message_error_unauthorized_anon)
}
}
is ApiService.EntityTooLargeException -> {
getString(R.string.detail_test_message_error_too_large)
}
else -> {
getString(R.string.publish_dialog_error_sending, e.message)
}
}
Toast.makeText(this@DetailActivity, errorMessage, Toast.LENGTH_LONG).show()
}
}
}
}
override fun onPublished() {
// Clear the message bar text when a message is published from the dialog
if (this::messageBarText.isInitialized) {
messageBarText.text?.clear()
}
}
override fun onResume() {

View file

@ -0,0 +1,252 @@
package io.heckel.ntfy.ui
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.textfield.TextInputEditText
import io.heckel.ntfy.R
import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.AfterChangedTextWatcher
import io.heckel.ntfy.util.topicShortUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class PublishFragment : DialogFragment() {
private val api = ApiService()
private lateinit var repository: Repository
private lateinit var toolbar: MaterialToolbar
private lateinit var sendMenuItem: MenuItem
private lateinit var titleText: TextInputEditText
private lateinit var messageText: TextInputEditText
private lateinit var tagsText: TextInputEditText
private lateinit var priorityDropdown: AutoCompleteTextView
private lateinit var progress: ProgressBar
private lateinit var errorText: TextView
private lateinit var errorImage: View
private var baseUrl: String = ""
private var topic: String = ""
private var selectedPriority: Int = 3 // Default priority
private var initialMessage: String = ""
interface PublishListener {
fun onPublished()
}
private var publishListener: PublishListener? = null
override fun onAttach(context: Context) {
super.onAttach(context)
if (context is PublishListener) {
publishListener = context
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
if (activity == null) {
throw IllegalStateException("Activity cannot be null")
}
// Dependencies
repository = Repository.getInstance(requireActivity())
// Get arguments
baseUrl = arguments?.getString(ARG_BASE_URL) ?: ""
topic = arguments?.getString(ARG_TOPIC) ?: ""
initialMessage = arguments?.getString(ARG_MESSAGE) ?: ""
// Build root view
val view = requireActivity().layoutInflater.inflate(R.layout.fragment_publish_dialog, null)
// Setup toolbar
toolbar = view.findViewById(R.id.publish_dialog_toolbar)
toolbar.title = getString(R.string.publish_dialog_title, topicShortUrl(baseUrl, topic))
toolbar.setNavigationOnClickListener {
dismiss()
}
toolbar.setOnMenuItemClickListener { menuItem ->
if (menuItem.itemId == R.id.publish_dialog_send_button) {
onSendClick()
true
} else {
false
}
}
sendMenuItem = toolbar.menu.findItem(R.id.publish_dialog_send_button)
// Fields
titleText = view.findViewById(R.id.publish_dialog_title_text)
messageText = view.findViewById(R.id.publish_dialog_message_text)
tagsText = view.findViewById(R.id.publish_dialog_tags_text)
priorityDropdown = view.findViewById(R.id.publish_dialog_priority_dropdown)
progress = view.findViewById(R.id.publish_dialog_progress)
errorText = view.findViewById(R.id.publish_dialog_error_text)
errorImage = view.findViewById(R.id.publish_dialog_error_image)
// Set initial message if provided
if (initialMessage.isNotEmpty()) {
messageText.setText(initialMessage)
}
// Setup priority dropdown
val priorities = listOf(
getString(R.string.publish_dialog_priority_min),
getString(R.string.publish_dialog_priority_low),
getString(R.string.publish_dialog_priority_default),
getString(R.string.publish_dialog_priority_high),
getString(R.string.publish_dialog_priority_max)
)
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, priorities)
priorityDropdown.setAdapter(adapter)
priorityDropdown.setText(priorities[2], false) // Default priority
priorityDropdown.setOnItemClickListener { _, _, position, _ ->
selectedPriority = position + 1 // Priority is 1-5
}
// Validation on text change
val textWatcher = AfterChangedTextWatcher {
validateInput()
}
messageText.addTextChangedListener(textWatcher)
// Build dialog
val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog)
dialog.setContentView(view)
// Initial validation
validateInput()
return dialog
}
override fun onStart() {
super.onStart()
dialog?.window?.apply {
setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
override fun onResume() {
super.onResume()
// Show keyboard after the dialog is fully visible
messageText.postDelayed({
messageText.requestFocus()
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
imm?.showSoftInput(messageText, InputMethodManager.SHOW_FORCED)
}, 200)
}
private fun validateInput() {
if (!this::sendMenuItem.isInitialized) return
sendMenuItem.isEnabled = messageText.text?.isNotEmpty() == true
}
private fun onSendClick() {
val title = titleText.text.toString()
val message = messageText.text.toString()
val tagsString = tagsText.text.toString()
val tags = if (tagsString.isNotEmpty()) {
tagsString.split(",").map { it.trim() }.filter { it.isNotEmpty() }
} else {
emptyList()
}
progress.visibility = View.VISIBLE
errorText.visibility = View.GONE
errorImage.visibility = View.GONE
enableView(false)
lifecycleScope.launch(Dispatchers.IO) {
try {
val user = repository.getUser(baseUrl)
api.publish(
baseUrl = baseUrl,
topic = topic,
user = user,
message = message,
title = title,
priority = selectedPriority,
tags = tags,
delay = ""
)
val activity = activity ?: return@launch
activity.runOnUiThread {
Toast.makeText(activity, R.string.publish_dialog_message_published, Toast.LENGTH_SHORT).show()
publishListener?.onPublished()
dismiss()
}
} catch (e: Exception) {
Log.w(TAG, "Failed to publish message", e)
val activity = activity ?: return@launch
activity.runOnUiThread {
progress.visibility = View.GONE
val errorMessage = when (e) {
is ApiService.UnauthorizedException -> {
if (e.user != null) {
getString(R.string.detail_test_message_error_unauthorized_user, e.user.username)
} else {
getString(R.string.detail_test_message_error_unauthorized_anon)
}
}
is ApiService.EntityTooLargeException -> {
getString(R.string.detail_test_message_error_too_large)
}
else -> {
getString(R.string.publish_dialog_error_sending, e.message)
}
}
errorText.text = errorMessage
errorText.visibility = View.VISIBLE
errorImage.visibility = View.VISIBLE
enableView(true)
}
}
}
}
private fun enableView(enable: Boolean) {
titleText.isEnabled = enable
messageText.isEnabled = enable
tagsText.isEnabled = enable
priorityDropdown.isEnabled = enable
sendMenuItem.isEnabled = enable && messageText.text?.isNotEmpty() == true
}
companion object {
const val TAG = "NtfyPublishFragment"
private const val ARG_BASE_URL = "baseUrl"
private const val ARG_TOPIC = "topic"
private const val ARG_MESSAGE = "message"
fun newInstance(baseUrl: String, topic: String, message: String = ""): PublishFragment {
val fragment = PublishFragment()
fragment.arguments = Bundle().apply {
putString(ARG_BASE_URL, baseUrl)
putString(ARG_TOPIC, topic)
putString(ARG_MESSAGE, message)
}
return fragment
}
}
}

View file

@ -377,6 +377,26 @@ class SettingsActivity : AppCompatActivity(), PreferenceFragmentCompat.OnPrefere
dynamicColorsEnabled?.isVisible = true
}
// Message bar enabled
val messageBarEnabledPrefId = context?.getString(R.string.settings_general_message_bar_key) ?: return
val messageBarEnabled: SwitchPreferenceCompat? = findPreference(messageBarEnabledPrefId)
messageBarEnabled?.isChecked = repository.getMessageBarEnabled()
messageBarEnabled?.preferenceDataStore = object : PreferenceDataStore() {
override fun putBoolean(key: String?, value: Boolean) {
repository.setMessageBarEnabled(value)
}
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return repository.getMessageBarEnabled()
}
}
messageBarEnabled?.summaryProvider = Preference.SummaryProvider<SwitchPreferenceCompat> { pref ->
if (pref.isChecked) {
getString(R.string.settings_general_message_bar_summary_enabled)
} else {
getString(R.string.settings_general_message_bar_summary_disabled)
}
}
// Default Base URL
val appBaseUrl = getString(R.string.app_base_url)
val defaultBaseUrlPrefId = context?.getString(R.string.settings_general_default_base_url_key) ?: return

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM20.71,7.04c0.39,-0.39 0.39,-1.02 0,-1.41l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83z"
android:fillColor="#FFFFFF"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,8l-6,6 1.41,1.41L12,10.83l4.59,4.58L18,14z"
android:fillColor="#808080"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M4.01,6.03l7.51,3.22 -7.52,-1 0.01,-2.22m7.5,8.72L4,17.97v-2.22l7.51,-1M2.01,3L2,10l15,2 -15,2 0.01,7L23,12 2.01,3z"
android:fillColor="#808080"/>
</vector>

View file

@ -18,7 +18,6 @@
android:id="@+id/detail_content_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="16dp"
android:background="@color/detail_activity_background"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
@ -26,8 +25,12 @@
style="@style/CardViewBackground"
android:id="@+id/detail_notification_list_container"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone">
android:layout_height="0dp"
android:visibility="gone"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/detail_message_bar"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/detail_notification_list"
android:layout_width="match_parent"
@ -45,8 +48,10 @@
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_no_notifications" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintStart_toStartOf="parent"
android:id="@+id/detail_no_notifications"
app:layout_constraintBottom_toTopOf="@id/detail_message_bar"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<ImageView
android:layout_width="match_parent"
@ -88,6 +93,28 @@
android:autoLink="web"/>
</LinearLayout>
<include
android:id="@+id/detail_message_bar"
layout="@layout/view_message_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/detail_fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="24dp"
android:contentDescription="@string/detail_fab_publish_description"
android:src="@drawable/ic_create_white_24dp"
android:visibility="gone"
app:layout_anchor="@id/detail_content_layout"
app:layout_anchorGravity="bottom|end"
style="@style/FloatingActionButton"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/publish_dialog_app_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurface"
app:elevation="0dp"
app:liftOnScroll="false">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/publish_dialog_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorSurface"
app:navigationIcon="@drawable/ic_close_white_24dp"
app:navigationIconTint="?attr/colorOnSurface"
app:titleTextColor="?attr/colorOnSurface"
app:menu="@menu/menu_publish_dialog" />
</com.google.android.material.appbar.AppBarLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:paddingHorizontal="?dialogPreferredPadding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<ScrollView
android:id="@+id/publish_dialog_scroll_view"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ProgressBar
style="?android:attr/progressBarStyle"
android:layout_width="24dp"
android:layout_height="24dp"
android:id="@+id/publish_dialog_progress"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:indeterminate="true"
android:layout_marginTop="16dp"
android:visibility="gone"/>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_title_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_title_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_title_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_message_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/publish_dialog_title_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="10dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_message_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_message_hint"
android:importantForAutofill="no"
android:minLines="3"
android:gravity="start|top"
android:inputType="textMultiLine|textCapSentences"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/publish_dialog_tags_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/publish_dialog_message_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="10dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/publish_dialog_tags_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/publish_dialog_tags_hint"
android:importantForAutofill="no"
android:maxLines="1"
android:inputType="text"/>
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.ExposedDropdownMenu"
android:id="@+id/publish_dialog_priority_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/publish_dialog_tags_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="16dp">
<AutoCompleteTextView
android:id="@+id/publish_dialog_priority_dropdown"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="none"
android:text="@string/publish_dialog_priority_default"/>
</com.google.android.material.textfield.TextInputLayout>
<ImageView
android:id="@+id/publish_dialog_error_image"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="1dp"
android:visibility="gone"
app:srcCompat="@drawable/ic_error_red_24dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/publish_dialog_error_text"/>
<TextView
android:id="@+id/publish_dialog_error_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textAppearance="@style/DangerText"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/publish_dialog_priority_layout"
app:layout_constraintStart_toEndOf="@id/publish_dialog_error_image"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/colorBackground"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:elevation="4dp">
<View
android:id="@+id/message_bar_divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutline"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginBottom="8dp"/>
<ImageButton
android:id="@+id/message_bar_expand_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_expand_less_gray_24dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/message_bar_expand_button_description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_bar_divider"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/colorOnSurfaceVariant"/>
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/message_bar_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/message_bar_hint"
android:inputType="textMultiLine|textCapSentences"
android:minHeight="40dp"
android:maxLines="4"
android:background="@null"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:importantForAutofill="no"
app:layout_constraintStart_toEndOf="@id/message_bar_expand_button"
app:layout_constraintEnd_toStartOf="@id/message_bar_send_button"
app:layout_constraintTop_toBottomOf="@id/message_bar_divider"
app:layout_constraintBottom_toBottomOf="parent"/>
<ImageButton
android:id="@+id/message_bar_send_button"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_send_gray_24dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/message_bar_publish_button_description"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/message_bar_divider"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/colorPrimary"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/publish_dialog_send_button"
android:title="@string/publish_dialog_button_send"
android:enabled="false"
app:showAsAction="always" />
</menu>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<dimen name="fab_margin">24dp</dimen>
</resources>

View file

@ -199,6 +199,29 @@
<string name="detail_settings_title">Subscription settings</string>
<!-- ... -->
<!-- Publish dialog -->
<string name="publish_dialog_title">Publish to %1$s</string>
<string name="publish_dialog_title_hint">Title (optional)</string>
<string name="publish_dialog_message_hint">Message</string>
<string name="publish_dialog_tags_hint">Tags (optional, comma-separated)</string>
<string name="publish_dialog_priority_default">Default priority</string>
<string name="publish_dialog_priority_min">Min priority</string>
<string name="publish_dialog_priority_low">Low priority</string>
<string name="publish_dialog_priority_high">High priority</string>
<string name="publish_dialog_priority_max">Max priority</string>
<string name="publish_dialog_button_cancel">Cancel</string>
<string name="publish_dialog_button_send">Send</string>
<string name="publish_dialog_error_sending">Cannot send message: %1$s</string>
<string name="publish_dialog_message_published">Message published</string>
<!-- Message bar -->
<string name="message_bar_hint">Type a message here</string>
<string name="message_bar_publish_button_description">Publish message</string>
<string name="message_bar_expand_button_description">More options</string>
<!-- Detail activity: Publish FAB -->
<string name="detail_fab_publish_description">Publish notification</string>
<!-- Share activity -->
<string name="share_title">Share</string>
<string name="share_menu_send">Share</string>
@ -312,6 +335,9 @@
<string name="settings_general_dynamic_colors_title">Dynamic colors</string>
<string name="settings_general_dynamic_colors_summary_enabled">Using the dynamic system colors</string>
<string name="settings_general_dynamic_colors_summary_disabled">Using the ntfy theme colors</string>
<string name="settings_general_message_bar_title">Show message bar</string>
<string name="settings_general_message_bar_summary_enabled">Message bar shown at bottom of topic view</string>
<string name="settings_general_message_bar_summary_disabled">Publish button shown at bottom of topic view</string>
<string name="settings_backup_restore_header">Backup &amp; Restore</string>
<string name="settings_backup_restore_backup_title">Back up to file</string>
<string name="settings_backup_restore_backup_summary">Export config, notifications, and users</string>

View file

@ -23,6 +23,7 @@
<string name="settings_general_users_key" translatable="false">ManageUsers</string>
<string name="settings_general_dark_mode_key" translatable="false">DarkMode</string>
<string name="settings_general_dynamic_colors_key" translatable="false">DynamicColors</string>
<string name="settings_general_message_bar_key" translatable="false">MessageBarEnabled</string>
<string name="settings_backup_restore_backup_key" translatable="false">Backup</string>
<string name="settings_backup_restore_restore_key" translatable="false">Restore</string>
<string name="settings_advanced_broadcast_key" translatable="false">BroadcastEnabled</string>

View file

@ -53,6 +53,10 @@
app:key="@string/settings_general_dynamic_colors_key"
app:title="@string/settings_general_dynamic_colors_title"
app:isPreferenceVisible="false"/>
<SwitchPreferenceCompat
app:key="@string/settings_general_message_bar_key"
app:title="@string/settings_general_message_bar_title"
app:defaultValue="false"/>
</PreferenceCategory>
<PreferenceCategory app:title="@string/settings_backup_restore_header">
<ListPreference