Compare commits
2 Commits
40e3a4da5c
...
3e5d0d4892
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e5d0d4892 | |||
| c0703c3537 |
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user