Notifications inbox (M489), Discover taste-arm fix, web tooling majors #151
@@ -0,0 +1,90 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.PUT
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications inbox and its per-user settings (M489). The server
|
||||||
|
* renders each notice's title, body and link, so the app shows them as given.
|
||||||
|
*/
|
||||||
|
interface NotificationsApi {
|
||||||
|
@GET("api/me/notifications")
|
||||||
|
suspend fun list(@Query("limit") limit: Int): NotificationsPageWire
|
||||||
|
|
||||||
|
/** 204; 404 when the notice is gone, which a replay treats as done. */
|
||||||
|
@POST("api/me/notifications/{id}/read")
|
||||||
|
suspend fun markRead(@Path("id") id: String)
|
||||||
|
|
||||||
|
@POST("api/me/notifications/read-all")
|
||||||
|
suspend fun readAll(@Body body: ReadAllBody)
|
||||||
|
|
||||||
|
@GET("api/me/notification-settings")
|
||||||
|
suspend fun getSettings(): NotificationSettingsWire
|
||||||
|
|
||||||
|
@PUT("api/me/notification-settings")
|
||||||
|
suspend fun putSettings(@Body body: PutNotificationSettingsBody): NotificationSettingsWire
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationWire(
|
||||||
|
val id: String,
|
||||||
|
val kind: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val link: String,
|
||||||
|
@SerialName("created_at") val createdAt: String,
|
||||||
|
@SerialName("read_at") val readAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationsPageWire(
|
||||||
|
val items: List<NotificationWire>,
|
||||||
|
@SerialName("unread_count") val unreadCount: Long,
|
||||||
|
@SerialName("next_before") val nextBefore: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `upTo` limits "mark all read" to what existed when the user asked, so a
|
||||||
|
* replay landing later leaves newer notices unread. No default on purpose:
|
||||||
|
* the app's Json drops default-valued fields.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ReadAllBody(@SerialName("up_to") val upTo: String?)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationKindSettingWire(
|
||||||
|
val kind: String,
|
||||||
|
@SerialName("admin_only") val adminOnly: Boolean,
|
||||||
|
val inbox: Boolean,
|
||||||
|
val phone: Boolean,
|
||||||
|
val email: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class NotificationSettingsWire(
|
||||||
|
val kinds: List<NotificationKindSettingWire>,
|
||||||
|
@SerialName("email_available") val emailAvailable: Boolean,
|
||||||
|
@SerialName("email_unavailable_reason") val emailUnavailableReason: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One kind's change. Untouched channels stay null and, being equal to their
|
||||||
|
* default, are left out of the JSON, so the server changes only the channel
|
||||||
|
* the user touched.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationSettingChangeWire(
|
||||||
|
val kind: String,
|
||||||
|
val inbox: Boolean? = null,
|
||||||
|
val phone: Boolean? = null,
|
||||||
|
val email: Boolean? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PutNotificationSettingsBody(val kinds: List<NotificationSettingChangeWire>)
|
||||||
+29
-1
@@ -13,6 +13,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedResumeStateDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
@@ -28,6 +29,8 @@ import com.fabledsword.minstrel.cache.db.entities.CachedHistorySnapshotEntity
|
|||||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedLikeEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedResumeStateEntity
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
@@ -66,7 +69,12 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
CachedHistorySnapshotEntity::class,
|
CachedHistorySnapshotEntity::class,
|
||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::class,
|
DiagnosticEventEntity::class,
|
||||||
|
CachedNotificationEntity::class,
|
||||||
|
CachedNotificationSettingsEntity::class,
|
||||||
],
|
],
|
||||||
|
// v11: + cached_notifications and cached_notification_settings, the
|
||||||
|
// notifications inbox and its settings (M489). MIGRATION_10_11 creates
|
||||||
|
// both; nothing to backfill, the first refresh fills them.
|
||||||
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
// v10: + cached_tracks.trackGain/trackPeak and cached_albums.albumGain/
|
||||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||||
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
// MIGRATION_9_10 also rewinds the sync cursor, so the next sync re-sends
|
||||||
@@ -82,7 +90,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// which is exactly right here: the next sync refills every row with the
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// new column populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 10,
|
version = 11,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -102,6 +110,7 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||||
|
abstract fun cachedNotificationDao(): CachedNotificationDao
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
/** v8 → v9: add the nullable normalization preference column (#4998). */
|
||||||
@@ -125,3 +134,22 @@ val MIGRATION_9_10: Migration = object : Migration(9, 10) {
|
|||||||
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
db.execSQL("UPDATE sync_metadata SET cursor = 0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v10 → v11: the notifications inbox cache and its settings (M489). The SQL
|
||||||
|
* matches what Room generates for the two entities; Room checks it on open.
|
||||||
|
*/
|
||||||
|
val MIGRATION_10_11: Migration = object : Migration(10, 11) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `cached_notifications` (" +
|
||||||
|
"`id` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, " +
|
||||||
|
"`body` TEXT NOT NULL, `link` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, " +
|
||||||
|
"`readAt` INTEGER, PRIMARY KEY(`id`))",
|
||||||
|
)
|
||||||
|
db.execSQL(
|
||||||
|
"CREATE TABLE IF NOT EXISTS `cached_notification_settings` (" +
|
||||||
|
"`id` INTEGER NOT NULL, `json` TEXT NOT NULL, PRIMARY KEY(`id`))",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+7
-1
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedHistorySnapshotDao
|
|||||||
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedHomeIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedLikeDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||||
@@ -37,7 +38,7 @@ object DatabaseModule {
|
|||||||
// launch, so users lose only the unsynced mutation queue
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// (acceptable while we're iterating). Replace with explicit
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
// Migration entries before the first tagged release.
|
// Migration entries before the first tagged release.
|
||||||
.addMigrations(MIGRATION_8_9, MIGRATION_9_10)
|
.addMigrations(MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11)
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
@@ -112,5 +113,10 @@ object DatabaseModule {
|
|||||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||||
db.diagnosticEventDao()
|
db.diagnosticEventDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
|
||||||
|
db.cachedNotificationDao()
|
||||||
|
|
||||||
private const val DATABASE_NAME = "minstrel.db"
|
private const val DATABASE_NAME = "minstrel.db"
|
||||||
}
|
}
|
||||||
|
|||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.dao
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface CachedNotificationDao {
|
||||||
|
@Query("SELECT * FROM cached_notifications ORDER BY createdAt DESC, id DESC")
|
||||||
|
fun observeAll(): Flow<List<CachedNotificationEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM cached_notifications WHERE readAt IS NULL")
|
||||||
|
fun observeUnreadCount(): Flow<Int>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cached_notifications")
|
||||||
|
suspend fun getAll(): List<CachedNotificationEntity>
|
||||||
|
|
||||||
|
@Query("DELETE FROM cached_notifications")
|
||||||
|
suspend fun clear()
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertAll(rows: List<CachedNotificationEntity>)
|
||||||
|
|
||||||
|
/** The newest page replaces the cache whole: a notice gone server-side goes here too. */
|
||||||
|
@Transaction
|
||||||
|
suspend fun replaceAll(rows: List<CachedNotificationEntity>) {
|
||||||
|
clear()
|
||||||
|
insertAll(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("UPDATE cached_notifications SET readAt = :at WHERE id = :id AND readAt IS NULL")
|
||||||
|
suspend fun markRead(id: String, at: Instant)
|
||||||
|
|
||||||
|
@Query("UPDATE cached_notifications SET readAt = :at WHERE readAt IS NULL")
|
||||||
|
suspend fun markAllRead(at: Instant)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
||||||
|
fun observeSettings(): Flow<CachedNotificationSettingsEntity?>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM cached_notification_settings WHERE id = 1")
|
||||||
|
suspend fun getSettings(): CachedNotificationSettingsEntity?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertSettings(row: CachedNotificationSettingsEntity)
|
||||||
|
}
|
||||||
Vendored
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One notice from the user's inbox (M489), kept so the Notifications screen
|
||||||
|
* and the bell's badge work offline, and so a read made offline shows at once.
|
||||||
|
* The newest page is cached; older notices are the server's to keep.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cached_notifications")
|
||||||
|
data class CachedNotificationEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val kind: String,
|
||||||
|
val title: String,
|
||||||
|
val body: String,
|
||||||
|
val link: String,
|
||||||
|
val createdAt: Instant,
|
||||||
|
val readAt: Instant?,
|
||||||
|
)
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.db.entities
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-row copy of the user's notification settings (M489), stored as the
|
||||||
|
* wire JSON, so the settings screen opens offline and a toggle shows at once.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "cached_notification_settings")
|
||||||
|
data class CachedNotificationSettingsEntity(
|
||||||
|
@PrimaryKey val id: Int = SINGLETON_ID,
|
||||||
|
val json: String,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val SINGLETON_ID = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
@@ -47,6 +48,17 @@ object MutationKind {
|
|||||||
// preference, a target state like the toggles above, so queued changes
|
// preference, a target state like the toggles above, so queued changes
|
||||||
// collapse to the last one and an older one can never be replayed last.
|
// collapse to the last one and an older one can never be replayed last.
|
||||||
const val NORMALIZATION_SET: String = "normalization_set"
|
const val NORMALIZATION_SET: String = "normalization_set"
|
||||||
|
|
||||||
|
// M489 notifications inbox. A read is a one-way action (read never goes
|
||||||
|
// back to unread), so neither read kind needs collapsing. Read-all
|
||||||
|
// carries the moment the user asked, so a late replay leaves newer
|
||||||
|
// notices unread.
|
||||||
|
const val NOTIFICATION_READ: String = "notification_read"
|
||||||
|
const val NOTIFICATIONS_READ_ALL: String = "notifications_read_all"
|
||||||
|
|
||||||
|
// M489 per-kind channel setting. One row per (kind, channel) target
|
||||||
|
// state, collapsed on that pair, so the newest choice is the one sent.
|
||||||
|
const val NOTIFICATION_SETTING_SET: String = "notification_setting_set"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,6 +202,25 @@ class MutationQueue @Inject constructor(
|
|||||||
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
json.encodeToString(NormalizationPrefs.serializer(), prefs),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueNotificationRead(id: String): Long = insertUserDriven(
|
||||||
|
MutationKind.NOTIFICATION_READ,
|
||||||
|
json.encodeToString(NotificationReadPayload.serializer(), NotificationReadPayload(id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueNotificationsReadAll(upToIso: String?): Long = insertUserDriven(
|
||||||
|
MutationKind.NOTIFICATIONS_READ_ALL,
|
||||||
|
json.encodeToString(
|
||||||
|
NotificationsReadAllPayload.serializer(),
|
||||||
|
NotificationsReadAllPayload(upToIso),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
suspend fun enqueueNotificationSettingSet(payload: NotificationSettingPayload): Long =
|
||||||
|
insertUserDriven(
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET,
|
||||||
|
json.encodeToString(NotificationSettingPayload.serializer(), payload),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
@@ -335,3 +366,36 @@ data class PlaybackErrorReportPayload(
|
|||||||
val detail: String? = null,
|
val detail: String? = null,
|
||||||
val clientId: String,
|
val clientId: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Persisted payload for `MutationKind.NOTIFICATION_READ` (M489). */
|
||||||
|
@Serializable
|
||||||
|
data class NotificationReadPayload(val id: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.NOTIFICATIONS_READ_ALL` (M489).
|
||||||
|
* `upToIso` is the newest notice the user could see when they asked; null
|
||||||
|
* when the inbox was empty on the device, which marks everything.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationsReadAllPayload(val upToIso: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.NOTIFICATION_SETTING_SET` (M489): one
|
||||||
|
* kind's one channel, as a target state. `channel` is "inbox" | "phone" |
|
||||||
|
* "email". No defaults, so every field is always written.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class NotificationSettingPayload(
|
||||||
|
val kind: String,
|
||||||
|
val channel: String,
|
||||||
|
val value: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The wire change for one queued channel setting, or null for an unknown channel. */
|
||||||
|
internal fun notificationSettingChange(p: NotificationSettingPayload): NotificationSettingChangeWire? =
|
||||||
|
when (p.channel) {
|
||||||
|
"inbox" -> NotificationSettingChangeWire(kind = p.kind, inbox = p.value)
|
||||||
|
"phone" -> NotificationSettingChangeWire(kind = p.kind, phone = p.value)
|
||||||
|
"email" -> NotificationSettingChangeWire(kind = p.kind, email = p.value)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
+32
@@ -8,6 +8,9 @@ import com.fabledsword.minstrel.api.endpoints.EventsApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.MeApi
|
import com.fabledsword.minstrel.api.endpoints.MeApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||||
@@ -82,6 +85,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val requestsApi: RequestsApi = retrofit.create()
|
private val requestsApi: RequestsApi = retrofit.create()
|
||||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||||
private val meApi: MeApi = retrofit.create()
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
private val notificationsApi: NotificationsApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -170,6 +174,9 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
|
||||||
|
MutationKind.NOTIFICATION_READ -> dispatchNotificationRead(row.payload)
|
||||||
|
MutationKind.NOTIFICATIONS_READ_ALL -> dispatchNotificationsReadAll(row.payload)
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET -> dispatchNotificationSettingSet(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -293,6 +300,27 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A 404 (the notice was trimmed or already gone) is a 4xx, so DROP: nothing left to do. */
|
||||||
|
private suspend fun dispatchNotificationRead(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(NotificationReadPayload.serializer(), payload)
|
||||||
|
notificationsApi.markRead(decoded.id)
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun dispatchNotificationsReadAll(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(NotificationsReadAllPayload.serializer(), payload)
|
||||||
|
notificationsApi.readAll(ReadAllBody(upTo = decoded.upToIso))
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unknown channel can only come from a corrupt row: DROP it. */
|
||||||
|
private suspend fun dispatchNotificationSettingSet(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(NotificationSettingPayload.serializer(), payload)
|
||||||
|
val change = notificationSettingChange(decoded) ?: return Outcome.DROP
|
||||||
|
notificationsApi.putSettings(PutNotificationSettingsBody(listOf(change)))
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -355,6 +383,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
|||||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
MutationKind.NOTIFICATION_SETTING_SET -> runCatching {
|
||||||
|
json.decodeFromString(NotificationSettingPayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.kind}:${it.channel}" }
|
||||||
|
|
||||||
// One preference per user, so every normalization row shares one key.
|
// One preference per user, so every normalization row shares one key.
|
||||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
|
|||||||
import androidx.lifecycle.ProcessLifecycleOwner
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -30,6 +31,7 @@ import javax.inject.Singleton
|
|||||||
class LiveEventsDispatcher @Inject constructor(
|
class LiveEventsDispatcher @Inject constructor(
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
) : DefaultLifecycleObserver {
|
) : DefaultLifecycleObserver {
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
"artist.liked",
|
"artist.liked",
|
||||||
"artist.unliked",
|
"artist.unliked",
|
||||||
-> refreshLikes()
|
-> refreshLikes()
|
||||||
|
// M489: a contentless nudge; the inbox refetches its newest page.
|
||||||
|
"notification.created" -> refreshNotifications()
|
||||||
}
|
}
|
||||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||||
@@ -61,6 +65,13 @@ class LiveEventsDispatcher @Inject constructor(
|
|||||||
// not have reconnected yet; flush the cross-screen refreshes
|
// not have reconnected yet; flush the cross-screen refreshes
|
||||||
// defensively.
|
// defensively.
|
||||||
refreshLikes()
|
refreshLikes()
|
||||||
|
refreshNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshNotifications() {
|
||||||
|
scope.launch {
|
||||||
|
runCatching { notifications.refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshLikes() {
|
private fun refreshLikes() {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import com.fabledsword.minstrel.home.ui.HomeScreen
|
|||||||
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
|
import com.fabledsword.minstrel.library.ui.AlbumDetailScreen
|
||||||
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
import com.fabledsword.minstrel.library.ui.ArtistDetailScreen
|
||||||
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
import com.fabledsword.minstrel.library.ui.LibraryScreen
|
||||||
|
import com.fabledsword.minstrel.notifications.ui.NotificationSettingsScreen
|
||||||
|
import com.fabledsword.minstrel.notifications.ui.NotificationsScreen
|
||||||
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
import com.fabledsword.minstrel.player.ui.NowPlayingScreen
|
||||||
import com.fabledsword.minstrel.player.ui.QueueScreen
|
import com.fabledsword.minstrel.player.ui.QueueScreen
|
||||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||||
@@ -59,6 +61,7 @@ fun MinstrelNavGraph(
|
|||||||
) {
|
) {
|
||||||
inShellTopLevel(navController, expandPlayer)
|
inShellTopLevel(navController, expandPlayer)
|
||||||
inShellDetail(navController, expandPlayer)
|
inShellDetail(navController, expandPlayer)
|
||||||
|
inShellNotifications(navController, expandPlayer)
|
||||||
outsideShell(navController)
|
outsideShell(navController)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,6 +203,27 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The notifications inbox and its settings (M489). */
|
||||||
|
private fun NavGraphBuilder.inShellNotifications(
|
||||||
|
navController: NavHostController,
|
||||||
|
expandPlayer: () -> Unit,
|
||||||
|
) {
|
||||||
|
composable<Notifications> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
NotificationsScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable<NotificationSettings> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
NotificationSettingsScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
composable<NowPlaying>(
|
composable<NowPlaying>(
|
||||||
// Slide up from the bottom on enter; back down on dismiss.
|
// Slide up from the bottom on enter; back down on dismiss.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object Settings
|
@Serializable data object Settings
|
||||||
@Serializable data object Admin
|
@Serializable data object Admin
|
||||||
@Serializable data object Requests
|
@Serializable data object Requests
|
||||||
|
@Serializable data object Notifications
|
||||||
|
@Serializable data object NotificationSettings
|
||||||
|
|
||||||
// ── In-shell detail / push-on-top destinations ────────────────────────
|
// ── In-shell detail / push-on-top destinations ────────────────────────
|
||||||
|
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.notificationSettingChange
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/** A notification channel, as the server names it. */
|
||||||
|
enum class NotificationChannel(val wire: String) { INBOX("inbox"), PHONE("phone"), EMAIL("email") }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user notification settings (M489), following snippet #5107: the device
|
||||||
|
* copy (a cached JSON row) shows a change at once, the PUT is best effort,
|
||||||
|
* and a failed or out-of-order one is queued for the MutationReplayer.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NotificationSettingsRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
private val dao: CachedNotificationDao,
|
||||||
|
private val mutationDao: CachedMutationDao,
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
private val json: Json,
|
||||||
|
) {
|
||||||
|
private val api: NotificationsApi = retrofit.create()
|
||||||
|
|
||||||
|
val settings: Flow<NotificationSettingsWire?> = dao.observeSettings().map { it?.let(::decode) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes the server's settings unless a change made here is still queued:
|
||||||
|
* the server has not seen it, and its older value would undo the change
|
||||||
|
* on screen until the replay lands. Throws on a network failure.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() {
|
||||||
|
val server = api.getSettings()
|
||||||
|
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) return
|
||||||
|
save(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||||
|
current()?.let { save(it.withChannel(kind, channel, value)) }
|
||||||
|
val payload = NotificationSettingPayload(kind = kind, channel = channel.wire, value = value)
|
||||||
|
// An earlier change still queued would replay after this PUT; queue
|
||||||
|
// behind it instead, and the replayer sends the newest per channel.
|
||||||
|
if (mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET)) {
|
||||||
|
mutationQueue.enqueueNotificationSettingSet(payload)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val change = notificationSettingChange(payload) ?: return
|
||||||
|
try {
|
||||||
|
save(api.putSettings(PutNotificationSettingsBody(listOf(change))))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notification settings: PUT failed; queued")
|
||||||
|
mutationQueue.enqueueNotificationSettingSet(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun current(): NotificationSettingsWire? = dao.getSettings()?.let(::decode)
|
||||||
|
|
||||||
|
/** Null for an unreadable row: the next refresh writes a good one. */
|
||||||
|
private fun decode(row: CachedNotificationSettingsEntity): NotificationSettingsWire? =
|
||||||
|
runCatching { json.decodeFromString(NotificationSettingsWire.serializer(), row.json) }.getOrNull()
|
||||||
|
|
||||||
|
private suspend fun save(s: NotificationSettingsWire) {
|
||||||
|
val encoded = json.encodeToString(NotificationSettingsWire.serializer(), s)
|
||||||
|
dao.upsertSettings(CachedNotificationSettingsEntity(json = encoded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun NotificationSettingsWire.withChannel(
|
||||||
|
kind: String,
|
||||||
|
channel: NotificationChannel,
|
||||||
|
value: Boolean,
|
||||||
|
): NotificationSettingsWire = copy(
|
||||||
|
kinds = kinds.map { k ->
|
||||||
|
if (k.kind != kind) {
|
||||||
|
k
|
||||||
|
} else {
|
||||||
|
when (channel) {
|
||||||
|
NotificationChannel.INBOX -> k.copy(inbox = value)
|
||||||
|
NotificationChannel.PHONE -> k.copy(phone = value)
|
||||||
|
NotificationChannel.EMAIL -> k.copy(email = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import timber.log.Timber
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications inbox (M489). The newest page lives in Room so the bell's
|
||||||
|
* badge and the Notifications screen work offline. Reads follow rule 100: the
|
||||||
|
* device marks the row at once, the call is best effort, and a failed call is
|
||||||
|
* queued for the MutationReplayer.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NotificationsRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
private val dao: CachedNotificationDao,
|
||||||
|
private val mutationQueue: MutationQueue,
|
||||||
|
) {
|
||||||
|
private val api: NotificationsApi = retrofit.create()
|
||||||
|
|
||||||
|
val notifications: Flow<List<CachedNotificationEntity>> = dao.observeAll().distinctUntilChanged()
|
||||||
|
|
||||||
|
/** Unread notices in the cached page, which is what the badge shows. */
|
||||||
|
val unreadCount: Flow<Int> = dao.observeUnreadCount().distinctUntilChanged()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the cache with the server's newest page. A read made on this
|
||||||
|
* device and not yet replayed stays read: a read never goes back to
|
||||||
|
* unread, so the device's mark wins over the server's older view.
|
||||||
|
* Throws on a network failure; the cache then stands.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() {
|
||||||
|
val page = api.list(limit = PAGE_SIZE)
|
||||||
|
val localReads = dao.getAll().associate { it.id to it.readAt }
|
||||||
|
dao.replaceAll(page.items.mapNotNull { it.toEntity(localReads[it.id]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun markRead(id: String) {
|
||||||
|
dao.markRead(id, Clock.System.now())
|
||||||
|
try {
|
||||||
|
api.markRead(id)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// A 4xx (404: trimmed or gone server-side) leaves nothing to mark;
|
||||||
|
// a 5xx is the server's trouble, so the read waits in the queue.
|
||||||
|
if (e.code() >= HTTP_SERVER_ERROR) {
|
||||||
|
mutationQueue.enqueueNotificationRead(id)
|
||||||
|
} else {
|
||||||
|
Timber.i(e, "notifications: mark read refused (%d)", e.code())
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notifications: mark read failed; queued")
|
||||||
|
mutationQueue.enqueueNotificationRead(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks everything the device holds read. The server is asked to mark
|
||||||
|
* only what existed up to the newest notice shown here, so a replay that
|
||||||
|
* lands later leaves anything newer unread.
|
||||||
|
*/
|
||||||
|
suspend fun markAllRead() {
|
||||||
|
val upTo = readAllCutoff(dao.getAll().map { it.createdAt })?.toString()
|
||||||
|
dao.markAllRead(Clock.System.now())
|
||||||
|
try {
|
||||||
|
api.readAll(ReadAllBody(upTo = upTo))
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
Timber.i(e, "notifications: mark all read failed; queued")
|
||||||
|
mutationQueue.enqueueNotificationsReadAll(upTo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On sign-out: the next account on this device must not see these. */
|
||||||
|
suspend fun clearLocal() {
|
||||||
|
dao.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The newest page the device keeps; older notices stay on the server. */
|
||||||
|
const val PAGE_SIZE = 50
|
||||||
|
|
||||||
|
private const val HTTP_SERVER_ERROR = 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest notice shown, rounded up a millisecond. Room keeps milliseconds
|
||||||
|
* and the server microseconds, so the stored time can sit just before the
|
||||||
|
* server's; without rounding up, the newest notice would stay unread.
|
||||||
|
*/
|
||||||
|
internal fun readAllCutoff(createdAts: List<Instant>): Instant? =
|
||||||
|
createdAts.maxOrNull()?.plus(1.milliseconds)
|
||||||
|
|
||||||
|
/** Null for a row whose timestamp will not parse, rather than failing the page. */
|
||||||
|
internal fun NotificationWire.toEntity(localReadAt: Instant?): CachedNotificationEntity? {
|
||||||
|
val created = runCatching { Instant.parse(createdAt) }.getOrNull() ?: return null
|
||||||
|
val serverRead = readAt?.let { runCatching { Instant.parse(it) }.getOrNull() }
|
||||||
|
return CachedNotificationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = kind,
|
||||||
|
title = title,
|
||||||
|
body = body,
|
||||||
|
link = link,
|
||||||
|
createdAt = created,
|
||||||
|
readAt = serverRead ?: localReadAt,
|
||||||
|
)
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app screen for a notice's link. The server writes web paths, the same
|
||||||
|
* for every client; this maps them onto the app's routes. Admin pages the app
|
||||||
|
* has no screen for (missing files, duplicates, playback errors) open the
|
||||||
|
* Admin landing; anything unknown opens nothing.
|
||||||
|
*/
|
||||||
|
internal fun routeForLink(link: String): Any? {
|
||||||
|
val parts = link.trim('/').split('/').filter { it.isNotEmpty() }
|
||||||
|
return when {
|
||||||
|
parts.size == 2 && parts[0] == "albums" -> AlbumDetail(parts[1])
|
||||||
|
parts.size == 2 && parts[0] == "artists" -> ArtistDetail(parts[1])
|
||||||
|
parts == listOf("requests") -> Requests
|
||||||
|
parts == listOf("admin", "requests") -> AdminRequests
|
||||||
|
parts == listOf("admin", "quarantine") -> AdminQuarantine
|
||||||
|
parts.firstOrNull() == "admin" -> Admin
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
@file:Suppress("TooManyFunctions") // Compose screen + private row composables
|
||||||
|
|
||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ChevronRight
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
|
|
||||||
|
private val CELL_WIDTH = 56.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user notification settings (M489): a row per kind with a box for each
|
||||||
|
* channel. Rows read as a menu (rule 188); anything that needs explaining
|
||||||
|
* is one short line.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationSettingsScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||||
|
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
// Re-read on every resume: the user may come back from system settings.
|
||||||
|
var phoneAllowed by remember { mutableStateOf(true) }
|
||||||
|
LifecycleResumeEffect(Unit) {
|
||||||
|
phoneAllowed = NotificationManagerCompat.from(context).areNotificationsEnabled()
|
||||||
|
onPauseOrDispose { }
|
||||||
|
}
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Notifications",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = NotificationSettings::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
val s = settings
|
||||||
|
if (s == null) {
|
||||||
|
LoadingCentered(modifier = Modifier.padding(inner))
|
||||||
|
} else {
|
||||||
|
SettingsBody(
|
||||||
|
settings = s,
|
||||||
|
isAdmin = isAdmin,
|
||||||
|
// Null while the system lets Minstrel post notifications.
|
||||||
|
onPhoneBlocked = if (phoneAllowed) null else ({ openAppNotificationSettings(context) }),
|
||||||
|
onSet = viewModel::set,
|
||||||
|
modifier = Modifier.padding(inner),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsBody(
|
||||||
|
settings: NotificationSettingsWire,
|
||||||
|
isAdmin: Boolean,
|
||||||
|
onPhoneBlocked: (() -> Unit)?,
|
||||||
|
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
if (onPhoneBlocked != null) {
|
||||||
|
PhoneBlockedRow(onClick = onPhoneBlocked)
|
||||||
|
}
|
||||||
|
HeaderRow()
|
||||||
|
settings.kinds.filter { !it.adminOnly }.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
|
val adminKinds = settings.kinds.filter { it.adminOnly }
|
||||||
|
if (adminKinds.isNotEmpty()) {
|
||||||
|
SectionLabel("Library health")
|
||||||
|
adminKinds.forEach { KindRow(it, settings.emailAvailable, onSet) }
|
||||||
|
}
|
||||||
|
if (!settings.emailAvailable) {
|
||||||
|
Muted(emailUnavailableLine(settings.emailUnavailableReason, isAdmin))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HeaderRow() {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
NotificationChannel.entries.forEach { c ->
|
||||||
|
Text(
|
||||||
|
text = channelLabel(c),
|
||||||
|
modifier = Modifier.width(CELL_WIDTH),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KindRow(
|
||||||
|
row: NotificationKindSettingWire,
|
||||||
|
emailAvailable: Boolean,
|
||||||
|
onSet: (String, NotificationChannel, Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
val label = kindLabel(row.kind)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(label, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge)
|
||||||
|
NotificationChannel.entries.forEach { c ->
|
||||||
|
val enabled = channelEnabled(row, c, emailAvailable)
|
||||||
|
Box(Modifier.width(CELL_WIDTH), contentAlignment = Alignment.Center) {
|
||||||
|
Checkbox(
|
||||||
|
checked = channelValue(row, c) && enabled,
|
||||||
|
onCheckedChange = { onSet(row.kind, c, it) },
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.semantics { contentDescription = "$label: ${channelLabel(c)}" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhoneBlockedRow(onClick: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text("Phone alerts are off", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
Muted("Allow notifications for Minstrel in system settings", padded = false)
|
||||||
|
}
|
||||||
|
Icon(Lucide.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Muted(text: String, padded: Boolean = true) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
modifier = if (padded) Modifier.padding(horizontal = 16.dp, vertical = 12.dp) else Modifier,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openAppNotificationSettings(context: Context) {
|
||||||
|
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
|
||||||
|
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Phone and email ride on the inbox; email also needs an address and SMTP. */
|
||||||
|
internal fun channelEnabled(
|
||||||
|
row: NotificationKindSettingWire,
|
||||||
|
channel: NotificationChannel,
|
||||||
|
emailAvailable: Boolean,
|
||||||
|
): Boolean = when (channel) {
|
||||||
|
NotificationChannel.INBOX -> true
|
||||||
|
NotificationChannel.PHONE -> row.inbox
|
||||||
|
NotificationChannel.EMAIL -> row.inbox && emailAvailable
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun channelValue(row: NotificationKindSettingWire, channel: NotificationChannel): Boolean =
|
||||||
|
when (channel) {
|
||||||
|
NotificationChannel.INBOX -> row.inbox
|
||||||
|
NotificationChannel.PHONE -> row.phone
|
||||||
|
NotificationChannel.EMAIL -> row.email
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun channelLabel(c: NotificationChannel): String = when (c) {
|
||||||
|
NotificationChannel.INBOX -> "Inbox"
|
||||||
|
NotificationChannel.PHONE -> "Phone"
|
||||||
|
NotificationChannel.EMAIL -> "Email"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short row labels, the same as the web's. Unknown kinds show their key. */
|
||||||
|
internal fun kindLabel(kind: String): String = when (kind) {
|
||||||
|
"request_approved" -> "Request approved"
|
||||||
|
"request_rejected" -> "Request declined"
|
||||||
|
"request_completed" -> "New music arrived"
|
||||||
|
"request_pending" -> "Requests to review"
|
||||||
|
"quarantine_flagged" -> "Tracks flagged"
|
||||||
|
"scan_failed" -> "Library scan failed"
|
||||||
|
"tracks_missing" -> "Tracks gone missing"
|
||||||
|
"duplicates_found" -> "Duplicates to review"
|
||||||
|
"playback_errors" -> "Playback errors"
|
||||||
|
else -> kind
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun emailUnavailableLine(reason: String?, isAdmin: Boolean): String = when {
|
||||||
|
reason == "no_address" -> "Email is off: add an email address in your profile"
|
||||||
|
isAdmin -> "Email is off: SMTP isn't set up on the server"
|
||||||
|
else -> "Email is off: this server doesn't send email"
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationSettingsRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationSettingsViewModel @Inject constructor(
|
||||||
|
private val repository: NotificationSettingsRepository,
|
||||||
|
authController: AuthController,
|
||||||
|
) : ViewModel() {
|
||||||
|
val settings: StateFlow<NotificationSettingsWire?> = repository.settings
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
||||||
|
|
||||||
|
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||||
|
.map { it?.isAdmin == true }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Offline, the device's copy stands.
|
||||||
|
viewModelScope.launch { runCatching { repository.refresh() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(kind: String, channel: NotificationChannel, value: Boolean) {
|
||||||
|
viewModelScope.launch { repository.set(kind, channel, value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+155
@@ -0,0 +1,155 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.Bell
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ShellContentWindowInsets
|
||||||
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.time.Duration.Companion.days
|
||||||
|
import kotlin.time.Duration.Companion.hours
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notifications inbox (M489): one row per notice, its title on one line
|
||||||
|
* and how long ago. Tapping a row marks it read and opens what it is about.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun NotificationsScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: NotificationsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val items by viewModel.items.collectAsStateWithLifecycle()
|
||||||
|
val anyUnread = items?.any { it.readAt == null } == true
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ShellContentWindowInsets,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Notifications",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = Notifications::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
actions = {
|
||||||
|
if (anyUnread) {
|
||||||
|
TextButton(onClick = viewModel::markAllRead) { Text("Mark all read") }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
|
val list = items
|
||||||
|
when {
|
||||||
|
list == null -> LoadingCentered()
|
||||||
|
list.isEmpty() -> EmptyState(
|
||||||
|
title = "Nothing waiting for you",
|
||||||
|
body = "Requests, new music and anything needing a look will gather here.",
|
||||||
|
icon = Lucide.Bell,
|
||||||
|
)
|
||||||
|
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(list, key = { it.id }) { item ->
|
||||||
|
NotificationRow(
|
||||||
|
item = item,
|
||||||
|
onClick = {
|
||||||
|
viewModel.open(item)
|
||||||
|
routeForLink(item.link)?.let { navController.navigate(it) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotificationRow(item: CachedNotificationEntity, onClick: () -> Unit) {
|
||||||
|
val unread = item.readAt == null
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 14.dp)
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
if (unread) contentDescription = "Unread: ${item.title}"
|
||||||
|
},
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.then(
|
||||||
|
if (unread) {
|
||||||
|
Modifier.background(MaterialTheme.colorScheme.onSurface, CircleShape)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
fontWeight = if (unread) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
color = if (unread) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = ago(item.createdAt),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "just now", "12m", "5h", "3d": the same coarse steps as the web inbox. */
|
||||||
|
internal fun ago(at: Instant, now: Instant = Clock.System.now()): String {
|
||||||
|
val d = now - at
|
||||||
|
return when {
|
||||||
|
d >= 1.days -> "${d.inWholeDays}d"
|
||||||
|
d >= 1.hours -> "${d.inWholeHours}h"
|
||||||
|
d >= 1.minutes -> "${d.inWholeMinutes}m"
|
||||||
|
else -> "just now"
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
|
/** `items` is null until the cache has been read once, so the screen can tell "loading" from "empty". */
|
||||||
|
@HiltViewModel
|
||||||
|
class NotificationsViewModel @Inject constructor(
|
||||||
|
private val repository: NotificationsRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
|
) : ViewModel() {
|
||||||
|
val items: StateFlow<List<CachedNotificationEntity>?> = repository.notifications
|
||||||
|
.map<List<CachedNotificationEntity>, List<CachedNotificationEntity>?> { it }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS), null)
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
// Back online: the cached page may be stale (#1245's recovery idiom).
|
||||||
|
viewModelScope.launch { networkStatus.recoveries().collect { refresh() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offline, the cached page stands. */
|
||||||
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
runCatching { repository.refresh() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun open(item: CachedNotificationEntity) {
|
||||||
|
if (item.readAt != null) return
|
||||||
|
viewModelScope.launch { repository.markRead(item.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAllRead() {
|
||||||
|
viewModelScope.launch { repository.markAllRead() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.Bell
|
||||||
import com.composables.icons.lucide.ChevronRight
|
import com.composables.icons.lucide.ChevronRight
|
||||||
import com.composables.icons.lucide.ListMusic
|
import com.composables.icons.lucide.ListMusic
|
||||||
import com.composables.icons.lucide.LogOut
|
import com.composables.icons.lucide.LogOut
|
||||||
@@ -52,6 +53,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Shield
|
import com.composables.icons.lucide.Shield
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
|
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||||
import com.fabledsword.minstrel.nav.ServerUrl
|
import com.fabledsword.minstrel.nav.ServerUrl
|
||||||
@@ -98,6 +100,7 @@ fun SettingsScreen(
|
|||||||
themeMode = themeMode,
|
themeMode = themeMode,
|
||||||
onPickTheme = themeVm::setThemeMode,
|
onPickTheme = themeVm::setThemeMode,
|
||||||
onNavToRequests = { navController.navigate(Requests) },
|
onNavToRequests = { navController.navigate(Requests) },
|
||||||
|
onNavToNotifications = { navController.navigate(NotificationSettings) },
|
||||||
onNavToAdmin = { navController.navigate(Admin) },
|
onNavToAdmin = { navController.navigate(Admin) },
|
||||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||||
onSignOutClick = { showSignOutConfirm = true },
|
onSignOutClick = { showSignOutConfirm = true },
|
||||||
@@ -121,6 +124,7 @@ private fun SettingsList(
|
|||||||
themeMode: ThemeMode,
|
themeMode: ThemeMode,
|
||||||
onPickTheme: (ThemeMode) -> Unit,
|
onPickTheme: (ThemeMode) -> Unit,
|
||||||
onNavToRequests: () -> Unit,
|
onNavToRequests: () -> Unit,
|
||||||
|
onNavToNotifications: () -> Unit,
|
||||||
onNavToAdmin: () -> Unit,
|
onNavToAdmin: () -> Unit,
|
||||||
onToggleDiagnostics: (Boolean) -> Unit,
|
onToggleDiagnostics: (Boolean) -> Unit,
|
||||||
onSignOutClick: () -> Unit,
|
onSignOutClick: () -> Unit,
|
||||||
@@ -144,6 +148,12 @@ private fun SettingsList(
|
|||||||
subtitle = "Track what you've asked Minstrel to add",
|
subtitle = "Track what you've asked Minstrel to add",
|
||||||
onClick = onNavToRequests,
|
onClick = onNavToRequests,
|
||||||
)
|
)
|
||||||
|
NavTile(
|
||||||
|
icon = Lucide.Bell,
|
||||||
|
title = "Notifications",
|
||||||
|
subtitle = "What reaches you, and where",
|
||||||
|
onClick = onNavToNotifications,
|
||||||
|
)
|
||||||
if (state.isAdmin) {
|
if (state.isAdmin) {
|
||||||
NavTile(
|
NavTile(
|
||||||
icon = Lucide.Shield,
|
icon = Lucide.Shield,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.auth.AuthController
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -28,6 +29,7 @@ data class SettingsState(
|
|||||||
class SettingsViewModel @Inject constructor(
|
class SettingsViewModel @Inject constructor(
|
||||||
private val authController: AuthController,
|
private val authController: AuthController,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
|
private val notifications: NotificationsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val transient = MutableStateFlow(TransientState())
|
private val transient = MutableStateFlow(TransientState())
|
||||||
@@ -70,6 +72,8 @@ class SettingsViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
transient.update { it.copy(isSigningOut = true) }
|
transient.update { it.copy(isSigningOut = true) }
|
||||||
authController.signOut()
|
authController.signOut()
|
||||||
|
// The inbox is this account's; the next one on the device must not see it.
|
||||||
|
runCatching { notifications.clearLocal() }
|
||||||
transient.update { it.copy(isSigningOut = false, signedOut = true) }
|
transient.update { it.copy(isSigningOut = false, signedOut = true) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.shared.widgets
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.auth.AuthController
|
import com.fabledsword.minstrel.auth.AuthController
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -22,6 +23,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AppBarActionsViewModel @Inject constructor(
|
class AppBarActionsViewModel @Inject constructor(
|
||||||
authController: AuthController,
|
authController: AuthController,
|
||||||
|
notifications: NotificationsRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||||
.map { it?.isAdmin == true }
|
.map { it?.isAdmin == true }
|
||||||
@@ -30,4 +32,12 @@ class AppBarActionsViewModel @Inject constructor(
|
|||||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Unread notices for the bell's badge (M489). */
|
||||||
|
val unreadCount: StateFlow<Int> = notifications.unreadCount
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = 0,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
@@ -1,10 +1,13 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.Bell
|
||||||
import com.composables.icons.lucide.House
|
import com.composables.icons.lucide.House
|
||||||
import com.composables.icons.lucide.LibraryBig
|
import com.composables.icons.lucide.LibraryBig
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
@@ -23,6 +27,7 @@ import com.fabledsword.minstrel.nav.Admin
|
|||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.nav.Home
|
import com.fabledsword.minstrel.nav.Home
|
||||||
import com.fabledsword.minstrel.nav.Library
|
import com.fabledsword.minstrel.nav.Library
|
||||||
|
import com.fabledsword.minstrel.nav.Notifications
|
||||||
import com.fabledsword.minstrel.nav.Playlists
|
import com.fabledsword.minstrel.nav.Playlists
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||||
@@ -50,6 +55,7 @@ fun MainAppBarActions(
|
|||||||
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||||
|
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
|
||||||
Row {
|
Row {
|
||||||
if (currentRouteName != Home::class.qualifiedName) {
|
if (currentRouteName != Home::class.qualifiedName) {
|
||||||
IconButton(onClick = { navController.navigate(Home) }) {
|
IconButton(onClick = { navController.navigate(Home) }) {
|
||||||
@@ -66,10 +72,47 @@ fun MainAppBarActions(
|
|||||||
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (currentRouteName != Notifications::class.qualifiedName) {
|
||||||
|
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
|
||||||
|
}
|
||||||
OverflowMenu(navController = navController, isAdmin = isAdmin)
|
OverflowMenu(navController = navController, isAdmin = isAdmin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The inbox bell with its unread badge (M489): the count to 9, then "9+". */
|
||||||
|
@Composable
|
||||||
|
private fun NotificationsBell(unread: Int, onClick: () -> Unit) {
|
||||||
|
val label = badgeLabel(unread)
|
||||||
|
IconButton(onClick = onClick) {
|
||||||
|
// Inverse of the bar, not error red: an unread count is news, not a
|
||||||
|
// fault, and the house style keeps the accent off general chrome.
|
||||||
|
BadgedBox(
|
||||||
|
badge = {
|
||||||
|
if (label.isNotEmpty()) {
|
||||||
|
Badge(
|
||||||
|
containerColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.surface,
|
||||||
|
) { Text(label) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Bell,
|
||||||
|
contentDescription = if (unread > 0) "Notifications, $unread unread" else "Notifications",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Badge text: nothing at zero, the count up to nine, then "9+". */
|
||||||
|
internal fun badgeLabel(count: Int): String = when {
|
||||||
|
count <= 0 -> ""
|
||||||
|
count > MAX_BADGE -> "$MAX_BADGE+"
|
||||||
|
else -> count.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_BADGE = 9
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
|||||||
Vendored
+31
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
import com.fabledsword.minstrel.settings.data.NormalizationMode
|
||||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
@@ -153,4 +154,34 @@ class SupersededToggleIdsTest {
|
|||||||
)
|
)
|
||||||
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun settingRow(id: Long, kind: String, channel: String, value: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.NOTIFICATION_SETTING_SET,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
NotificationSettingPayload.serializer(),
|
||||||
|
NotificationSettingPayload(kind, channel, value),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `notification settings collapse per kind and channel, never across them`() {
|
||||||
|
val rows = listOf(
|
||||||
|
settingRow(1, "request_completed", "email", false),
|
||||||
|
settingRow(2, "request_completed", "phone", false),
|
||||||
|
settingRow(3, "request_completed", "email", true),
|
||||||
|
settingRow(4, "request_approved", "email", false),
|
||||||
|
)
|
||||||
|
// Only the older email toggle for request_completed is superseded.
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a queued setting becomes a one-channel change, and an unknown channel none`() {
|
||||||
|
assertEquals(
|
||||||
|
NotificationSettingChangeWire(kind = "k", phone = true),
|
||||||
|
notificationSettingChange(NotificationSettingPayload("k", "phone", true)),
|
||||||
|
)
|
||||||
|
assertEquals(null, notificationSettingChange(NotificationSettingPayload("k", "pager", true)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationSettingsWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.PutNotificationSettingsBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationSettingsEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationKind
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.NotificationSettingPayload
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
|
/** Settings follow snippet #5107: shown at once, never overwritten or reordered by an older change. */
|
||||||
|
class NotificationSettingsRepositoryTest {
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val api: NotificationsApi = mockk(relaxed = true)
|
||||||
|
private val dao: CachedNotificationDao = mockk(relaxed = true)
|
||||||
|
private val mutationDao: CachedMutationDao = mockk()
|
||||||
|
private val queue: MutationQueue = mockk(relaxed = true)
|
||||||
|
private val retrofit: Retrofit = mockk {
|
||||||
|
every { create(NotificationsApi::class.java) } returns api
|
||||||
|
}
|
||||||
|
private val repo = NotificationSettingsRepository(retrofit, dao, mutationDao, queue, json)
|
||||||
|
|
||||||
|
private val settings = NotificationSettingsWire(
|
||||||
|
kinds = listOf(
|
||||||
|
NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true),
|
||||||
|
),
|
||||||
|
emailAvailable = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun cached() {
|
||||||
|
coEvery { dao.getSettings() } returns CachedNotificationSettingsEntity(
|
||||||
|
json = json.encodeToString(NotificationSettingsWire.serializer(), settings),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a toggle is shown at once and sends only that channel`() = runTest {
|
||||||
|
cached()
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
|
||||||
|
val saved = mutableListOf<CachedNotificationSettingsEntity>()
|
||||||
|
coEvery { dao.upsertSettings(capture(saved)) } returns Unit
|
||||||
|
val body = slot<PutNotificationSettingsBody>()
|
||||||
|
coEvery { api.putSettings(capture(body)) } returns settings
|
||||||
|
|
||||||
|
repo.set("request_completed", NotificationChannel.EMAIL, false)
|
||||||
|
|
||||||
|
val optimistic = json.decodeFromString(NotificationSettingsWire.serializer(), saved.first().json)
|
||||||
|
assertFalse(optimistic.kinds.single().email)
|
||||||
|
assertEquals(
|
||||||
|
listOf(NotificationSettingChangeWire(kind = "request_completed", email = false)),
|
||||||
|
body.captured.kinds,
|
||||||
|
)
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNotificationSettingSet(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a toggle that cannot reach the server is queued`() = runTest {
|
||||||
|
cached()
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns false
|
||||||
|
coEvery { api.putSettings(any()) } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.set("request_completed", NotificationChannel.PHONE, false)
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "phone", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a toggle behind a queued one queues too`() = runTest {
|
||||||
|
cached()
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
|
||||||
|
|
||||||
|
repo.set("request_completed", NotificationChannel.INBOX, false)
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { api.putSettings(any()) }
|
||||||
|
coVerify {
|
||||||
|
queue.enqueueNotificationSettingSet(NotificationSettingPayload("request_completed", "inbox", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh leaves a queued change on screen`() = runTest {
|
||||||
|
coEvery { api.getSettings() } returns settings
|
||||||
|
coEvery { mutationDao.hasPending(MutationKind.NOTIFICATION_SETTING_SET) } returns true
|
||||||
|
|
||||||
|
repo.refresh()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { dao.upsertSettings(any()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationsPageWire
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.ReadAllBody
|
||||||
|
import com.fabledsword.minstrel.cache.db.dao.CachedNotificationDao
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedNotificationEntity
|
||||||
|
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import retrofit2.HttpException
|
||||||
|
import retrofit2.Response
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads are offline-first (rule 100): the device marks the row at once, and a
|
||||||
|
* read the server has not taken is queued, except when the server says the
|
||||||
|
* notice is gone.
|
||||||
|
*/
|
||||||
|
class NotificationsRepositoryTest {
|
||||||
|
private val api: NotificationsApi = mockk(relaxed = true)
|
||||||
|
private val dao: CachedNotificationDao = mockk(relaxed = true)
|
||||||
|
private val queue: MutationQueue = mockk(relaxed = true)
|
||||||
|
private val retrofit: Retrofit = mockk {
|
||||||
|
every { create(NotificationsApi::class.java) } returns api
|
||||||
|
}
|
||||||
|
private val repo = NotificationsRepository(retrofit, dao, queue)
|
||||||
|
|
||||||
|
private fun httpError(status: Int) = HttpException(Response.error<Unit>(status, "".toResponseBody()))
|
||||||
|
|
||||||
|
private fun row(id: String, created: String, readAt: Instant? = null) = CachedNotificationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = "request_completed",
|
||||||
|
title = "t",
|
||||||
|
body = "b",
|
||||||
|
link = "/requests",
|
||||||
|
createdAt = Instant.parse(created),
|
||||||
|
readAt = readAt,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read is shown at once and sent`() = runTest {
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify { dao.markRead("n1", any()) }
|
||||||
|
coVerify { api.markRead("n1") }
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a read that cannot reach the server is queued`() = runTest {
|
||||||
|
coEvery { api.markRead("n1") } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify { dao.markRead("n1", any()) }
|
||||||
|
coVerify { queue.enqueueNotificationRead("n1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a notice the server no longer has is not queued`() = runTest {
|
||||||
|
coEvery { api.markRead("n1") } throws httpError(404)
|
||||||
|
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { queue.enqueueNotificationRead(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a server error queues the read for later`() = runTest {
|
||||||
|
coEvery { api.markRead("n1") } throws httpError(503)
|
||||||
|
|
||||||
|
repo.markRead("n1")
|
||||||
|
|
||||||
|
coVerify { queue.enqueueNotificationRead("n1") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `mark all read sends the newest notice shown, rounded up, and queues it offline`() = runTest {
|
||||||
|
coEvery { dao.getAll() } returns listOf(
|
||||||
|
row("a", "2026-10-08T10:00:00.123Z"),
|
||||||
|
row("b", "2026-10-08T11:00:00.456Z"),
|
||||||
|
)
|
||||||
|
val body = slot<ReadAllBody>()
|
||||||
|
coEvery { api.readAll(capture(body)) } throws IOException("offline")
|
||||||
|
|
||||||
|
repo.markAllRead()
|
||||||
|
|
||||||
|
coVerify { dao.markAllRead(any()) }
|
||||||
|
assertEquals("2026-10-08T11:00:00.457Z", body.captured.upTo)
|
||||||
|
coVerify { queue.enqueueNotificationsReadAll("2026-10-08T11:00:00.457Z") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `refresh keeps a read made here that the server has not seen yet`() = runTest {
|
||||||
|
val readHere = Instant.parse("2026-10-08T12:00:00Z")
|
||||||
|
coEvery { dao.getAll() } returns listOf(row("a", "2026-10-08T10:00:00Z", readAt = readHere))
|
||||||
|
coEvery { api.list(any()) } returns NotificationsPageWire(
|
||||||
|
items = listOf(
|
||||||
|
NotificationWire("a", "request_completed", "t", "b", "/requests", "2026-10-08T10:00:00Z", null),
|
||||||
|
NotificationWire("b", "request_completed", "t", "b", "/requests", "2026-10-08T11:00:00Z", null),
|
||||||
|
),
|
||||||
|
unreadCount = 2,
|
||||||
|
)
|
||||||
|
val saved = slot<List<CachedNotificationEntity>>()
|
||||||
|
coEvery { dao.replaceAll(capture(saved)) } returns Unit
|
||||||
|
|
||||||
|
repo.refresh()
|
||||||
|
|
||||||
|
val byId = saved.captured.associateBy { it.id }
|
||||||
|
assertEquals(readHere, byId.getValue("a").readAt)
|
||||||
|
assertNull(byId.getValue("b").readAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a notice with an unreadable timestamp is skipped, not the whole page`() {
|
||||||
|
val bad = NotificationWire("x", "k", "t", "b", "/", "yesterday", null)
|
||||||
|
assertNull(bad.toEntity(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no cutoff when nothing is shown`() {
|
||||||
|
assertNull(readAllCutoff(emptyList()))
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.fabledsword.minstrel.notifications.ui
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.NotificationKindSettingWire
|
||||||
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
|
import com.fabledsword.minstrel.notifications.data.NotificationChannel
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.badgeLabel
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class NotificationUiTest {
|
||||||
|
@Test
|
||||||
|
fun `server links open the matching screens`() {
|
||||||
|
assertEquals(AlbumDetail("al-1"), routeForLink("/albums/al-1"))
|
||||||
|
assertEquals(ArtistDetail("ar-1"), routeForLink("/artists/ar-1"))
|
||||||
|
assertEquals(Requests, routeForLink("/requests"))
|
||||||
|
assertEquals(AdminRequests, routeForLink("/admin/requests"))
|
||||||
|
assertEquals(AdminQuarantine, routeForLink("/admin/quarantine"))
|
||||||
|
// Admin pages the app has no screen for open the Admin landing.
|
||||||
|
assertEquals(Admin, routeForLink("/admin/duplicates"))
|
||||||
|
assertEquals(Admin, routeForLink("/admin"))
|
||||||
|
assertNull(routeForLink("/somewhere-new"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the badge counts to nine, then 9+`() {
|
||||||
|
assertEquals("", badgeLabel(0))
|
||||||
|
assertEquals("1", badgeLabel(1))
|
||||||
|
assertEquals("9", badgeLabel(9))
|
||||||
|
assertEquals("9+", badgeLabel(10))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ages read in coarse steps`() {
|
||||||
|
val now = Instant.parse("2026-10-08T12:00:00Z")
|
||||||
|
assertEquals("just now", ago(Instant.parse("2026-10-08T11:59:30Z"), now))
|
||||||
|
assertEquals("12m", ago(Instant.parse("2026-10-08T11:48:00Z"), now))
|
||||||
|
assertEquals("5h", ago(Instant.parse("2026-10-08T07:00:00Z"), now))
|
||||||
|
assertEquals("3d", ago(Instant.parse("2026-10-05T12:00:00Z"), now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `phone and email ride on the inbox, and email needs to be available`() {
|
||||||
|
val on = NotificationKindSettingWire("request_completed", false, inbox = true, phone = true, email = true)
|
||||||
|
val off = on.copy(inbox = false)
|
||||||
|
assertTrue(channelEnabled(on, NotificationChannel.PHONE, emailAvailable = true))
|
||||||
|
assertFalse(channelEnabled(off, NotificationChannel.PHONE, emailAvailable = true))
|
||||||
|
assertFalse(channelEnabled(on, NotificationChannel.EMAIL, emailAvailable = false))
|
||||||
|
assertTrue(channelEnabled(off, NotificationChannel.INBOX, emailAvailable = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the email line says why it is off`() {
|
||||||
|
assertEquals("Email is off: add an email address in your profile", emailUnavailableLine("no_address", false))
|
||||||
|
assertEquals("Email is off: SMTP isn't set up on the server", emailUnavailableLine("smtp_not_configured", true))
|
||||||
|
assertEquals("Email is off: this server doesn't send email", emailUnavailableLine("smtp_not_configured", false))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user