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.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,24 +444,65 @@ fun NotificationsSheet(
|
|||||||
else -> Column(
|
else -> Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight()
|
.fillMaxHeight(),
|
||||||
.verticalScroll(rememberScrollState()),
|
|
||||||
) {
|
) {
|
||||||
items.forEachIndexed { index, item ->
|
Row(
|
||||||
F7NotificationRow(
|
modifier = Modifier
|
||||||
subject = item.subject,
|
.fillMaxWidth()
|
||||||
message = item.message,
|
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||||
relativeTime = formatNotificationRelativeTime(item.datetime),
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
iconUrl = resolveNotificationIcon(session.serverUrl, item.icon),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
closeIconUrl = closeIconUrl,
|
) {
|
||||||
authHeader = authHeader,
|
Text(
|
||||||
onClick = {
|
"Уведомления",
|
||||||
onDismiss()
|
style = MaterialTheme.typography.titleMedium.copy(
|
||||||
onNotificationClick(item)
|
fontSize = MaterialTheme.typography.titleMedium.fontSize * 2,
|
||||||
},
|
lineHeight = MaterialTheme.typography.titleMedium.lineHeight * 2,
|
||||||
onDismiss = { dismissNotification(item) },
|
),
|
||||||
showDivider = index < items.lastIndex,
|
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
|
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}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user