feat(android): notifications bell, inbox screen and settings, offline-first (#5343, #5345)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Failing after 1m41s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 2m49s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m11s
release / go (push) Successful in 1m29s
release / android (push) Failing after 1m41s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 2m49s
release / Build signed APK (releases and dev) (push) Canceled after 2m50s
- The top bar carries a bell with a badge. The badge is inverse, not
error red, and counts to 9, then shows 9+. It opens the Notifications
screen: one line per notice, how long ago, "Mark all read", pull to
refresh. A tap marks the notice read and opens its screen (albums,
artists, requests, admin requests and quarantine; other admin pages
land on Admin).
- The newest 50 notices live in Room (cached_notifications, v11 with
MIGRATION_10_11), so the badge and the list work offline. A refresh
keeps a read made here that the server hasn't seen yet.
- Reads, read-all and setting toggles go through the MutationQueue
(rule 100): NOTIFICATION_READ, NOTIFICATIONS_READ_ALL and
NOTIFICATION_SETTING_SET.
- Read-all sends the newest notice shown, rounded up a millisecond
(Room keeps ms, the server µs), so a late replay leaves newer notices
unread.
- Settings collapse per kind and channel.
- The `notification.created` live event, a return to the foreground and
reconnecting all refresh the inbox.
- Settings → Notifications: a row per kind with Inbox, Phone and Email.
Admin kinds sit under "Library health". Phone and email ride on the
inbox. One line says why email is off. If the system blocks
notifications, a row opens Minstrel's notification settings; it is
re-checked on resume.
- Signing out clears the cached inbox.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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.CachedLikeDao
|
||||
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.CachedResumeStateDao
|
||||
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.CachedLikeEntity
|
||||
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.CachedResumeStateEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
@@ -66,7 +69,12 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
||||
CachedHistorySnapshotEntity::class,
|
||||
AuthSessionEntity::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/
|
||||
// albumPeak, the ReplayGain values the player levels by (M464 #5000).
|
||||
// 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 —
|
||||
// which is exactly right here: the next sync refills every row with the
|
||||
// new column populated, so there is nothing to migrate by hand.
|
||||
version = 10,
|
||||
version = 11,
|
||||
exportSchema = true,
|
||||
)
|
||||
@TypeConverters(MinstrelTypeConverters::class)
|
||||
@@ -102,6 +110,7 @@ abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun cachedHistorySnapshotDao(): CachedHistorySnapshotDao
|
||||
abstract fun authSessionDao(): AuthSessionDao
|
||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
||||
abstract fun cachedNotificationDao(): CachedNotificationDao
|
||||
}
|
||||
|
||||
/** 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")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.CachedLikeDao
|
||||
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.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
@@ -37,7 +38,7 @@ object DatabaseModule {
|
||||
// launch, so users lose only the unsynced mutation queue
|
||||
// (acceptable while we're iterating). Replace with explicit
|
||||
// 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)
|
||||
.build()
|
||||
|
||||
@@ -112,5 +113,10 @@ object DatabaseModule {
|
||||
fun provideDiagnosticEventDao(db: AppDatabase): DiagnosticEventDao =
|
||||
db.diagnosticEventDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCachedNotificationDao(db: AppDatabase): CachedNotificationDao =
|
||||
db.cachedNotificationDao()
|
||||
|
||||
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
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.NotificationSettingChangeWire
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
@@ -47,6 +48,17 @@ object MutationKind {
|
||||
// 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.
|
||||
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),
|
||||
)
|
||||
|
||||
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(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
json.encodeToString(
|
||||
@@ -335,3 +366,36 @@ data class PlaybackErrorReportPayload(
|
||||
val detail: String? = null,
|
||||
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.LikesApi
|
||||
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.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
@@ -82,6 +85,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val requestsApi: RequestsApi = retrofit.create()
|
||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||
private val meApi: MeApi = retrofit.create()
|
||||
private val notificationsApi: NotificationsApi = retrofit.create()
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -170,6 +174,9 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(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.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -293,6 +300,27 @@ class MutationReplayer @Inject constructor(
|
||||
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 {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -355,6 +383,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
|
||||
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||
}.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.
|
||||
MutationKind.NORMALIZATION_SET -> runCatching {
|
||||
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
|
||||
|
||||
@@ -5,6 +5,7 @@ import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -30,6 +31,7 @@ import javax.inject.Singleton
|
||||
class LiveEventsDispatcher @Inject constructor(
|
||||
private val eventsStream: EventsStream,
|
||||
private val likes: LikesRepository,
|
||||
private val notifications: NotificationsRepository,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) : DefaultLifecycleObserver {
|
||||
|
||||
@@ -49,6 +51,8 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
"artist.liked",
|
||||
"artist.unliked",
|
||||
-> refreshLikes()
|
||||
// M489: a contentless nudge; the inbox refetches its newest page.
|
||||
"notification.created" -> refreshNotifications()
|
||||
}
|
||||
// Other kinds (playlist.*, quarantine.*, request.status_changed,
|
||||
// scan.*) reach screen-scoped subscribers via EventsStream
|
||||
@@ -61,6 +65,13 @@ class LiveEventsDispatcher @Inject constructor(
|
||||
// not have reconnected yet; flush the cross-screen refreshes
|
||||
// defensively.
|
||||
refreshLikes()
|
||||
refreshNotifications()
|
||||
}
|
||||
|
||||
private fun refreshNotifications() {
|
||||
scope.launch {
|
||||
runCatching { notifications.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
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.ArtistDetailScreen
|
||||
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.QueueScreen
|
||||
import com.fabledsword.minstrel.playlists.ui.PlaylistDetailScreen
|
||||
@@ -59,6 +61,7 @@ fun MinstrelNavGraph(
|
||||
) {
|
||||
inShellTopLevel(navController, expandPlayer)
|
||||
inShellDetail(navController, expandPlayer)
|
||||
inShellNotifications(navController, expandPlayer)
|
||||
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) {
|
||||
composable<NowPlaying>(
|
||||
// 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 Admin
|
||||
@Serializable data object Requests
|
||||
@Serializable data object Notifications
|
||||
@Serializable data object NotificationSettings
|
||||
|
||||
// ── 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.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.Bell
|
||||
import com.composables.icons.lucide.ChevronRight
|
||||
import com.composables.icons.lucide.ListMusic
|
||||
import com.composables.icons.lucide.LogOut
|
||||
@@ -52,6 +53,7 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Shield
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.NotificationSettings
|
||||
import com.fabledsword.minstrel.nav.Requests
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
import com.fabledsword.minstrel.nav.ServerUrl
|
||||
@@ -98,6 +100,7 @@ fun SettingsScreen(
|
||||
themeMode = themeMode,
|
||||
onPickTheme = themeVm::setThemeMode,
|
||||
onNavToRequests = { navController.navigate(Requests) },
|
||||
onNavToNotifications = { navController.navigate(NotificationSettings) },
|
||||
onNavToAdmin = { navController.navigate(Admin) },
|
||||
onToggleDiagnostics = viewModel::setDiagnosticsOptOut,
|
||||
onSignOutClick = { showSignOutConfirm = true },
|
||||
@@ -121,6 +124,7 @@ private fun SettingsList(
|
||||
themeMode: ThemeMode,
|
||||
onPickTheme: (ThemeMode) -> Unit,
|
||||
onNavToRequests: () -> Unit,
|
||||
onNavToNotifications: () -> Unit,
|
||||
onNavToAdmin: () -> Unit,
|
||||
onToggleDiagnostics: (Boolean) -> Unit,
|
||||
onSignOutClick: () -> Unit,
|
||||
@@ -144,6 +148,12 @@ private fun SettingsList(
|
||||
subtitle = "Track what you've asked Minstrel to add",
|
||||
onClick = onNavToRequests,
|
||||
)
|
||||
NavTile(
|
||||
icon = Lucide.Bell,
|
||||
title = "Notifications",
|
||||
subtitle = "What reaches you, and where",
|
||||
onClick = onNavToNotifications,
|
||||
)
|
||||
if (state.isAdmin) {
|
||||
NavTile(
|
||||
icon = Lucide.Shield,
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -28,6 +29,7 @@ data class SettingsState(
|
||||
class SettingsViewModel @Inject constructor(
|
||||
private val authController: AuthController,
|
||||
private val authStore: AuthStore,
|
||||
private val notifications: NotificationsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val transient = MutableStateFlow(TransientState())
|
||||
@@ -70,6 +72,8 @@ class SettingsViewModel @Inject constructor(
|
||||
viewModelScope.launch {
|
||||
transient.update { it.copy(isSigningOut = true) }
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.shared.widgets
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.auth.AuthController
|
||||
import com.fabledsword.minstrel.notifications.data.NotificationsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -22,6 +23,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
@HiltViewModel
|
||||
class AppBarActionsViewModel @Inject constructor(
|
||||
authController: AuthController,
|
||||
notifications: NotificationsRepository,
|
||||
) : ViewModel() {
|
||||
val isAdmin: StateFlow<Boolean> = authController.currentUser
|
||||
.map { it?.isAdmin == true }
|
||||
@@ -30,4 +32,12 @@ class AppBarActionsViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
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
|
||||
|
||||
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.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -14,6 +17,7 @@ import androidx.compose.runtime.setValue
|
||||
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.House
|
||||
import com.composables.icons.lucide.LibraryBig
|
||||
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.Home
|
||||
import com.fabledsword.minstrel.nav.Library
|
||||
import com.fabledsword.minstrel.nav.Notifications
|
||||
import com.fabledsword.minstrel.nav.Playlists
|
||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||
import com.fabledsword.minstrel.nav.Settings as SettingsRoute
|
||||
@@ -50,6 +55,7 @@ fun MainAppBarActions(
|
||||
viewModel: AppBarActionsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val isAdmin by viewModel.isAdmin.collectAsStateWithLifecycle()
|
||||
val unread by viewModel.unreadCount.collectAsStateWithLifecycle()
|
||||
Row {
|
||||
if (currentRouteName != Home::class.qualifiedName) {
|
||||
IconButton(onClick = { navController.navigate(Home) }) {
|
||||
@@ -66,10 +72,47 @@ fun MainAppBarActions(
|
||||
Icon(Lucide.SearchIcon, contentDescription = "Search")
|
||||
}
|
||||
}
|
||||
if (currentRouteName != Notifications::class.qualifiedName) {
|
||||
NotificationsBell(unread = unread, onClick = { navController.navigate(Notifications) })
|
||||
}
|
||||
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
|
||||
private fun OverflowMenu(navController: NavHostController, isAdmin: Boolean) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Vendored
+31
@@ -1,5 +1,6 @@
|
||||
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.settings.data.NormalizationMode
|
||||
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||
@@ -153,4 +154,34 @@ class SupersededToggleIdsTest {
|
||||
)
|
||||
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