Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 40e3a4da5c | |||
| a96eda5b7c | |||
| 1e39b2e2fe | |||
| e1e0427066 |
+2
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
+11
-8
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user