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

View file

@ -6,5 +6,5 @@ import io.heckel.ntfy.data.Repository
class Application : Application() { class Application : Application() {
private val database by lazy { Database.getInstance(this) } 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 package io.heckel.ntfy.data
import android.content.Context import android.content.Context
import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -9,12 +10,22 @@ data class Subscription(
@PrimaryKey val id: Long, // Internal ID, only used in Repository and activities @PrimaryKey val id: Long, // Internal ID, only used in Repository and activities
@ColumnInfo(name = "baseUrl") val baseUrl: String, @ColumnInfo(name = "baseUrl") val baseUrl: String,
@ColumnInfo(name = "topic") val topic: 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 class Database : RoomDatabase() {
abstract fun subscriptionDao(): SubscriptionDao abstract fun subscriptionDao(): SubscriptionDao
abstract fun notificationDao(): NotificationDao
companion object { companion object {
@Volatile @Volatile
@ -35,7 +46,7 @@ abstract class Database : RoomDatabase() {
@Dao @Dao
interface SubscriptionDao { interface SubscriptionDao {
@Query("SELECT * FROM subscription") @Query("SELECT * FROM subscription ORDER BY lastActive DESC")
fun list(): Flow<List<Subscription>> fun list(): Flow<List<Subscription>>
@Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic") @Query("SELECT * FROM subscription WHERE baseUrl = :baseUrl AND topic = :topic")
@ -47,6 +58,21 @@ interface SubscriptionDao {
@Update @Update
fun update(subscription: Subscription) fun update(subscription: Subscription)
@Delete @Query("DELETE FROM subscription WHERE id = :subscriptionId")
fun remove(subscription: Subscription) 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.LiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
class Repository(private val subscriptionDao: SubscriptionDao) { class Repository(private val subscriptionDao: SubscriptionDao, private val notificationDao: NotificationDao) {
fun list(): LiveData<List<Subscription>> { fun getAllSubscriptions(): LiveData<List<Subscription>> {
return subscriptionDao.list().asLiveData() return subscriptionDao.list().asLiveData()
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun get(baseUrl: String, topic: String): Subscription? { suspend fun getSubscription(baseUrl: String, topic: String): Subscription? {
return subscriptionDao.get(baseUrl, topic) return subscriptionDao.get(baseUrl, topic)
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun add(subscription: Subscription) { suspend fun addSubscription(subscription: Subscription) {
subscriptionDao.add(subscription) subscriptionDao.add(subscription)
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun update(subscription: Subscription) { suspend fun updateSubscription(subscription: Subscription) {
subscriptionDao.update(subscription) subscriptionDao.update(subscription)
} }
@Suppress("RedundantSuspendModifier") @Suppress("RedundantSuspendModifier")
@WorkerThread @WorkerThread
suspend fun remove(subscription: Subscription) { suspend fun removeSubscription(subscriptionId: Long) {
subscriptionDao.remove(subscription) 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 { companion object {
private var instance: Repository? = null private var instance: Repository? = null
fun getInstance(subscriptionDao: SubscriptionDao): Repository { fun getInstance(subscriptionDao: SubscriptionDao, notificationDao: NotificationDao): Repository {
return synchronized(Repository::class) { return synchronized(Repository::class) {
val newInstance = instance ?: Repository(subscriptionDao) val newInstance = instance ?: Repository(subscriptionDao, notificationDao)
instance = newInstance instance = newInstance
newInstance newInstance
} }

View file

@ -11,16 +11,18 @@ import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.data.Database import io.heckel.ntfy.data.Database
import io.heckel.ntfy.data.Notification
import io.heckel.ntfy.data.Repository import io.heckel.ntfy.data.Repository
import io.heckel.ntfy.data.topicShortUrl import io.heckel.ntfy.data.topicShortUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.*
import kotlin.random.Random import kotlin.random.Random
class MessagingService : FirebaseMessagingService() { class MessagingService : FirebaseMessagingService() {
private val database by lazy { Database.getInstance(this) } 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() private val job = SupervisorJob()
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
@ -32,9 +34,10 @@ class MessagingService : FirebaseMessagingService() {
// Check if valid data, and send notification // Check if valid data, and send notification
val data = remoteMessage.data val data = remoteMessage.data
val timestamp = data["time"]?.toLongOrNull()
val topic = data["topic"] val topic = data["topic"]
val message = data["message"] 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}") Log.d(TAG, "Discarding unexpected message: from=${remoteMessage.from}, data=${data}")
return return
} }
@ -43,9 +46,13 @@ class MessagingService : FirebaseMessagingService() {
val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL! val baseUrl = getString(R.string.app_base_url) // Everything from Firebase comes from main service URL!
// Update message counter // Update message counter
val subscription = repository.get(baseUrl, topic) ?: return@launch val subscription = repository.getSubscription(baseUrl, topic) ?: return@launch
val newSubscription = subscription.copy(messages = subscription.messages + 1) val newSubscription = subscription.copy(notifications = subscription.notifications + 1, lastActive = Date().time/1000)
repository.update(newSubscription) repository.updateSubscription(newSubscription)
// Add notification
val notification = Notification(id = Random.nextLong(), subscriptionId = subscription.id, timestamp = timestamp, message = message)
repository.addNotification(notification)
// Send notification // Send notification
Log.d(TAG, "Sending notification for message: from=${remoteMessage.from}, data=${data}") 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.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import android.view.View import android.view.View
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import io.heckel.ntfy.R import io.heckel.ntfy.R
import io.heckel.ntfy.app.Application import io.heckel.ntfy.app.Application
import io.heckel.ntfy.data.Subscription import io.heckel.ntfy.data.Subscription
import io.heckel.ntfy.data.topicShortUrl
import java.util.*
import kotlin.random.Random import kotlin.random.Random
class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener { class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
private val subscriptionsViewModel by viewModels<SubscriptionsViewModel> { private val viewModel by viewModels<SubscriptionsViewModel> {
SubscriptionsViewModelFactory((application as Application).repository) SubscriptionsViewModelFactory((application as Application).repository)
} }
@ -35,21 +35,21 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
onSubscribeButtonClick() onSubscribeButtonClick()
} }
// Update main list based on topicsViewModel (& its datasource/livedata) // Update main list based on viewModel (& its datasource/livedata)
val noSubscriptionsText: View = findViewById(R.id.main_no_subscriptions_text) val noEntriesText: View = findViewById(R.id.main_no_subscriptions_text)
val adapter = SubscriptionsAdapter { subscription -> onUnsubscribe(subscription) } val adapter = SubscriptionsAdapter { subscription -> onSubscriptionItemClick(subscription) }
val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list) val mainList: RecyclerView = findViewById(R.id.main_subscriptions_list)
mainList.adapter = adapter mainList.adapter = adapter
subscriptionsViewModel.list().observe(this) { viewModel.list().observe(this) {
it?.let { it?.let {
adapter.submitList(it as MutableList<Subscription>) adapter.submitList(it as MutableList<Subscription>)
if (it.isEmpty()) { if (it.isEmpty()) {
mainList.visibility = View.GONE mainList.visibility = View.GONE
noSubscriptionsText.visibility = View.VISIBLE noEntriesText.visibility = View.VISIBLE
} else { } else {
mainList.visibility = View.VISIBLE 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)))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.main_menu_source_url))))
true true
} }
R.id.menu_action_website -> { R.id.detail_menu_delete -> {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_base_url)))) startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(getString(R.string.app_base_url))))
true true
} }
@ -80,17 +80,35 @@ class MainActivity : AppCompatActivity(), AddFragment.AddSubscriptionListener {
} }
override fun onSubscribe(topic: String, baseUrl: String) { override fun onSubscribe(topic: String, baseUrl: String) {
val subscription = Subscription(id = Random.nextLong(), baseUrl = baseUrl, topic = topic, messages = 0) val subscription = Subscription(id = Random.nextLong(), baseUrl = baseUrl, topic = topic, notifications = 0, lastActive = Date().time/1000)
subscriptionsViewModel.add(subscription) viewModel.add(subscription)
FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl FirebaseMessaging.getInstance().subscribeToTopic(topic) // FIXME ignores baseUrl
} }
private fun onUnsubscribe(subscription: Subscription) { private fun onSubscriptionItemClick(subscription: Subscription) {
subscriptionsViewModel.remove(subscription) val intent = Intent(this, DetailActivity::class.java)
FirebaseMessaging.getInstance().unsubscribeFromTopic(subscription.topic) 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 { companion object {
const val TAG = "NtfyMainActivity" 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.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.PopupMenu
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter 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. */ /* 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) { RecyclerView.ViewHolder(itemView) {
private var subscription: Subscription? = null private var subscription: Subscription? = null
private val context: Context = itemView.context private val context: Context = itemView.context
private val nameView: TextView = itemView.findViewById(R.id.topic_text) private val nameView: TextView = itemView.findViewById(R.id.main_item_text)
private val statusView: TextView = itemView.findViewById(R.id.topic_status) private val statusView: TextView = itemView.findViewById(R.id.main_item_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
}
}
fun bind(subscription: Subscription) { fun bind(subscription: Subscription) {
this.subscription = subscription this.subscription = subscription
val statusMessage = if (subscription.messages == 1) { val statusMessage = if (subscription.notifications == 1) {
context.getString(R.string.main_item_status_text_one, subscription.messages) context.getString(R.string.main_item_status_text_one, subscription.notifications)
} else { } 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) nameView.text = topicShortUrl(subscription.baseUrl, subscription.topic)
statusView.text = statusMessage statusView.text = statusMessage
itemView.setOnClickListener { onClick(subscription) }
} }
} }

View file

@ -11,15 +11,15 @@ import kotlin.collections.List
class SubscriptionsViewModel(private val repository: Repository) : ViewModel() { class SubscriptionsViewModel(private val repository: Repository) : ViewModel() {
fun list(): LiveData<List<Subscription>> { fun list(): LiveData<List<Subscription>> {
return repository.list() return repository.getAllSubscriptions()
} }
fun add(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) { fun add(subscription: Subscription) = viewModelScope.launch(Dispatchers.IO) {
repository.add(topic) repository.addSubscription(subscription)
} }
fun remove(topic: Subscription) = viewModelScope.launch(Dispatchers.IO) { fun remove(subscriptionId: Long) = viewModelScope.launch(Dispatchers.IO) {
repository.remove(topic) 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 <TextView
android:text="ntfy.sh/example" android:text="ntfy.sh/example"
android:layout_width="match_parent" 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:layout_marginTop="16dp" android:layout_marginStart="12dp"
android:textColor="@color/primaryTextColor" android:textColor="@color/primaryTextColor"
android:textAppearance="@style/TextAppearance.AppCompat.Medium"/> android:textAppearance="@style/TextAppearance.AppCompat.Medium"/>
@ -23,7 +23,7 @@
<TextView <TextView
android:text="Subscribed, 0 notifications" android:text="Subscribed, 0 notifications"
android:layout_width="match_parent" 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"/> android:textAppearance="@style/TextAppearance.AppCompat.Small" android:layout_marginStart="12dp"/>
</LinearLayout> </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" > <menu xmlns:android="http://schemas.android.com/apk/res/android" >
<item android:id="@+id/menu_action_source" <item android:id="@+id/menu_action_source"
android:title="@string/main_menu_source_title"/> 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> </menu>

View file

@ -28,4 +28,13 @@
<string name="add_dialog_use_another_server">Use another server</string> <string name="add_dialog_use_another_server">Use another server</string>
<string name="add_dialog_button_cancel">Cancel</string> <string name="add_dialog_button_cancel">Cancel</string>
<string name="add_dialog_button_subscribe">Subscribe</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> </resources>