2 Commits

Author SHA1 Message Date
b-dev-mobile 3e5d0d4892 chore: версия 0.5.135 (143)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:43:18 +00:00
b-dev-mobile c0703c3537 feat(files): нативный редактор markdown/текста; whiteboard уточнён
- .md/.markdown/.txt/.org/.log теперь открываются в НАТИВНОМ редакторе
  (MarkdownRepository GET/PUT по WebDAV) — мгновенно, без тяжёлого веб-Text,
  который «вечно грузился» (тянул весь Files UI). Экран MarkdownEditorScreen:
  правка в TextField, предпросмотр md с базовым рендером (заголовки/жирный/
  курсив/код/списки/цитаты), сохранение по WebDAV, кнопка активна только
  при изменениях, back закрывает.
- .whiteboard остаётся веб-вьюером (/f/<id>): доска — Excalidraw-канвас
  с реалтайм-бэкендом (whiteboard.f7cloud.ru + JWT), нативно не реализуется;
  открывается через штатный viewer Files, как в вебе.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:43:01 +00:00
7 changed files with 335 additions and 9 deletions
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26
targetSdk 36
versionCode 142
versionName '0.5.134'
versionCode 143
versionName '0.5.135'
missingDimensionStrategy 'default', 'f7'
multiDexEnabled true
@@ -16,6 +16,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items as gridItems
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -477,4 +478,14 @@ fun FilesScreen(
)
}
}
// Нативный редактор markdown/текста поверх всего (быстрый, вместо тяжёлого веб-Text)
state.markdownDoc?.let { doc ->
BackHandler { vm.closeMarkdown() }
MarkdownEditorScreen(
session = session,
doc = doc,
onClose = { vm.closeMarkdown() },
)
}
}
@@ -20,6 +20,8 @@ data class FilesUiState(
val openingFile: String? = null,
val editorLaunch: OfficeEditorLaunch? = null,
val openAction: FileOpenAction? = null,
// Нативный markdown-редактор: открытый текстовый файл (путь + имя).
val markdownDoc: MarkdownDoc? = null,
val searchQuery: String = "",
val searchResults: List<FilesSearchHit> = emptyList(),
val searchActive: Boolean = false,
@@ -487,6 +487,7 @@ class FilesViewModel(
if (item.isDirectory) return
when {
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
MarkdownFiles.isMarkdown(item.name) -> openMarkdown(item)
WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item)
@@ -496,6 +497,17 @@ class FilesViewModel(
}
}
/** Markdown/текст — нативный редактор (мгновенно, WebDAV). */
private fun openMarkdown(item: FileItem) {
_state.value = _state.value.copy(
markdownDoc = MarkdownDoc(relativePath = item.relativePath, name = item.name),
)
}
fun closeMarkdown() {
_state.value = _state.value.copy(markdownDoc = null)
}
/** Markdown/текст/доска — открываем каноническую ссылку /f/<id> в авторизованном WebView. */
private fun openWebFile(session: AuthSession, item: FileItem) {
val fileId = item.fileId ?: run {
@@ -0,0 +1,244 @@
package ru.forbion.f7cloud.feature.files
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.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7Colors
import ru.forbion.f7cloud.core.designsystem.f7SafeTopInsets
/**
* Нативный редактор markdown/текста: качает содержимое по WebDAV (мгновенно), правка в
* TextField, предпросмотр с базовым рендером, сохранение по WebDAV PUT. Заменяет тяжёлый
* веб-редактор Text (который «вечно грузился», т.к. тянул весь Files UI).
*/
@Composable
fun MarkdownEditorScreen(
session: AuthSession,
doc: MarkdownDoc,
onClose: () -> Unit,
repository: MarkdownRepository = remember { MarkdownRepository() },
) {
var text by remember(doc.relativePath) { mutableStateOf("") }
var loading by remember(doc.relativePath) { mutableStateOf(true) }
var saving by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
var preview by remember { mutableStateOf(false) }
var dirty by remember(doc.relativePath) { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val isMd = doc.name.substringAfterLast('.', "").lowercase() in setOf("md", "markdown")
LaunchedEffect(doc.relativePath) {
loading = true
error = null
runCatching { withContext(Dispatchers.IO) { repository.load(session, doc.relativePath) } }
.onSuccess { text = it; loading = false }
.onFailure { error = it.message ?: "Ошибка загрузки"; loading = false }
}
Column(
modifier = Modifier
.fillMaxSize()
.background(F7Colors.Background)
.f7SafeTopInsets(),
) {
// Шапка: назад, имя, предпросмотр (для md), сохранить
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Назад",
tint = F7Colors.TextPrimary,
modifier = Modifier.size(24.dp).clickable(onClick = onClose),
)
Text(
doc.name,
modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = F7Colors.TextPrimary,
maxLines = 1,
)
if (isMd) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(if (preview) F7Colors.PrimaryLight else F7Colors.SurfaceMuted)
.clickable { preview = !preview }
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
Text(
if (preview) "Правка" else "Просмотр",
style = MaterialTheme.typography.bodyMedium,
color = F7Colors.TextPrimary,
)
}
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(100.dp))
.background(if (dirty && !saving) F7Colors.Primary else F7Colors.SurfaceMuted)
.clickable(enabled = dirty && !saving) {
saving = true
error = null
scope.launch {
runCatching {
withContext(Dispatchers.IO) { repository.save(session, doc.relativePath, text) }
}.onSuccess { saving = false; dirty = false }
.onFailure { error = it.message ?: "Ошибка сохранения"; saving = false }
}
}
.padding(horizontal = 14.dp, vertical = 6.dp),
) {
Text(
if (saving) "" else "Сохранить",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = if (dirty && !saving) androidx.compose.ui.graphics.Color.White else F7Colors.TextSecondary,
)
}
}
error?.let {
Text(it, color = F7Colors.Error, modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
}
when {
loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = F7Colors.Primary)
}
preview && isMd -> {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(renderMarkdown(text), style = MaterialTheme.typography.bodyLarge, color = F7Colors.TextPrimary)
}
}
else -> {
BasicTextField(
value = text,
onValueChange = { text = it; dirty = true },
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
textStyle = TextStyle(
fontSize = 15.sp,
lineHeight = 22.sp,
color = F7Colors.TextPrimary,
fontFamily = if (isMd) FontFamily.Monospace else FontFamily.Default,
),
cursorBrush = SolidColor(F7Colors.Primary),
)
}
}
}
}
/** Лёгкий рендер markdown → AnnotatedString: заголовки, жирный/курсив, списки, код, цитаты. */
private fun renderMarkdown(src: String): AnnotatedString = buildAnnotatedString {
src.lineSequence().forEachIndexed { index, raw ->
if (index > 0) append("\n")
val line = raw.trimEnd()
when {
line.startsWith("### ") -> withStyle(SpanStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold)) {
append(line.removePrefix("### "))
}
line.startsWith("## ") -> withStyle(SpanStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold)) {
append(line.removePrefix("## "))
}
line.startsWith("# ") -> withStyle(SpanStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold)) {
append(line.removePrefix("# "))
}
line.startsWith("> ") -> withStyle(
SpanStyle(fontStyle = FontStyle.Italic, color = androidx.compose.ui.graphics.Color(0xFF808080)),
) { append(line.removePrefix("> ")) }
line.startsWith("- ") || line.startsWith("* ") -> {
append("")
appendInline(line.drop(2))
}
line.matches(Regex("^\\d+\\. .*")) -> appendInline(line)
line.startsWith("```") -> withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(line) }
else -> appendInline(line)
}
}
}
/** Инлайн-разметка: **жирный**, *курсив*, `код`. */
private fun androidx.compose.ui.text.AnnotatedString.Builder.appendInline(text: String) {
var i = 0
while (i < text.length) {
when {
text.startsWith("**", i) -> {
val end = text.indexOf("**", i + 2)
if (end > 0) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, end)) }
i = end + 2
} else { append(text[i]); i++ }
}
text[i] == '*' -> {
val end = text.indexOf('*', i + 1)
if (end > 0) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, end)) }
i = end + 1
} else { append(text[i]); i++ }
}
text[i] == '`' -> {
val end = text.indexOf('`', i + 1)
if (end > 0) {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { append(text.substring(i + 1, end)) }
i = end + 1
} else { append(text[i]); i++ }
}
else -> { append(text[i]); i++ }
}
}
}
@@ -0,0 +1,46 @@
package ru.forbion.f7cloud.feature.files
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.auth.OcsUserResolver
import ru.forbion.f7cloud.core.network.NetworkFactory
import ru.forbion.f7cloud.core.network.davFileUrl
/**
* Нативная работа с текстовыми/markdown-файлами по WebDAV — быстро, без тяжёлого веб-редактора
* (веб Text грузил весь Files UI). Просто GET текста и PUT при сохранении.
*/
class MarkdownRepository {
private fun client(session: AuthSession) =
NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
private fun url(session: AuthSession, relativePath: String): String {
val userId = session.davUserId ?: OcsUserResolver.resolveDavUserId(session)
return davFileUrl(session.serverUrl, userId, relativePath)
}
fun load(session: AuthSession, relativePath: String): String {
val request = Request.Builder().url(url(session, relativePath)).header("Accept", "*/*").get().build()
client(session).newCall(request).execute().use { response ->
if (!response.isSuccessful || response.body == null) {
error("Не удалось загрузить файл (HTTP ${response.code})")
}
return response.body!!.string()
}
}
fun save(session: AuthSession, relativePath: String, content: String) {
val media = "text/markdown; charset=utf-8".toMediaType()
val request = Request.Builder()
.url(url(session, relativePath))
.put(content.toRequestBody(media))
.build()
client(session).newCall(request).execute().use { response ->
if (response.code !in 200..299 && response.code != 204) {
error("Не удалось сохранить (HTTP ${response.code})")
}
}
}
}
@@ -1,16 +1,27 @@
package ru.forbion.f7cloud.feature.files
/**
* Файлы, которые открываются во ВЕБ-вьюере Nextcloud по канонической ссылке
* `/index.php/f/<fileId>` (сервер сам направляет в нужное приложение):
* markdown/текст → Text, доска → Whiteboard. В отличие от office (Collabora direct-edit).
* Файлы, открываемые во ВЕБ-вьюере Nextcloud по ссылке `/index.php/f/<fileId>` — сервер
* направляет в нужное приложение. Сейчас это только доска (Whiteboard, Excalidraw-канвас
* с реалтайм-бэкендом — нативно не реализуется). Markdown/текст — нативно, см. [MarkdownFiles].
*/
object WebOpenableFiles {
private val EXTENSIONS = setOf(
"md", "markdown", "txt", "text", "org",
"whiteboard",
)
private val EXTENSIONS = setOf("whiteboard")
fun isWebOpenable(name: String): Boolean =
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
}
/** Текстовые/markdown-файлы — открываются в НАТИВНОМ редакторе (быстро, WebDAV). */
object MarkdownFiles {
private val EXTENSIONS = setOf("md", "markdown", "txt", "text", "org", "log")
fun isMarkdown(name: String): Boolean =
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
}
/** Открытый в нативном редакторе текстовый файл. */
data class MarkdownDoc(
val relativePath: String,
val name: String,
)