From 77e58d518b834bc8dd4098a3bd47ea693715900e Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Tue, 23 Dec 2025 11:46:44 -0500 Subject: [PATCH] Message bar --- .../main/java/io/heckel/ntfy/db/Repository.kt | 11 + .../java/io/heckel/ntfy/ui/DetailActivity.kt | 115 +++++++- .../java/io/heckel/ntfy/ui/PublishFragment.kt | 252 ++++++++++++++++++ .../io/heckel/ntfy/ui/SettingsActivity.kt | 20 ++ .../res/drawable/ic_create_white_24dp.xml | 10 + .../res/drawable/ic_expand_less_gray_24dp.xml | 10 + .../main/res/drawable/ic_send_gray_24dp.xml | 10 + app/src/main/res/layout/activity_detail.xml | 37 ++- .../res/layout/fragment_publish_dialog.xml | 168 ++++++++++++ app/src/main/res/layout/view_message_bar.xml | 66 +++++ app/src/main/res/menu/menu_publish_dialog.xml | 10 + app/src/main/res/values/dimens.xml | 5 + app/src/main/res/values/strings.xml | 26 ++ app/src/main/res/values/values.xml | 1 + app/src/main/res/xml/main_preferences.xml | 4 + 15 files changed, 739 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt create mode 100644 app/src/main/res/drawable/ic_create_white_24dp.xml create mode 100644 app/src/main/res/drawable/ic_expand_less_gray_24dp.xml create mode 100644 app/src/main/res/drawable/ic_send_gray_24dp.xml create mode 100644 app/src/main/res/layout/fragment_publish_dialog.xml create mode 100644 app/src/main/res/layout/view_message_bar.xml create mode 100644 app/src/main/res/menu/menu_publish_dialog.xml create mode 100644 app/src/main/res/values/dimens.xml diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index f748e3a0..d8709443 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 37ff3e84..464c807f 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -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 { 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() { diff --git a/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt new file mode 100644 index 00000000..b8154090 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/ui/PublishFragment.kt @@ -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 + } + } +} + diff --git a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt index d007d2f5..e94c4e5c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/SettingsActivity.kt @@ -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 { 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 diff --git a/app/src/main/res/drawable/ic_create_white_24dp.xml b/app/src/main/res/drawable/ic_create_white_24dp.xml new file mode 100644 index 00000000..52c290f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_create_white_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml b/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml new file mode 100644 index 00000000..be97faaf --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_less_gray_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_send_gray_24dp.xml b/app/src/main/res/drawable/ic_send_gray_24dp.xml new file mode 100644 index 00000000..6a6ec8d8 --- /dev/null +++ b/app/src/main/res/drawable/ic_send_gray_24dp.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_detail.xml b/app/src/main/res/layout/activity_detail.xml index ab6327a2..ba0f96f0 100644 --- a/app/src/main/res/layout/activity_detail.xml +++ b/app/src/main/res/layout/activity_detail.xml @@ -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"> + + + + diff --git a/app/src/main/res/layout/fragment_publish_dialog.xml b/app/src/main/res/layout/fragment_publish_dialog.xml new file mode 100644 index 00000000..6b89da5c --- /dev/null +++ b/app/src/main/res/layout/fragment_publish_dialog.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/view_message_bar.xml b/app/src/main/res/layout/view_message_bar.xml new file mode 100644 index 00000000..82ffeb1f --- /dev/null +++ b/app/src/main/res/layout/view_message_bar.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/menu_publish_dialog.xml b/app/src/main/res/menu/menu_publish_dialog.xml new file mode 100644 index 00000000..230626fc --- /dev/null +++ b/app/src/main/res/menu/menu_publish_dialog.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 00000000..b7d698fd --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 24dp + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index acf26954..f2c9ed5a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -199,6 +199,29 @@ Subscription settings + + Publish to %1$s + Title (optional) + Message + Tags (optional, comma-separated) + Default priority + Min priority + Low priority + High priority + Max priority + Cancel + Send + Cannot send message: %1$s + Message published + + + Type a message here + Publish message + More options + + + Publish notification + Share Share @@ -312,6 +335,9 @@ Dynamic colors Using the dynamic system colors Using the ntfy theme colors + Show message bar + Message bar shown at bottom of topic view + Publish button shown at bottom of topic view Backup & Restore Back up to file Export config, notifications, and users diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 77de8393..e7951248 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -23,6 +23,7 @@ ManageUsers DarkMode DynamicColors + MessageBarEnabled Backup Restore BroadcastEnabled diff --git a/app/src/main/res/xml/main_preferences.xml b/app/src/main/res/xml/main_preferences.xml index dc4ef519..231a6160 100644 --- a/app/src/main/res/xml/main_preferences.xml +++ b/app/src/main/res/xml/main_preferences.xml @@ -53,6 +53,10 @@ app:key="@string/settings_general_dynamic_colors_key" app:title="@string/settings_general_dynamic_colors_title" app:isPreferenceVisible="false"/> +