Add detail view back
This commit is contained in:
parent
785a36e257
commit
2a64f44916
17 changed files with 416 additions and 88 deletions
|
@ -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>
|
||||||
|
|
|
@ -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()) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}")
|
||||||
|
|
95
app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
Normal file
95
app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
57
app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
Normal file
57
app/src/main/java/io/heckel/ntfy/ui/DetailAdapter.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt
Normal file
40
app/src/main/java/io/heckel/ntfy/ui/DetailViewModel.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
29
app/src/main/res/layout/detail_activity.xml
Normal file
29
app/src/main/res/layout/detail_activity.xml
Normal 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>
|
28
app/src/main/res/layout/detail_fragment_item.xml
Normal file
28
app/src/main/res/layout/detail_fragment_item.xml
Normal 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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
3
app/src/main/res/menu/detail_action_bar_menu.xml
Normal file
3
app/src/main/res/menu/detail_action_bar_menu.xml
Normal 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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue