diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppSheets.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppSheets.kt index 1559330..8009a1b 100644 --- a/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppSheets.kt +++ b/app/src/main/java/ru/forbion/f7cloud/mobile/ui/AppSheets.kt @@ -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) } + }, + ) + } } } } diff --git a/core/designsystem/src/main/java/ru/forbion/f7cloud/core/designsystem/F7NotificationRow.kt b/core/designsystem/src/main/java/ru/forbion/f7cloud/core/designsystem/F7NotificationRow.kt index 05402a7..ed8779d 100644 --- a/core/designsystem/src/main/java/ru/forbion/f7cloud/core/designsystem/F7NotificationRow.kt +++ b/core/designsystem/src/main/java/ru/forbion/f7cloud/core/designsystem/F7NotificationRow.kt @@ -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 = 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, + ) + } +} diff --git a/core/network/src/main/java/ru/forbion/f7cloud/core/network/NotificationsRepository.kt b/core/network/src/main/java/ru/forbion/f7cloud/core/network/NotificationsRepository.kt index b3458f7..ca887a6 100644 --- a/core/network/src/main/java/ru/forbion/f7cloud/core/network/NotificationsRepository.kt +++ b/core/network/src/main/java/ru/forbion/f7cloud/core/network/NotificationsRepository.kt @@ -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 = 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 { + if (array == null) return emptyList() + val out = mutableListOf() + 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}") + } + } + } }