notifications P1: лента с действиями + «Скрыть все»

- NotificationsRepository: парс actions[] (label/link/type/primary),
  dismissAll (DELETE /api/v2/notifications), executeAction (GET/POST/PUT/DELETE по action.link)
- F7NotificationRow: FlowRow кнопок-действий (primary=заливка, прочие=контур)
- NotificationsSheet: заголовок «Уведомления» + «Скрыть все», проброс действий,
  оптимистичное скрытие после действия/скрыть-все

Контракты сверены с исходником notifications на forbion (deleteAllNotifications, actionToArray).
This commit is contained in:
b-dev-mobile
2026-07-13 10:11:32 +00:00
parent 5f81bd92f4
commit a30627a07d
3 changed files with 250 additions and 16 deletions
@@ -59,9 +59,11 @@ import org.json.JSONObject
import ru.forbion.f7cloud.core.auth.AuthSession import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel import ru.forbion.f7cloud.core.designsystem.F7FloatingPanel
import ru.forbion.f7cloud.core.designsystem.F7NotificationActionUi
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime import ru.forbion.f7cloud.core.designsystem.formatNotificationRelativeTime
import ru.forbion.f7cloud.core.network.F7Notification import ru.forbion.f7cloud.core.network.F7Notification
import ru.forbion.f7cloud.core.network.F7NotificationAction
import ru.forbion.f7cloud.core.network.NetworkFactory import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.NotificationsRepository import ru.forbion.f7cloud.core.network.NotificationsRepository
import ru.forbion.f7cloud.core.network.UnauthorizedException import ru.forbion.f7cloud.core.network.UnauthorizedException
@@ -337,6 +339,53 @@ fun NotificationsSheet(
} }
} }
fun dismissAllNotifications() {
items = emptyList()
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
repository.dismissAll(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
trustAllCerts = session.trustAllCerts,
)
}
}.onFailure { t ->
if (t is UnauthorizedException) {
onDismiss()
onUnauthorized()
} else {
loadNotifications()
}
}
}
}
fun runAction(notification: F7Notification, action: F7NotificationAction) {
items = items.filter { it.id != notification.id }
scope.launch {
runCatching {
withContext(Dispatchers.IO) {
repository.executeAction(
serverUrl = session.serverUrl,
username = session.username,
appPassword = session.appPassword,
action = action,
trustAllCerts = session.trustAllCerts,
)
}
}.onFailure { t ->
if (t is UnauthorizedException) {
onDismiss()
onUnauthorized()
} else {
loadNotifications()
}
}
}
}
F7FloatingPanel( F7FloatingPanel(
visible = visible, visible = visible,
onDismiss = onDismiss, onDismiss = onDismiss,
@@ -395,7 +444,41 @@ fun NotificationsSheet(
else -> Column( else -> Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight() .fillMaxHeight(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Уведомления",
style = MaterialTheme.typography.titleMedium.copy(
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = F7Colors.TextPrimary,
)
TextButton(onClick = { dismissAllNotifications() }) {
Text(
"Скрыть все",
style = MaterialTheme.typography.labelLarge.copy(
fontSize = MaterialTheme.typography.labelLarge.fontSize * 2,
lineHeight = MaterialTheme.typography.labelLarge.lineHeight * 2,
),
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
color = F7Colors.Primary,
)
}
}
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
items.forEachIndexed { index, item -> items.forEachIndexed { index, item ->
@@ -412,9 +495,16 @@ fun NotificationsSheet(
}, },
onDismiss = { dismissNotification(item) }, onDismiss = { dismissNotification(item) },
showDivider = index < items.lastIndex, showDivider = index < items.lastIndex,
actions = item.actions.map {
F7NotificationActionUi(label = it.label, primary = it.primary)
},
onActionClick = { actionIndex ->
item.actions.getOrNull(actionIndex)?.let { runAction(item, it) }
},
) )
} }
} }
} }
} }
} }
}
@@ -1,15 +1,20 @@
package ru.forbion.f7cloud.core.designsystem package ru.forbion.f7cloud.core.designsystem
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -27,11 +32,18 @@ import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import okhttp3.Credentials import okhttp3.Credentials
/** Кнопка-действие уведомления (label/link/type/primary) для рендера в строке. */
data class F7NotificationActionUi(
val label: String,
val primary: Boolean,
)
private fun TextStyle.doubled(): TextStyle = copy( private fun TextStyle.doubled(): TextStyle = copy(
fontSize = fontSize * 2, fontSize = fontSize * 2,
lineHeight = lineHeight * 2, lineHeight = lineHeight * 2,
) )
@OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun F7NotificationRow( fun F7NotificationRow(
subject: String, subject: String,
@@ -44,6 +56,8 @@ fun F7NotificationRow(
onDismiss: () -> Unit, onDismiss: () -> Unit,
showDivider: Boolean, showDivider: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
actions: List<F7NotificationActionUi> = emptyList(),
onActionClick: (Int) -> Unit = {},
) { ) {
val context = LocalContext.current val context = LocalContext.current
Column( Column(
@@ -134,8 +148,55 @@ fun F7NotificationRow(
.padding(start = 78.dp, top = 6.dp), .padding(start = 78.dp, top = 6.dp),
) )
} }
if (actions.isNotEmpty()) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 78.dp, top = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
actions.forEachIndexed { index, action ->
NotificationActionButton(
label = action.label,
primary = action.primary,
onClick = { onActionClick(index) },
)
}
}
}
} }
if (showDivider) { if (showDivider) {
HorizontalDivider(color = F7Colors.Border, thickness = 1.dp) HorizontalDivider(color = F7Colors.Border, thickness = 1.dp)
} }
} }
@Composable
private fun NotificationActionButton(
label: String,
primary: Boolean,
onClick: () -> Unit,
) {
val shape = RoundedCornerShape(22.dp)
val base = Modifier
.clip(shape)
.clickable(onClick = onClick)
val boxModifier = if (primary) {
base.background(F7Colors.Primary, shape)
} else {
base
.background(F7Colors.Surface, shape)
.border(BorderStroke(1.dp, F7Colors.Border), shape)
}
Box(
modifier = boxModifier.padding(horizontal = 20.dp, vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge.doubled(),
fontWeight = FontWeight.SemiBold,
color = if (primary) F7Colors.TextOnPrimary else F7Colors.TextPrimary,
)
}
}
@@ -1,8 +1,19 @@
package ru.forbion.f7cloud.core.network package ru.forbion.f7cloud.core.network
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
/** Кнопка-действие уведомления (NC notifications: actions[]={label,link,type,primary}). */
data class F7NotificationAction(
val label: String,
val link: String,
val type: String,
val primary: Boolean,
)
data class F7Notification( data class F7Notification(
val id: Long, val id: Long,
val subject: String, val subject: String,
@@ -11,6 +22,7 @@ data class F7Notification(
val link: String, val link: String,
val app: String, val app: String,
val icon: String = "", val icon: String = "",
val actions: List<F7NotificationAction> = emptyList(),
) )
class NotificationsRepository { class NotificationsRepository {
@@ -64,6 +76,25 @@ class NotificationsRepository {
link = obj.optString("link"), link = obj.optString("link"),
app = obj.optString("app"), app = obj.optString("app"),
icon = obj.optString("icon"), icon = obj.optString("icon"),
actions = parseActions(obj.optJSONArray("actions")),
)
}
return out
}
private fun parseActions(array: JSONArray?): List<F7NotificationAction> {
if (array == null) return emptyList()
val out = mutableListOf<F7NotificationAction>()
for (i in 0 until array.length()) {
val obj = array.optJSONObject(i) ?: continue
val link = obj.optString("link")
val label = obj.optString("label")
if (link.isBlank() || label.isBlank()) continue
out += F7NotificationAction(
label = label,
link = link,
type = obj.optString("type").ifBlank { "GET" }.uppercase(),
primary = obj.optBoolean("primary", false),
) )
} }
return out return out
@@ -90,4 +121,56 @@ class NotificationsRepository {
} }
} }
} }
/** «Скрыть все» — NC deleteAllNotifications (DELETE .../api/v2/notifications). */
fun dismissAll(
serverUrl: String,
username: String,
appPassword: String,
trustAllCerts: Boolean = false,
) {
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
val url = "${serverUrl.trimEnd('/')}/ocs/v2.php/apps/notifications/api/v2/notifications"
val request = Request.Builder()
.url(url)
.delete()
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
}
}
/**
* Выполнить кнопку-действие уведомления. link — абсолютный URL от сервера,
* type — GET/POST/PUT/DELETE. После действия сервер сам гасит уведомление.
*/
fun executeAction(
serverUrl: String,
username: String,
appPassword: String,
action: F7NotificationAction,
trustAllCerts: Boolean = false,
) {
val client = NetworkFactory.newAuthedClient(username, appPassword, trustAllCerts)
val empty = "".toRequestBody("application/json".toMediaType())
val builder = Request.Builder()
.url(action.link)
.header("OCS-APIRequest", "true")
.header("Accept", "application/json")
when (action.type.uppercase()) {
"POST" -> builder.post(empty)
"PUT" -> builder.put(empty)
"DELETE" -> builder.delete()
else -> builder.get()
}
client.newCall(builder.build()).execute().use { response ->
if (!response.isSuccessful) {
error("Уведомления HTTP ${response.code}")
}
}
}
} }