profile: нативная панель профиля вместо веб-ЛК forbion
«Личный кабинет» открывал внешнюю веб-ссылку → десктопный дропдаун forbion, криво на мобильном. Теперь пункт открывает нативную панель (ProfileSheet, bottom-card F7FloatingPanel): - шапка: аватар-инициал + отображаемое имя (тянется из OCS cloud/user) + «Открыть профиль» (deep-link /u/<id>) + кнопка QR-скана браузера; - пункты: Установить статус / Личные настройки / Учётные записи (deep-link в веб-настройки), О программе и что нового (нативный About-диалог с версией), Выйти (нативный logout, красный). Material-иконки (extended уже в app). Точки входа: пункт меню «Личный кабинет» + кнопка профиля в нижней панели. Компилируется.
This commit is contained in:
@@ -637,7 +637,12 @@ fun AppScaffold(
|
|||||||
onItemClick = { index ->
|
onItemClick = { index ->
|
||||||
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
val item = appMenuItemsList.getOrNull(index) ?: return@F7AppMenuSheet
|
||||||
val external = item.externalUrl
|
val external = item.externalUrl
|
||||||
if (!external.isNullOrBlank()) {
|
val isPersonalCabinet = item.label.trim().equals("Личный кабинет", ignoreCase = true)
|
||||||
|
if (isPersonalCabinet) {
|
||||||
|
// Профиль — нативная панель вместо веб-ЛК forbion
|
||||||
|
menuOpen = false
|
||||||
|
profileOpen = true
|
||||||
|
} else if (!external.isNullOrBlank()) {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(external)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,38 @@
|
|||||||
package ru.forbion.f7cloud.mobile.ui
|
package ru.forbion.f7cloud.mobile.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
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.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||||
|
import androidx.compose.material.icons.filled.Group
|
||||||
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.QrCodeScanner
|
||||||
|
import androidx.compose.material.icons.filled.TaskAlt
|
||||||
|
import androidx.compose.material.icons.outlined.Info
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -21,22 +42,30 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.Credentials
|
import okhttp3.Credentials
|
||||||
|
import okhttp3.Request
|
||||||
|
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.F7NotificationRow
|
import ru.forbion.f7cloud.core.designsystem.F7NotificationRow
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7PrimaryButton
|
|
||||||
import ru.forbion.f7cloud.core.designsystem.F7TextButton
|
|
||||||
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.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
|
||||||
|
import ru.forbion.f7cloud.core.network.applyOcsJson
|
||||||
|
|
||||||
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
|
private fun themeHeaderAsset(serverUrl: String, fileName: String): String =
|
||||||
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
|
"${serverUrl.trimEnd('/')}/themes/forbion/images/header/$fileName"
|
||||||
@@ -54,46 +83,178 @@ fun ProfileSheet(
|
|||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onScanBrowserQr: () -> Unit = {},
|
onScanBrowserQr: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val base = session.serverUrl.trimEnd('/')
|
||||||
|
val userId = session.davUserId ?: session.username
|
||||||
|
var displayName by remember(session.username) { mutableStateOf(userId) }
|
||||||
|
var aboutOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Отображаемое имя тянем с сервера (OCS), пока — логин
|
||||||
|
LaunchedEffect(visible, session.username) {
|
||||||
|
if (visible) displayName = fetchDisplayName(session).ifBlank { userId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openWeb(path: String) {
|
||||||
|
runCatching {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("$base$path")))
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
F7FloatingPanel(
|
F7FloatingPanel(
|
||||||
visible = visible,
|
visible = visible,
|
||||||
onDismiss = onDismiss,
|
onDismiss = onDismiss,
|
||||||
) {
|
) {
|
||||||
Text("Профиль", style = MaterialTheme.typography.titleMedium, color = F7Colors.TextPrimary)
|
// Шапка: аватар-инициал + имя/«Открыть профиль» + кнопка QR
|
||||||
Text(
|
Row(
|
||||||
text = session.username,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
color = F7Colors.TextPrimary,
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
)
|
) {
|
||||||
Text(
|
Box(
|
||||||
text = session.serverUrl,
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.size(44.dp)
|
||||||
color = F7Colors.TextSecondary,
|
.clip(CircleShape)
|
||||||
)
|
.background(F7Colors.PrimaryLight),
|
||||||
session.davUserId?.let { davId ->
|
contentAlignment = Alignment.Center,
|
||||||
Text(
|
) {
|
||||||
text = "ID: $davId",
|
Text(
|
||||||
style = MaterialTheme.typography.bodySmall,
|
displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?",
|
||||||
color = F7Colors.TextSecondary,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.Primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { openWeb("/u/$userId") }
|
||||||
|
.padding(vertical = 2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
displayName,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = F7Colors.TextPrimary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Открыть профиль",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = F7Colors.Primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(F7Colors.Grey2)
|
||||||
|
.clickable {
|
||||||
|
onDismiss()
|
||||||
|
onScanBrowserQr()
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.QrCodeScanner,
|
||||||
|
contentDescription = "Сканировать QR браузера",
|
||||||
|
tint = F7Colors.TextSecondary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
HorizontalDivider(color = F7Colors.Border.copy(alpha = 0.5f))
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
ProfileMenuRow(Icons.Filled.TaskAlt, "Установить статус") { openWeb("/settings/user") }
|
||||||
|
ProfileMenuRow(Icons.Filled.Person, "Личные настройки") { openWeb("/settings/user") }
|
||||||
|
ProfileMenuRow(Icons.Filled.Group, "Учётные записи") { openWeb("/settings/user/security") }
|
||||||
|
ProfileMenuRow(Icons.Outlined.Info, "О программе и что нового") { aboutOpen = true }
|
||||||
|
ProfileMenuRow(
|
||||||
|
icon = Icons.AutoMirrored.Filled.Logout,
|
||||||
|
label = "Выйти",
|
||||||
|
tint = F7Colors.Error,
|
||||||
|
) {
|
||||||
|
onDismiss()
|
||||||
|
onLogout()
|
||||||
}
|
}
|
||||||
F7PrimaryButton(
|
|
||||||
text = "Сканировать QR браузера",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onScanBrowserQr()
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
F7PrimaryButton(
|
|
||||||
text = "Выйти",
|
|
||||||
onClick = {
|
|
||||||
onDismiss()
|
|
||||||
onLogout()
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
F7TextButton(text = "Закрыть", onClick = onDismiss, modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aboutOpen) {
|
||||||
|
F7AboutDialog(onDismiss = { aboutOpen = false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProfileMenuRow(
|
||||||
|
icon: ImageVector,
|
||||||
|
label: String,
|
||||||
|
tint: Color = F7Colors.TextPrimary,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(vertical = 12.dp, horizontal = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
||||||
|
Text(label, style = MaterialTheme.typography.bodyLarge, color = tint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun F7AboutDialog(onDismiss: () -> Unit) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val version = remember {
|
||||||
|
runCatching {
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName
|
||||||
|
}.getOrNull() ?: "—"
|
||||||
|
}
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("Закрыть") }
|
||||||
|
},
|
||||||
|
title = { Text("F7cloud Mobile") },
|
||||||
|
text = {
|
||||||
|
Column {
|
||||||
|
Text("Версия $version", style = MaterialTheme.typography.bodyMedium, color = F7Colors.TextPrimary)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Нативный клиент F7cloud: почта, файлы, календарь, контакты, задачи, конференции, поддержка.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = F7Colors.TextSecondary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отображаемое имя пользователя из OCS (fallback — пусто). */
|
||||||
|
private suspend fun fetchDisplayName(session: AuthSession): String = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val client = NetworkFactory.newAuthedClient(
|
||||||
|
session.username,
|
||||||
|
session.appPassword,
|
||||||
|
session.trustAllCerts,
|
||||||
|
)
|
||||||
|
val url = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/cloud/user?format=json"
|
||||||
|
val request = Request.Builder().url(url).applyOcsJson().get().build()
|
||||||
|
client.newCall(request).execute().use { response ->
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
JSONObject(body)
|
||||||
|
.optJSONObject("ocs")
|
||||||
|
?.optJSONObject("data")
|
||||||
|
?.optString("displayname")
|
||||||
|
.orEmpty()
|
||||||
|
}
|
||||||
|
}.getOrDefault("")
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
Reference in New Issue
Block a user