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:
@@ -59,9 +59,11 @@ import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
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.formatNotificationRelativeTime
|
||||
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.NotificationsRepository
|
||||
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(
|
||||
visible = visible,
|
||||
onDismiss = onDismiss,
|
||||
@@ -395,24 +444,65 @@ fun NotificationsSheet(
|
||||
else -> Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
.fillMaxHeight(),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
F7NotificationRow(
|
||||
subject = item.subject,
|
||||
message = item.message,
|
||||
relativeTime = formatNotificationRelativeTime(item.datetime),
|
||||
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
||||
closeIconUrl = closeIconUrl,
|
||||
authHeader = authHeader,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNotificationClick(item)
|
||||
},
|
||||
onDismiss = { dismissNotification(item) },
|
||||
showDivider = index < items.lastIndex,
|
||||
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()),
|
||||
) {
|
||||
items.forEachIndexed { index, item ->
|
||||
F7NotificationRow(
|
||||
subject = item.subject,
|
||||
message = item.message,
|
||||
relativeTime = formatNotificationRelativeTime(item.datetime),
|
||||
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
||||
closeIconUrl = closeIconUrl,
|
||||
authHeader = authHeader,
|
||||
onClick = {
|
||||
onDismiss()
|
||||
onNotificationClick(item)
|
||||
},
|
||||
onDismiss = { dismissNotification(item) },
|
||||
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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+61
@@ -1,15 +1,20 @@
|
||||
package ru.forbion.f7cloud.core.designsystem
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -27,11 +32,18 @@ import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import okhttp3.Credentials
|
||||
|
||||
/** Кнопка-действие уведомления (label/link/type/primary) для рендера в строке. */
|
||||
data class F7NotificationActionUi(
|
||||
val label: String,
|
||||
val primary: Boolean,
|
||||
)
|
||||
|
||||
private fun TextStyle.doubled(): TextStyle = copy(
|
||||
fontSize = fontSize * 2,
|
||||
lineHeight = lineHeight * 2,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
fun F7NotificationRow(
|
||||
subject: String,
|
||||
@@ -44,6 +56,8 @@ fun F7NotificationRow(
|
||||
onDismiss: () -> Unit,
|
||||
showDivider: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: List<F7NotificationActionUi> = emptyList(),
|
||||
onActionClick: (Int) -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
@@ -134,8 +148,55 @@ fun F7NotificationRow(
|
||||
.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) {
|
||||
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
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
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(
|
||||
val id: Long,
|
||||
val subject: String,
|
||||
@@ -11,6 +22,7 @@ data class F7Notification(
|
||||
val link: String,
|
||||
val app: String,
|
||||
val icon: String = "",
|
||||
val actions: List<F7NotificationAction> = emptyList(),
|
||||
)
|
||||
|
||||
class NotificationsRepository {
|
||||
@@ -64,6 +76,25 @@ class NotificationsRepository {
|
||||
link = obj.optString("link"),
|
||||
app = obj.optString("app"),
|
||||
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
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user