4 Commits

Author SHA1 Message Date
b-dev-mobile 40e3a4da5c chore: версия 0.5.134 (142)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:18:28 +00:00
b-dev-mobile a96eda5b7c feat(files): открытие .md/.whiteboard + предпрогрев WebView для office
- .md/.markdown/.txt/.whiteboard теперь открываются: WebOpenableFiles →
  каноническая ссылка /index.php/f/<fileId> в авторизованном WebView,
  сервер сам направляет в Text/Whiteboard (office остаётся на Collabora).
  Тап по таким файлам в списке разрешён.
- Предпрогрев: OfficeWarmup.warmWebViewEngine создаёт и уничтожает пустой
  WebView при входе в Файлы — первое создание WebView в процессе тянет
  провайдер Chromium (сотни мс), из-за чего первый docx/xlsx открывался
  медленно; теперь движок прогрет заранее.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 08:17:41 +00:00
b-dev-mobile 1e39b2e2fe chore: версия 0.5.133 (141)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:52:20 +00:00
b-dev-mobile e1e0427066 fix: иконки шторки в единой круглой рамке; кэш Задач для быстрого показа
- Шторка: КАЖДАЯ иконка теперь в белом круге с рамкой и мягкой тенью,
  все одного размера (глиф внутри 56% рамки) — как на мобильном сайте
  (было: плоские глифы без рамки, разного размера).
- Задачи грузятся быстрее: TasksCache (в памяти на время процесса) —
  списки и задачи по спискам показываются мгновенно из кэша при
  повторном открытии раздела/списка, сеть обновляет в фоне. Гонки
  учтены: результат применяется, только если пользователь ещё на этом
  списке.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 07:52:01 +00:00
8 changed files with 136 additions and 23 deletions
+2 -2
View File
@@ -39,8 +39,8 @@ android {
applicationId 'ru.forbion.f7cloud.mobile' applicationId 'ru.forbion.f7cloud.mobile'
minSdk 26 minSdk 26
targetSdk 36 targetSdk 36
versionCode 140 versionCode 142
versionName '0.5.132' versionName '0.5.134'
missingDimensionStrategy 'default', 'f7' missingDimensionStrategy 'default', 'f7'
multiDexEnabled true multiDexEnabled true
@@ -236,32 +236,35 @@ private fun F7AppMenuGridItem(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
val localIcon = item.localIcon // Единая круглая рамка (белый круг + рамка + мягкая тень) под КАЖДУЮ иконку —
if (localIcon != null) { // как на мобильном сайте: все иконки одного размера в кружке. Глиф внутри меньше рамки.
// Нативный пункт: иконка в круглом бейдже с зелёной обводкой (стиль glass) val frameShape = CircleShape
Box( Box(
modifier = Modifier modifier = Modifier
.size(MenuIconSize) .size(MenuIconSize)
.clip(CircleShape) .shadow(2.dp, frameShape, spotColor = Color(0xFFE6E6E6))
.clip(frameShape)
.background(Color.White) .background(Color.White)
.border(1.5.dp, F7Colors.Green30, CircleShape), .border(1.dp, Color(0xFFECECEC), frameShape),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val localIcon = item.localIcon
if (localIcon != null) {
Icon( Icon(
localIcon, localIcon,
contentDescription = item.label, contentDescription = item.label,
tint = F7Colors.Primary, tint = F7Colors.Primary,
modifier = Modifier.size(MenuIconSize * 0.46f), modifier = Modifier.size(MenuIconSize * 0.5f),
) )
}
} else { } else {
AsyncImage( AsyncImage(
model = item.iconUrl, model = item.iconUrl,
contentDescription = item.label, contentDescription = item.label,
modifier = Modifier.size(MenuIconSize), modifier = Modifier.size(MenuIconSize * 0.56f),
contentScale = ContentScale.Fit, contentScale = ContentScale.Fit,
) )
} }
}
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.labelLarge.copy( style = MaterialTheme.typography.labelLarge.copy(
@@ -113,6 +113,7 @@ fun FilesScreen(
LaunchedEffect(session.serverUrl, session.username, session.davUserId) { LaunchedEffect(session.serverUrl, session.username, session.davUserId) {
OfficeWarmup.warm(session) OfficeWarmup.warm(session)
OfficeWarmup.warmWebViewEngine(context) // прогрев WebView-движка для быстрого docx/xlsx
vm.load(session) vm.load(session)
vm.loadSidebarData(session) vm.loadSidebarData(session)
} }
@@ -241,7 +242,11 @@ fun FilesScreen(
) { ) {
val openItem: (FileItem) -> Unit = { item -> val openItem: (FileItem) -> Unit = { item ->
val openable = !item.isDirectory && 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 { when {
item.isDirectory -> vm.openFolder(session, item) item.isDirectory -> vm.openFolder(session, item)
openable -> vm.openItem(session, item) openable -> vm.openItem(session, item)
@@ -487,6 +487,7 @@ class FilesViewModel(
if (item.isDirectory) return if (item.isDirectory) return
when { when {
OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item) OfficeFiles.isOfficeFile(item.name) -> openOfficeFile(session, item)
WebOpenableFiles.isWebOpenable(item.name) -> openWebFile(session, item)
ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item) ArchiveFiles.isArchive(item.name) -> openArchiveFile(session, item)
OpenableFiles.isImage(item.name) -> openImageFile(session, item) OpenableFiles.isImage(item.name) -> openImageFile(session, item)
else -> _state.value = _state.value.copy( else -> _state.value = _state.value.copy(
@@ -495,6 +496,26 @@ class FilesViewModel(
} }
} }
/** 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) { private fun openOfficeFile(session: AuthSession, item: FileItem) {
val fileId = item.fileId ?: run { val fileId = item.fileId ?: run {
_state.value = _state.value.copy(error = "Не удалось определить ID файла") _state.value = _state.value.copy(error = "Не удалось определить ID файла")
@@ -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() { fun clear() {
cachedServerUrl = null cachedServerUrl = null
cachedCollaboraUrl = null cachedCollaboraUrl = null
@@ -0,0 +1,16 @@
package ru.forbion.f7cloud.feature.files
/**
* Файлы, которые открываются во ВЕБ-вьюере Nextcloud по канонической ссылке
* `/index.php/f/<fileId>` (сервер сам направляет в нужное приложение):
* markdown/текст → Text, доска → Whiteboard. В отличие от office (Collabora direct-edit).
*/
object WebOpenableFiles {
private val EXTENSIONS = setOf(
"md", "markdown", "txt", "text", "org",
"whiteboard",
)
fun isWebOpenable(name: String): Boolean =
name.substringAfterLast('.', "").lowercase() in EXTENSIONS
}
@@ -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() val state: StateFlow<TasksUiState> = _state.asStateFlow()
fun load(session: AuthSession) { 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) { viewModelScope.launch(Dispatchers.IO) {
_state.update { it.copy(loading = it.lists.isEmpty(), error = null) } _state.update { it.copy(loading = it.lists.isEmpty(), error = null) }
runCatching { repository.listTaskLists(session) } runCatching { repository.listTaskLists(session) }
.onSuccess { lists -> .onSuccess { lists ->
TasksCache.putLists(lists)
val selected = _state.value.selectedListHref val selected = _state.value.selectedListHref
?: _state.value.defaultListHref ?: _state.value.defaultListHref
?: lists.firstOrNull()?.href ?: lists.firstOrNull()?.href
@@ -107,6 +124,8 @@ class TasksViewModel(
_state.update { _state.update {
it.copy( it.copy(
selectedListHref = listHref, selectedListHref = listHref,
// Мгновенно из кэша, если есть — иначе пусто до загрузки.
tasks = TasksCache.cachedTasks(listHref).orEmpty(),
searchQuery = "", searchQuery = "",
detailTask = null, detailTask = null,
createInputExpanded = false, createInputExpanded = false,
@@ -381,8 +400,12 @@ class TasksViewModel(
_state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) } _state.update { it.copy(loading = _state.value.tasks.isEmpty(), error = null) }
runCatching { repository.loadTasks(session, listHref) } runCatching { repository.loadTasks(session, listHref) }
.onSuccess { tasks -> .onSuccess { tasks ->
TasksCache.putTasks(listHref, tasks)
// Обновляем список задач, только если пользователь всё ещё на этом списке.
if (_state.value.selectedListHref == listHref) {
_state.update { it.copy(loading = false, tasks = tasks) } _state.update { it.copy(loading = false, tasks = tasks) }
} }
}
.onFailure { t -> .onFailure { t ->
_state.update { _state.update {
it.copy( it.copy(