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.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