Compare commits
6 Commits
v0.5.132
...
3e5d0d4892
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e5d0d4892 | |||
| c0703c3537 | |||
| 40e3a4da5c | |||
| a96eda5b7c | |||
| 1e39b2e2fe | |||
| e1e0427066 |
+2
-2
@@ -39,8 +39,8 @@ android {
|
||||
applicationId 'ru.forbion.f7cloud.mobile'
|
||||
minSdk 26
|
||||
targetSdk 36
|
||||
versionCode 140
|
||||
versionName '0.5.132'
|
||||
versionCode 143
|
||||
versionName '0.5.135'
|
||||
missingDimensionStrategy 'default', 'f7'
|
||||
multiDexEnabled true
|
||||
|
||||
|
||||
+22
-19
@@ -236,31 +236,34 @@ private fun F7AppMenuGridItem(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val localIcon = item.localIcon
|
||||
if (localIcon != null) {
|
||||
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(MenuIconSize)
|
||||
.clip(CircleShape)
|
||||
.background(Color.White)
|
||||
.border(1.5.dp, F7Colors.Green30, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
|
||||
// как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
|
||||
val frameShape = CircleShape
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(MenuIconSize)
|
||||
.shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
|
||||
.clip(frameShape)
|
||||
.background(Color.White)
|
||||
.border(1.dp, Color(0xFFECECEC), frameShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val localIcon = item.localIcon
|
||||
if (localIcon != null) {
|
||||
Icon(
|
||||
localIcon,
|
||||
contentDescription = item.label,
|
||||
tint = F7Colors.Primary,
|
||||
modifier = Modifier.size(MenuIconSize * 0.46f),
|
||||
modifier = Modifier.size(MenuIconSize * 0.5f),
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize * 0.56f),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = item.label,
|
||||
modifier = Modifier.size(MenuIconSize),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
|
||||
@@ -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
|
||||
@@ -113,6 +114,7 @@ fun FilesScreen(
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
|
||||
OfficeWarmup.warm(session)
|
||||
OfficeWarmup.warmWebViewEngine(context) // прогрев WebView-движка для быстрого docx/xlsx
|
||||
vm.load(session)
|
||||
vm.loadSidebarData(session)
|
||||
}
|
||||
@@ -241,7 +243,11 @@ fun FilesScreen(
|
||||
) {
|
||||
val openItem: (FileItem) -> Unit = { item ->
|
||||
val openable = !item.isDirectory &&
|
||||
(OfficeFiles.isOfficeFile(item.name) || OpenableFiles.isOpenable(item.name))
|
||||
(
|
||||
OfficeFiles.isOfficeFile(item.name) ||
|
||||
WebOpenableFiles.isWebOpenable(item.name) ||
|
||||
OpenableFiles.isOpenable(item.name)
|
||||
)
|
||||
when {
|
||||
item.isDirectory -> vm.openFolder(session, item)
|
||||
openable -> vm.openItem(session, item)
|
||||
@@ -472,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,8 @@ 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)
|
||||
else -> _state.value = _state.value.copy(
|
||||
@@ -495,6 +497,37 @@ 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 {
|
||||
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
|
||||
return
|
||||
}
|
||||
val base = session.serverUrl.trimEnd('/')
|
||||
_state.value = _state.value.copy(
|
||||
editorLaunch = OfficeEditorLaunch(
|
||||
url = "$base/index.php/f/$fileId",
|
||||
title = item.name,
|
||||
username = session.username,
|
||||
password = session.appPassword,
|
||||
trustAllCerts = session.trustAllCerts,
|
||||
serverUrl = session.serverUrl,
|
||||
collaboraBaseUrl = "",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun openOfficeFile(session: AuthSession, item: FileItem) {
|
||||
val fileId = item.fileId ?: run {
|
||||
_state.value = _state.value.copy(error = "Не удалось определить ID файла")
|
||||
|
||||
@@ -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})")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,23 @@ object OfficeWarmup {
|
||||
}
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var engineWarmed = false
|
||||
|
||||
/**
|
||||
* Прогрев WebView-движка: ПЕРВОЕ создание WebView в процессе тянет провайдер Chromium
|
||||
* (сотни мс), из-за чего первый docx/xlsx открывается медленно. Создаём и сразу уничтожаем
|
||||
* пустой WebView заранее (при входе в Файлы) — движок загружается в фоне. Только UI-поток.
|
||||
*/
|
||||
fun warmWebViewEngine(context: android.content.Context) {
|
||||
if (engineWarmed) return
|
||||
engineWarmed = true
|
||||
runCatching {
|
||||
val wv = android.webkit.WebView(context.applicationContext)
|
||||
wv.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
cachedServerUrl = null
|
||||
cachedCollaboraUrl = null
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package ru.forbion.f7cloud.feature.files
|
||||
|
||||
/**
|
||||
* Файлы, открываемые во ВЕБ-вьюере Nextcloud по ссылке `/index.php/f/<fileId>` — сервер
|
||||
* направляет в нужное приложение. Сейчас это только доска (Whiteboard, Excalidraw-канвас
|
||||
* с реалтайм-бэкендом — нативно не реализуется). Markdown/текст — нативно, см. [MarkdownFiles].
|
||||
*/
|
||||
object WebOpenableFiles {
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package ru.forbion.f7cloud.feature.tasks
|
||||
|
||||
/**
|
||||
* Лёгкий кэш в памяти на время жизни процесса: списки задач и задачи по каждому списку.
|
||||
* VM пересоздаётся при каждом открытии раздела Задач — без кэша это полный PROPFIND+REPORT
|
||||
* заново. Кэш даёт мгновенный показ, сеть обновляет в фоне.
|
||||
*/
|
||||
internal object TasksCache {
|
||||
@Volatile
|
||||
private var lists: List<TaskListItem>? = null
|
||||
private val tasksByList = java.util.concurrent.ConcurrentHashMap<String, List<TaskItem>>()
|
||||
|
||||
fun cachedLists(): List<TaskListItem>? = lists
|
||||
|
||||
fun putLists(value: List<TaskListItem>) {
|
||||
lists = value
|
||||
}
|
||||
|
||||
fun cachedTasks(listHref: String): List<TaskItem>? = tasksByList[listHref]
|
||||
|
||||
fun putTasks(listHref: String, value: List<TaskItem>) {
|
||||
tasksByList[listHref] = value
|
||||
}
|
||||
|
||||
fun invalidateTasks(listHref: String) {
|
||||
tasksByList.remove(listHref)
|
||||
}
|
||||
}
|
||||
@@ -74,10 +74,27 @@ class TasksViewModel(
|
||||
val state: StateFlow<TasksUiState> = _state.asStateFlow()
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
// Мгновенно показываем кэш (списки + задачи выбранного), сеть обновляет в фоне.
|
||||
val cachedLists = TasksCache.cachedLists()
|
||||
if (cachedLists != null && cachedLists.isNotEmpty()) {
|
||||
val selected = _state.value.selectedListHref
|
||||
?: _state.value.defaultListHref
|
||||
?: cachedLists.firstOrNull()?.href
|
||||
_state.update {
|
||||
it.copy(
|
||||
loading = false,
|
||||
lists = cachedLists,
|
||||
selectedListHref = selected,
|
||||
defaultListHref = it.defaultListHref ?: cachedLists.firstOrNull()?.href,
|
||||
tasks = selected?.let { href -> TasksCache.cachedTasks(href) } ?: it.tasks,
|
||||
)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
|
||||
runCatching { repository.listTaskLists(session) }
|
||||
.onSuccess { lists ->
|
||||
TasksCache.putLists(lists)
|
||||
val selected = _state.value.selectedListHref
|
||||
?: _state.value.defaultListHref
|
||||
?: lists.firstOrNull()?.href
|
||||
@@ -107,6 +124,8 @@ class TasksViewModel(
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedListHref = listHref,
|
||||
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
|
||||
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
|
||||
searchQuery = "",
|
||||
detailTask = null,
|
||||
createInputExpanded = false,
|
||||
@@ -381,7 +400,11 @@ class TasksViewModel(
|
||||
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
|
||||
runCatching { repository.loadTasks(session, listHref) }
|
||||
.onSuccess { tasks ->
|
||||
_state.update { it.copy(loading = false, tasks = tasks) }
|
||||
TasksCache.putTasks(listHref, tasks)
|
||||
// Обновляем список задач, только если пользователь всё ещё на этом списке.
|
||||
if (_state.value.selectedListHref == listHref) {
|
||||
_state.update { it.copy(loading = false, tasks = tasks) }
|
||||
}
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.update {
|
||||
|
||||
Reference in New Issue
Block a user