Add detail view back

This commit is contained in:
Philipp Heckel 2021-10-31 15:19:25 -04:00
parent 785a36e257
commit 2a64f44916
17 changed files with 416 additions and 88 deletions

View file

@ -1,40 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.heckel.ntfy">
package="io.heckel.ntfy">
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Main app -->
<application
android:name = ".app.Application"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:name=".app.Application"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<!-- Main activity -->
<activity android:name="io.heckel.ntfy.ui.MainActivity"
android:label="@string/app_name">
<activity
android:name=".ui.MainActivity"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Detail activity -->
<activity android:name=".ui.DetailActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".ui.MainActivity" />
</activity>
<!-- Firebase messaging -->
<service android:name="io.heckel.ntfy.msg.MessagingService"
android:exported="false">
<service
android:name=".msg.MessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
<meta-data android:name="firebase_analytics_collection_enabled"
android:value="false" />
<meta-data
android:name="firebase_analytics_collection_enabled"
android:value="false"/>
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@drawable/ic_notification_icon" />
android:resource="@drawable/ic_notification_icon"/>
</application>
</manifest>

View file

@ -6,5 +6,5 @@ import io.heckel.ntfy.data.Repository
class Application : Application() {
private val database by lazy { Database.getInstance(this) }
val repository by lazy { Repository.getInstance(database.subscriptionDao()) }
val repository by lazy { Repository.getInstance(database.subscriptionDao(), database.notificationDao()) }
}

View file

@ -1,6 +1,7 @@
package io.heckel.ntfy.data
import android.content.Context
import androidx.lifecycle.LiveData
import androidx.room.*
import kotlinx.coroutines.flow.Flow
@ -9,12 +10,22 @@ data class Subscription(
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: String,
@ColumnInfo(name = "messages") val messages: Int
@ColumnInfo(name = "notifications") val notifications: Int,
@ColumnInfo(name = "lastActive") val lastActive: Long // Unix timestamp
)
@androidx.room.Database(entities = [Subscription::class], version = 1)
@Entity
data class Notification(
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "subscriptionId") val subscriptionId: Long,
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "message") val message: String
)
@androidx.room.Database(entities = [Subscription::class, Notification::class], version = 1)
abstract class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
companion object {
@Volatile
@ -35,7 +46,7 @@ abstract class Database : RoomDatabase() {
@Dao
interface SubscriptionDao {
@Query("SELECT * FROM subscription")
@Query("SELECT * FROM subscription ORDER BY lastActive DESC")
fun list(): Flow<List<Subscription>>
@Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic")
@ -47,6 +58,21 @@ interface SubscriptionDao {
@Update
fun update(subscription: Subscription)
@Delete
fun remove(subscription: Subscription)
@Query("DELETE FROM subscription WHERE id = :subscriptionId")
fun remove(subscriptionId: Long)
}
@Dao
interface NotificationDao {
@Query("SELECT * FROM notification WHERE subscriptionId = :subscriptionId ORDER BY timestamp DESC")
fun list(subscriptionId: Long): Flow<List<Notification>>
@Insert
fun add(notification: Notification)
@Delete
fun remove(notification: Notification)
@Query("DELETE FROM notification WHERE subscriptionId = :subscriptionId")
fun removeAll(subscriptionId: Long)
}

View file

@ -4,41 +4,64 @@ import androidx.annotation.WorkerThread
import androidx.lifecycle.LiveData
import androidx.lifecycle.asLiveData
class Repository(private val subscriptionDao: SubscriptionDao) {
fun list(): LiveData<List<Subscription>> {
class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
fun getAllSubscriptions(): LiveData<List<Subscription>> {
return subscriptionDao.list().asLiveData()
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun get(baseUrl: String, topic: String): Subscription? {
suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
return subscriptionDao.get(baseUrl, topic)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun add(subscription: Subscription) {
suspend fun addSubscription(subscription: Subscription) {
subscriptionDao.add(subscription)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun update(subscription: Subscription) {
suspend fun updateSubscription(subscription: Subscription) {
subscriptionDao.update(subscription)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun remove(subscription: Subscription) {
subscriptionDao.remove(subscription)
suspend fun removeSubscription(subscriptionId: Long) {
subscriptionDao.remove(subscriptionId)
}
fun getAllNotifications(subscriptionId: Long): LiveData<List<Notification>> {
return notificationDao.list(subscriptionId).asLiveData()
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun addNotification(notification: Notification) {
notificationDao.add(notification)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
suspend fun removeNotification(notification: Notification) {
notificationDao.remove(notification)
}
@Suppress("RedundantSuspendModifier")
@WorkerThread
fun removeAllNotifications(subscriptionId: Long) {
notificationDao.removeAll(subscriptionId)
}
companion object {
private var instance: Repository? = null
fun getInstance(subscriptionDao: SubscriptionDao): Repository {
fun getInstance(subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository {
return synchronized(Repository::class) {
val newInstance = instance ?: Repository(subscriptionDao)
val newInstance = instance ?: Repository(subscriptionDao, notificationDao)
instance = newInstance
newInstance
}

View file

@ -11,16 +11,18 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.topicShortUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import java.util.*
import kotlin.random.Random
class MessagingService : FirebaseMessagingService() {
private val database by lazy { Database.getInstance(this) }
private val repository by lazy { Repository.getInstance(database.subscriptionDao()) }
private val repository by lazy { Repository.getInstance(database.subscriptionDao(), database.notificationDao()) }
private val job = SupervisorJob()
override fun onMessageReceived(remoteMessage: RemoteMessage) {
@ -32,9 +34,10 @@ class MessagingService : FirebaseMessagingService() {
// Check if valid data, and send notification
val data = remoteMessage.data
val timestamp = data["time"]?.toLongOrNull()
val topic = data["topic"]
val message = data["message"]
if (topic == null || message == null) {
if (topic == null || message == null || timestamp == null) {
Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return
}
@ -43,9 +46,13 @@ class MessagingService : FirebaseMessagingService() {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
// Update message counter
val subscription = repository.get(baseUrl, topic) ?: return@launch
val newSubscription = subscription.copy(messages = subscription.messages + 1)
repository.update(newSubscription)
val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val newSubscription = subscription.copy(notifications = subscription.notifications + 1, lastActive = Date().time/1000)
repository.updateSubscription(newSubscription)
// Add notification
val notification = Notification(id = Random.nextLong(), subscriptionId = subscription.id, timestamp = timestamp, message = message)
repository.addNotification(notification)
// Send notification
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}")

View file

@ -0,0 +1,95 @@
package io.heckel.ntfy.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.topicShortUrl
class DetailActivity : AppCompatActivity() {
private val viewModel by viewModels<DetailViewModel> {
DetailViewModelFactory((application as Application).repository)
}
private var subscriptionId: Long = 0L // Set in onCreate()
private var subscriptionTopic: String = "" // Set in onCreate()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.detail_activity)
supportActionBar?.setDisplayHomeAsUpEnabled(true) // Show 'Back' button
// Get extras required for the return to the main activity
subscriptionId = intent.getLongExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, 0)
subscriptionTopic = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC) ?: return
// Set title
val subscriptionBaseUrl = intent.getStringExtra(MainActivity.EXTRA_SUBSCRIPTION_BASE_URL) ?: return
title = topicShortUrl(subscriptionBaseUrl, subscriptionTopic)
// Update main list based on viewModel (& its datasource/livedata)
val noEntriesText: View = findViewById(R.id.detail_no_notifications_text)
val adapter = DetailAdapter { notification -> onNotificationClick(notification) }
val mainList: RecyclerView = findViewById(R.id.detail_notification_list)
mainList.adapter = adapter
viewModel.list(subscriptionId).observe(this) {
it?.let {
adapter.submitList(it as MutableList<Notification>)
if (it.isEmpty()) {
mainList.visibility = View.GONE
noEntriesText.visibility = View.VISIBLE
} else {
mainList.visibility = View.VISIBLE
noEntriesText.visibility = View.GONE
}
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.detail_action_bar_menu, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.detail_menu_delete -> {
onDeleteClick()
true
}
else -> super.onOptionsItemSelected(item)
}
}
private fun onDeleteClick() {
val builder = AlertDialog.Builder(this)
builder
.setMessage(R.string.detail_delete_dialog_message)
.setPositiveButton(R.string.detail_delete_dialog_permanently_delete) { _, _ ->
// Return to main activity
val result = Intent()
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_ID, subscriptionId)
.putExtra(MainActivity.EXTRA_SUBSCRIPTION_TOPIC, subscriptionTopic)
setResult(RESULT_OK, result)
finish()
// Delete notifications
viewModel.removeAll(subscriptionId)
}
.setNegativeButton(R.string.detail_delete_dialog_cancel) { _, _ -> /* Do nothing */ }
.create()
.show()
}
private fun onNotificationClick(notification: Notification) {
println("clicked " + notification.id)
}
}

View file

@ -0,0 +1,57 @@
package io.heckel.ntfy.ui
import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import io.heckel.ntfy.R
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
import java.time.Instant
import java.util.*
class DetailAdapter(private val onClick: (Notification) -> Unit) :
ListAdapter<Notification, DetailAdapter.DetailViewHolder>(TopicDiffCallback) {
/* Creates and inflates view and return TopicViewHolder. */
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DetailViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.detail_fragment_item, parent, false)
return DetailViewHolder(view, onClick)
}
/* Gets current topic and uses it to bind view. */
override fun onBindViewHolder(holder: DetailViewHolder, position: Int) {
holder.bind(getItem(position))
}
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
class DetailViewHolder(itemView: View, val onClick: (Notification) -> Unit) :
RecyclerView.ViewHolder(itemView) {
private var notification: Notification? = null
private val dateView: TextView = itemView.findViewById(R.id.detail_item_date_text)
private val messageView: TextView = itemView.findViewById(R.id.detail_item_message_text)
fun bind(notification: Notification) {
this.notification = notification
dateView.text = Date(notification.timestamp * 1000).toString()
messageView.text = notification.message
itemView.setOnClickListener { onClick(notification) }
}
}
object TopicDiffCallback : DiffUtil.ItemCallback<Notification>() {
override fun areItemsTheSame(oldItem: Notification, newItem: Notification): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Notification, newItem: Notification): Boolean {
return oldItem == newItem
}
}
}

View file

@ -0,0 +1,40 @@
package io.heckel.ntfy.ui
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class DetailViewModel(private val repository: Repository) : ViewModel() {
fun list(subscriptionId: Long): LiveData<List<Notification>> {
return repository.getAllNotifications(subscriptionId)
}
fun add(notification: Notification) = viewModelScope.launch(Dispatchers.IO) {
repository.addNotification(notification)
}
fun remove(notification: Notification) = viewModelScope.launch(Dispatchers.IO) {
repository.removeNotification(notification)
}
fun removeAll(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
repository.removeAllNotifications(subscriptionId)
}
}
class DetailViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>) =
with(modelClass){
when {
isAssignableFrom(DetailViewModel::class.java) -> DetailViewModel(repository) as T
else -> throw IllegalArgumentException("Unknown viewModel class $modelClass")
}
}
}

View file

@ -3,22 +3,22 @@ package io.heckel.ntfy.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.Menu
import android.view.MenuItem
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
import java.util.*
import kotlin.random.Random
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> {
private val viewModel by viewModels<SubscriptionsViewModel> {
SubscriptionsViewModelFactory((application as Application).repository)
}
@ -35,21 +35,21 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
onSubscribeButtonClick()
}
// Update main list based on topicsViewModel (& its datasource/livedata)
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text)
val adapter = SubscriptionsAdapter { subscription -> onUnsubscribe(subscription) }
// Update main list based on viewModel (& its datasource/livedata)
val noEntriesText: View = findViewById(R.id.main_no_subscriptions_text)
val adapter = SubscriptionsAdapter { subscription -> onSubscriptionItemClick(subscription) }
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
mainList.adapter = adapter
subscriptionsViewModel.list().observe(this) {
viewModel.list().observe(this) {
it?.let {
adapter.submitList(it as MutableList<Subscription>)
if (it.isEmpty()) {
mainList.visibility = View.GONE
noSubscriptionsText.visibility = View.VISIBLE
noEntriesText.visibility = View.VISIBLE
} else {
mainList.visibility = View.VISIBLE
noSubscriptionsText.visibility = View.GONE
noEntriesText.visibility = View.GONE
}
}
}
@ -66,7 +66,7 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
true
}
R.id.menu_action_website -> {
R.id.detail_menu_delete -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_base_url))))
true
}
@ -80,17 +80,35 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
}
override fun onSubscribe(topic: String, baseUrl: String) {
val subscription = Subscription(id = Random.nextLong(), baseUrl = baseUrl, topic = topic, messages = 0)
subscriptionsViewModel.add(subscription)
val subscription = Subscription(id = Random.nextLong(), baseUrl = baseUrl, topic = topic, notifications = 0, lastActive = Date().time/1000)
viewModel.add(subscription)
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
}
private fun onUnsubscribe(subscription: Subscription) {
subscriptionsViewModel.remove(subscription)
FirebaseMessaging.getInstance().unsubscribeFromTopic(subscription.topic)
private fun onSubscriptionItemClick(subscription: Subscription) {
val intent = Intent(this, DetailActivity::class.java)
intent.putExtra(EXTRA_SUBSCRIPTION_ID, subscription.id)
intent.putExtra(EXTRA_SUBSCRIPTION_BASE_URL, subscription.baseUrl)
intent.putExtra(EXTRA_SUBSCRIPTION_TOPIC, subscription.topic)
startActivityForResult(intent, REQUEST_CODE_DELETE_SUBSCRIPTION)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
if (requestCode == REQUEST_CODE_DELETE_SUBSCRIPTION && resultCode == RESULT_OK) {
val subscriptionId = data?.getLongExtra(EXTRA_SUBSCRIPTION_ID, 0)
val subscriptionTopic = data?.getStringExtra(EXTRA_SUBSCRIPTION_TOPIC)
subscriptionId?.let { id -> viewModel.remove(id) }
subscriptionTopic?.let { topic -> FirebaseMessaging.getInstance().unsubscribeFromTopic(topic) } // FIXME This only works for ntfy.sh
} else {
super.onActivityResult(requestCode, resultCode, data)
}
}
companion object {
const val TAG = "NtfyMainActivity"
const val EXTRA_SUBSCRIPTION_ID = "subscriptionId"
const val EXTRA_SUBSCRIPTION_BASE_URL = "subscriptionBaseUrl"
const val EXTRA_SUBSCRIPTION_TOPIC = "subscriptionTopic"
const val REQUEST_CODE_DELETE_SUBSCRIPTION = 1;
}
}

View file

@ -4,7 +4,6 @@ import android.content.Context
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.PopupMenu
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
@ -30,40 +29,23 @@ class SubscriptionsAdapter(private val onClick: (Subscription) -> Unit) :
}
/* ViewHolder for Topic, takes in the inflated view and the onClick behavior. */
class SubscriptionViewHolder(itemView: View, val onUnsubscribe: (Subscription) -> Unit) :
class SubscriptionViewHolder(itemView: View, val onClick: (Subscription) -> Unit) :
RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null
private val context: Context = itemView.context
private val nameView: TextView = itemView.findViewById(R.id.topic_text)
private val statusView: TextView = itemView.findViewById(R.id.topic_status)
init {
val popup = PopupMenu(context, itemView)
popup.inflate(R.menu.main_item_popup_menu)
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.main_item_popup_unsubscribe -> {
subscription?.let { s -> onUnsubscribe(s) }
true
}
else -> false
}
}
itemView.setOnLongClickListener {
subscription?.let { popup.show() }
true
}
}
private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.main_item_status)
fun bind(subscription: Subscription) {
this.subscription = subscription
val statusMessage = if (subscription.messages == 1) {
context.getString(R.string.main_item_status_text_one, subscription.messages)
val statusMessage = if (subscription.notifications == 1) {
context.getString(R.string.main_item_status_text_one, subscription.notifications)
} else {
context.getString(R.string.main_item_status_text_not_one, subscription.messages)
context.getString(R.string.main_item_status_text_not_one, subscription.notifications)
}
nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage
itemView.setOnClickListener { onClick(subscription) }
}
}

View file

@ -11,15 +11,15 @@ import kotlin.collections.List
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
fun list(): LiveData<List<Subscription>> {
return repository.list()
return repository.getAllSubscriptions()
}
fun add(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
repository.add(topic)
fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
repository.addSubscription(subscription)
}
fun remove(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) {
repository.remove(topic)
fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
repository.removeSubscription(subscriptionId)
}
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.DetailActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/detail_notification_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true"
android:background="?android:attr/selectableItemBackground"
app:layoutManager="LinearLayoutManager" android:visibility="gone"/>
<TextView
android:id="@+id/detail_no_notifications_text"
android:text="@string/detail_no_subscriptions_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent" android:textAppearance="@style/TextAppearance.AppCompat.Large"
android:padding="50dp" app:layout_constraintBottom_toBottomOf="parent" android:gravity="center_horizontal"
android:textStyle="italic"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout 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/selectableItemBackground"
android:orientation="vertical" android:clickable="true" android:focusable="true">
<TextView
android:text="Sun, October 31, 2021, 10:43:12"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_item_date_text"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginTop="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
<TextView
android:text="This is a very very very long message. It could be as long as 1024 charaters, which is a lot more than you'd think. No, really so far this message is barely 180 characters long. I can't believe how long 1024 bytes are. This is outrageous. Oh you know what, I think I won't type the whole thing. This seems a little too long for a sample text. Well, anyway, it was nice chatting. So far this message is about 400 bytes long. So maybe just double what you see and that's that."
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/detail_item_message_text"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
</LinearLayout>

View file

@ -15,7 +15,7 @@
<TextView
android:text="ntfy.sh/example"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/topic_text"
android:layout_height="wrap_content" android:id="@+id/main_item_text"
android:layout_marginTop="16dp" android:layout_marginStart="12dp"
android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
@ -23,7 +23,7 @@
<TextView
android:text="Subscribed, 0 notifications"
android:layout_width="match_parent"
android:layout_height="wrap_content" android:id="@+id/topic_status"
android:layout_height="wrap_content" android:id="@+id/main_item_status"
android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp"/>
</LinearLayout>

View file

@ -0,0 +1,3 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:title="@string/detail_menu_delete" android:id="@+id/detail_menu_delete"/>
</menu>

View file

@ -1,5 +1,5 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/menu_action_source"
android:title="@string/main_menu_source_title"/>
<item android:title="@string/main_menu_website_title" android:id="@+id/menu_action_website"/>
<item android:title="@string/main_menu_website_title" android:id="@+id/detail_menu_delete"/>
</menu>

View file

@ -28,4 +28,13 @@
<string name="add_dialog_use_another_server">Use another server</string>
<string name="add_dialog_button_cancel">Cancel</string>
<string name="add_dialog_button_subscribe">Subscribe</string>
<!-- Detail activity -->
<string name="detail_no_subscriptions_text">You haven\'t received any notifications for this topic yet.</string>
<string name="detail_delete_dialog_message">Do you really want to permanently delete this subscription and all its messages?</string>
<string name="detail_delete_dialog_permanently_delete">Permanently delete</string>
<string name="detail_delete_dialog_cancel">Cancel</string>
<!-- Detail activity: Action bar -->
<string name="detail_menu_delete">Delete topic</string>
</resources>