Этап 0 п.5 (gradle-гигиена): parallel=true + удалены мёртвые модули
- org.gradle.parallel=true (быстрее сборка 20 модулей) - удалены core:data, core:ui (пустые заготовки) и feature:widgets (не в settings.gradle, не компилировался) + чистка settings.gradle - nonTransitiveRClass=true и убирание enableJetifier ОТКАЧЕНЫ: vendor-форк talk-android их не переживает (R транзитивно; legacy android.support.* в зависимостях типа FlexibleAdapter). Причины — в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. Этап 0 закрыт.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
Формат: `ГГГГ-ММ-ДД | версия | изменение | контракты | риск`
|
||||
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.5 (gradle-гигиена): org.gradle.parallel=true (ускорение сборки 20 модулей); удалены мёртвые модули core:data, core:ui (пустые заготовки) и feature:widgets (не был в settings.gradle, не компилировался) + чистка settings.gradle. nonTransitiveRClass и убирание jetifier — ПРОБОВАЛ, откатил: vendor-форк talk-android ломается (R транзитивно + legacy android.support.* в его depS); причины в комментах gradle.properties. Проверено сборкой: assembleRelease BUILD SUCCESSFUL. **Этап 0 завершён** (кроме п.4c — публикация исходников, к CI этапа 1) | контракты не менялись | низкий: удалён неиспользуемый код, ускорена сборка
|
||||
- 2026-07-07 | v0.5.114 (122) | Требование владельца: звонки/push ДОЛЖНЫ работать при заблокированном телефоне. (1) Биометрическая привязка app password ОТКЛОНЕНА (вариант А) — она неустранимо блокировала приём звонка без разблокировки; откачена из main, сохранена на ветке wip/biometric-binding. App password остаётся защищён шифрованием в покое (Keystore AES-256-GCM из п.3, работает в фоне → звонки/push/приём при блокировке ОК). (2) Приватность локскрина: уведомления сообщений и звонков → VISIBILITY_PRIVATE + generic public-версия («Новое сообщение»/«Входящий звонок»), канал lockscreenVisibility=PRIVATE. Полный контент (отправитель/текст/имя звонящего) — только после разблокировки. Push у приложения plaintext, пароль для показа/звонка НЕ нужен. Проверено сборкой: assembleRelease 53 МБ | контракты не менялись | низкий: только UX уведомлений; звонки/push в фоне работают как раньше
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4b (GPL): восстановлены затёртые копирайты Nextcloud в vendor/talk-android — точной сверкой с апстримом v23.0.0 (скачан с GitHub). 102 файла: искажённые email реальных разработчиков NC (@f7cloud.com → настоящие), холдер «F7cloud»/«F7cloud and F7cloud contributors» → «Nextcloud GmbH and Nextcloud contributors». Название продукта в заголовках оставлено «F7cloud Talk» (ребренд форка, требование trademark-политики NC). Менялись ТОЛЬКО строки копирайта в комментах, код не тронут (103+/103−). Проверено сборкой: assembleRelease 53 МБ OK | лицензирование/атрибуция (не код) | нет: функционально нейтрально, снимает нарушение GPLv3 §4 (атрибуция)
|
||||
- 2026-07-07 | v0.5.114 (122) | Этап 0 п.4a (GPL): добавлен LICENSE (GPL-3.0-or-later) в корень репо — приложение производно от vendored Nextcloud Talk (GPL), значит весь APK под GPL; раздел «Лицензия» в README (обязательство corresponding source по тегу релиза). Восстановление затёртых копирайтов Nextcloud в vendor (п.4b) — отдельно, сверкой с апстримом v23.0.0 | лицензирование (не код) | нет
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'ru.forbion.f7cloud.core.data'
|
||||
compileSdk 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:database')
|
||||
implementation project(':core:network')
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
@@ -1,3 +0,0 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
plugins {
|
||||
id 'com.android.library'
|
||||
id 'org.jetbrains.kotlin.android'
|
||||
id 'org.jetbrains.kotlin.plugin.compose'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace 'ru.forbion.f7cloud.feature.widgets'
|
||||
compileSdk 36
|
||||
|
||||
defaultConfig {
|
||||
minSdk 26
|
||||
}
|
||||
buildFeatures {
|
||||
compose true
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':core:auth')
|
||||
implementation project(':core:network')
|
||||
implementation project(':core:designsystem')
|
||||
implementation project(':feature:files')
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
|
||||
implementation 'org.json:json:20240303'
|
||||
def composeBom = platform('androidx.compose:compose-bom:2025.02.00')
|
||||
implementation composeBom
|
||||
implementation 'androidx.compose.ui:ui'
|
||||
implementation 'androidx.compose.material3:material3'
|
||||
implementation 'androidx.compose.foundation:foundation'
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.1'
|
||||
implementation 'io.coil-kt:coil-compose:2.6.0'
|
||||
implementation 'io.coil-kt:coil-svg:2.6.0'
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest />
|
||||
-271
@@ -1,271 +0,0 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.NetworkFactory
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
|
||||
class WidgetsRepository {
|
||||
suspend fun loadDashboard(session: AuthSession): List<DashboardWidgetGroup> = coroutineScope {
|
||||
val client = NetworkFactory.newAuthedClient(
|
||||
session.username,
|
||||
session.appPassword,
|
||||
session.trustAllCerts,
|
||||
)
|
||||
val base = "${session.serverUrl.trimEnd('/')}/ocs/v2.php/apps/dashboard"
|
||||
|
||||
val widgetsDeferred = async(Dispatchers.IO) {
|
||||
parseWidgets(getOcsData(client, "$base/api/v1/widgets"))
|
||||
}
|
||||
val layoutDeferred = async(Dispatchers.IO) {
|
||||
fetchLayoutWithTimeout(client, base)
|
||||
}
|
||||
val itemsV2Deferred = async(Dispatchers.IO) {
|
||||
parseWidgetItemsV2(getOcsData(client, "$base/api/v2/widget-items?limit=7"))
|
||||
}
|
||||
// recommendations есть только в API v1
|
||||
val itemsV1Deferred = async(Dispatchers.IO) {
|
||||
parseWidgetItemsV1(
|
||||
getOcsData(
|
||||
client,
|
||||
buildWidgetItemsUrl(base, apiVersion = 1, widgetIds = listOf("recommendations")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val allWidgets = widgetsDeferred.await().associateBy { it.id }
|
||||
val layout = layoutDeferred.await()
|
||||
val itemsByWidget = mergeWidgetItems(itemsV1Deferred.await(), itemsV2Deferred.await())
|
||||
val widgetOrder = resolveWidgetOrder(layout, allWidgets, itemsByWidget.keys)
|
||||
|
||||
widgetOrder.map { widget ->
|
||||
val bucket = itemsByWidget[widget.id]
|
||||
DashboardWidgetGroup(
|
||||
id = widget.id,
|
||||
title = widget.title,
|
||||
iconUrl = widget.iconUrl,
|
||||
items = bucket?.items.orEmpty(),
|
||||
emptyMessage = bucket?.emptyMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchLayoutWithTimeout(
|
||||
client: okhttp3.OkHttpClient,
|
||||
base: String,
|
||||
): List<String> = withContext(Dispatchers.IO) {
|
||||
withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
|
||||
runCatching {
|
||||
parseLayout(getOcsData(client, "$base/api/v3/layout"))
|
||||
}.getOrNull()
|
||||
}?.takeIf { it.isNotEmpty() } ?: DEFAULT_LAYOUT
|
||||
}
|
||||
|
||||
private fun buildWidgetItemsUrl(
|
||||
base: String,
|
||||
apiVersion: Int,
|
||||
widgetIds: List<String> = emptyList(),
|
||||
): String {
|
||||
val builder = "$base/api/v$apiVersion/widget-items?limit=7"
|
||||
.toHttpUrlOrNull()
|
||||
?.newBuilder()
|
||||
?: return "$base/api/v$apiVersion/widget-items?limit=7"
|
||||
widgetIds.forEach { builder.addQueryParameter("widgets[]", it) }
|
||||
return builder.build().toString()
|
||||
}
|
||||
|
||||
private fun resolveWidgetOrder(
|
||||
layout: List<String>,
|
||||
allWidgets: Map<String, WidgetMeta>,
|
||||
itemWidgetIds: Set<String>,
|
||||
): List<WidgetMeta> {
|
||||
val order = layout.ifEmpty { DEFAULT_LAYOUT }
|
||||
val seen = mutableSetOf<String>()
|
||||
val ordered = mutableListOf<WidgetMeta>()
|
||||
for (id in order) {
|
||||
if (!seen.add(id)) continue
|
||||
val meta = allWidgets[id] ?: continue
|
||||
if (id in itemWidgetIds || meta.id in allWidgets) {
|
||||
ordered += meta
|
||||
}
|
||||
}
|
||||
for (id in itemWidgetIds) {
|
||||
if (!seen.add(id)) continue
|
||||
allWidgets[id]?.let { ordered += it }
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private fun mergeWidgetItems(
|
||||
v1: Map<String, WidgetItemsBucket>,
|
||||
v2: Map<String, WidgetItemsBucket>,
|
||||
): Map<String, WidgetItemsBucket> {
|
||||
val ids = (v1.keys + v2.keys).toSet()
|
||||
return ids.associateWith { id ->
|
||||
val fromV2 = v2[id]
|
||||
val fromV1 = v1[id]
|
||||
when {
|
||||
fromV2 != null && fromV2.items.isNotEmpty() -> fromV2
|
||||
fromV1 != null && fromV1.items.isNotEmpty() -> fromV1
|
||||
fromV2 != null -> fromV2
|
||||
else -> fromV1 ?: WidgetItemsBucket()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOcsData(client: okhttp3.OkHttpClient, url: String): Any {
|
||||
val jsonUrl = if (url.contains("format=")) url else {
|
||||
if (url.contains("?")) "$url&format=json" else "$url?format=json"
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url(jsonUrl)
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 401) throw UnauthorizedException()
|
||||
if (!response.isSuccessful || response.body == null) {
|
||||
error("Dashboard API HTTP ${response.code}")
|
||||
}
|
||||
val body = response.body!!.string()
|
||||
val ocs = JSONObject(body).optJSONObject("ocs")
|
||||
?: error("Некорректный ответ Dashboard API")
|
||||
val meta = ocs.optJSONObject("meta")
|
||||
val status = meta?.optString("status").orEmpty()
|
||||
if (status.equals("failure", ignoreCase = true)) {
|
||||
error(meta?.optString("message").orEmpty().ifBlank { "Dashboard API error" })
|
||||
}
|
||||
return ocs.opt("data") ?: JSONObject()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLayout(data: Any): List<String> {
|
||||
if (data !is JSONObject) return emptyList()
|
||||
val layout = data.optJSONArray("layout") ?: return emptyList()
|
||||
return buildList {
|
||||
for (i in 0 until layout.length()) {
|
||||
val id = layout.optString(i)
|
||||
if (id.isNotBlank()) add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseWidgets(data: Any): List<WidgetMeta> {
|
||||
val out = mutableListOf<WidgetMeta>()
|
||||
when (data) {
|
||||
is JSONObject -> {
|
||||
val keys = data.keys()
|
||||
while (keys.hasNext()) {
|
||||
val key = keys.next()
|
||||
val obj = data.optJSONObject(key) ?: continue
|
||||
out += widgetMetaFromJson(obj, key)
|
||||
}
|
||||
}
|
||||
is JSONArray -> for (i in 0 until data.length()) {
|
||||
val obj = data.optJSONObject(i) ?: continue
|
||||
out += widgetMetaFromJson(obj, obj.optString("id"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun widgetMetaFromJson(obj: JSONObject, fallbackId: String): WidgetMeta {
|
||||
return WidgetMeta(
|
||||
id = obj.optString("id", fallbackId),
|
||||
title = obj.optString("title", fallbackId),
|
||||
order = obj.optInt("order", 0),
|
||||
iconUrl = obj.optString("icon_url"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseWidgetItemsV1(data: Any): Map<String, WidgetItemsBucket> {
|
||||
val out = mutableMapOf<String, WidgetItemsBucket>()
|
||||
if (data !is JSONObject) return out
|
||||
val keys = data.keys()
|
||||
while (keys.hasNext()) {
|
||||
val widgetId = keys.next()
|
||||
val value = data.opt(widgetId) ?: continue
|
||||
val items = when (value) {
|
||||
is JSONArray -> parseItemArray(value)
|
||||
is JSONObject -> parseItemArray(value.optJSONArray("items") ?: JSONArray())
|
||||
else -> emptyList()
|
||||
}
|
||||
if (items.isNotEmpty()) {
|
||||
out[widgetId] = WidgetItemsBucket(items = items)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseWidgetItemsV2(data: Any): Map<String, WidgetItemsBucket> {
|
||||
val out = mutableMapOf<String, WidgetItemsBucket>()
|
||||
if (data !is JSONObject) return out
|
||||
val keys = data.keys()
|
||||
while (keys.hasNext()) {
|
||||
val widgetId = keys.next()
|
||||
val widgetObj = data.optJSONObject(widgetId) ?: continue
|
||||
val items = parseItemArray(widgetObj.optJSONArray("items") ?: JSONArray())
|
||||
val emptyMessage = widgetObj.optString("emptyContentMessage")
|
||||
.ifBlank { widgetObj.optString("halfEmptyContentMessage") }
|
||||
.ifBlank { null }
|
||||
if (items.isNotEmpty() || !emptyMessage.isNullOrBlank()) {
|
||||
out[widgetId] = WidgetItemsBucket(items = items, emptyMessage = emptyMessage)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun parseItemArray(itemsArray: JSONArray): List<WidgetItem> {
|
||||
val items = mutableListOf<WidgetItem>()
|
||||
for (i in 0 until itemsArray.length()) {
|
||||
val item = itemsArray.optJSONObject(i) ?: continue
|
||||
items += WidgetItem(
|
||||
title = item.optString("title"),
|
||||
subtitle = item.optString("subtitle"),
|
||||
link = item.optString("link"),
|
||||
iconUrl = item.optString("iconUrl"),
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val LAYOUT_TIMEOUT_MS = 2_000L
|
||||
private val DEFAULT_LAYOUT = listOf("recommendations", "spreed", "mail", "calendar")
|
||||
}
|
||||
}
|
||||
|
||||
private data class WidgetMeta(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val order: Int,
|
||||
val iconUrl: String,
|
||||
)
|
||||
|
||||
data class WidgetItem(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val link: String,
|
||||
val iconUrl: String = "",
|
||||
)
|
||||
|
||||
data class DashboardWidgetGroup(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val iconUrl: String = "",
|
||||
val items: List<WidgetItem>,
|
||||
val emptyMessage: String? = null,
|
||||
)
|
||||
|
||||
private data class WidgetItemsBucket(
|
||||
val items: List<WidgetItem> = emptyList(),
|
||||
val emptyMessage: String? = null,
|
||||
)
|
||||
@@ -1,176 +0,0 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.designsystem.F7Colors
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ListCard
|
||||
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFiles
|
||||
import ru.forbion.f7cloud.feature.files.OfficeWarmup
|
||||
|
||||
@Composable
|
||||
fun WidgetsScreen(
|
||||
session: AuthSession,
|
||||
modifier: Modifier = Modifier,
|
||||
onUnauthorized: () -> Unit = {},
|
||||
onOpenOfficeEditor: (OfficeEditorLaunch) -> Unit = {},
|
||||
onOpenLink: (String) -> Unit = {},
|
||||
) {
|
||||
val vm: WidgetsViewModel = viewModel()
|
||||
val state by vm.state.collectAsState()
|
||||
|
||||
LaunchedEffect(session.serverUrl, session.username) {
|
||||
OfficeWarmup.warm(session)
|
||||
vm.load(session)
|
||||
}
|
||||
LaunchedEffect(state.unauthorized) {
|
||||
if (state.unauthorized) onUnauthorized()
|
||||
}
|
||||
LaunchedEffect(state.editorLaunch) {
|
||||
val launch = state.editorLaunch ?: return@LaunchedEffect
|
||||
onOpenOfficeEditor(launch)
|
||||
vm.clearEditorLaunch()
|
||||
}
|
||||
|
||||
F7ModuleScreen(
|
||||
modifier = modifier,
|
||||
loading = state.loading && state.groups.isEmpty(),
|
||||
error = state.error,
|
||||
onRefresh = { vm.load(session) },
|
||||
) {
|
||||
if (!state.loading && state.groups.isEmpty() && state.error.isNullOrBlank()) {
|
||||
Text(
|
||||
text = "Нет виджетов на главной. Настройте их в веб-интерфейсе.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
if (state.openingFile != null) {
|
||||
CircularProgressIndicator(color = F7Colors.Primary)
|
||||
Text(
|
||||
text = "Открываем «${state.openingFile}»…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
items(state.groups, key = { it.id }) { group ->
|
||||
F7ListCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (group.iconUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = group.iconUrl,
|
||||
contentDescription = group.title,
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
group.title.ifBlank { group.id },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
group.items.forEach { item ->
|
||||
val canOpenOffice = item.link.isNotBlank() &&
|
||||
OfficeFileLinks.parseFileId(item.link, session.serverUrl) != null &&
|
||||
(OfficeFiles.isOfficeFile(item.title) ||
|
||||
OfficeFiles.isOfficeFile(item.link.substringAfterLast('/')))
|
||||
val canOpenLink = item.link.isNotBlank() && !canOpenOffice
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
when {
|
||||
canOpenOffice -> Modifier.clickable { vm.openWidgetItem(session, item) }
|
||||
canOpenLink -> Modifier.clickable { onOpenLink(item.link) }
|
||||
else -> Modifier
|
||||
},
|
||||
)
|
||||
.padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (item.iconUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = item.iconUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(32.dp),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
androidx.compose.foundation.layout.Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
item.title.ifBlank { "—" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (item.subtitle.isNotBlank()) {
|
||||
Text(
|
||||
item.subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
if (canOpenOffice) {
|
||||
Text(
|
||||
text = "Открыть в Office",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
} else if (canOpenLink) {
|
||||
Text(
|
||||
text = "Открыть",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (group.items.isEmpty()) {
|
||||
Text(
|
||||
text = group.emptyMessage ?: "Нет элементов",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = F7Colors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import ru.forbion.f7cloud.feature.files.OfficeEditorLaunch
|
||||
|
||||
data class WidgetsUiState(
|
||||
val loading: Boolean = false,
|
||||
val groups: List<DashboardWidgetGroup> = emptyList(),
|
||||
val error: String? = null,
|
||||
val unauthorized: Boolean = false,
|
||||
val openingFile: String? = null,
|
||||
val editorLaunch: OfficeEditorLaunch? = null,
|
||||
)
|
||||
@@ -1,79 +0,0 @@
|
||||
package ru.forbion.f7cloud.feature.widgets
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import ru.forbion.f7cloud.core.auth.AuthSession
|
||||
import ru.forbion.f7cloud.core.network.UnauthorizedException
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileLinks
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFileOpener
|
||||
import ru.forbion.f7cloud.feature.files.OfficeFiles
|
||||
|
||||
class WidgetsViewModel(
|
||||
private val repository: WidgetsRepository = WidgetsRepository(),
|
||||
private val officeFileOpener: OfficeFileOpener = OfficeFileOpener(),
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(WidgetsUiState())
|
||||
val state: StateFlow<WidgetsUiState> = _state.asStateFlow()
|
||||
|
||||
fun load(session: AuthSession) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(loading = true, error = null)
|
||||
runCatching { repository.loadDashboard(session) }
|
||||
.onSuccess { groups ->
|
||||
_state.value = _state.value.copy(loading = false, groups = groups)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
loading = false,
|
||||
error = t.message,
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openWidgetItem(session: AuthSession, item: WidgetItem) {
|
||||
if (item.link.isBlank()) return
|
||||
val fileId = OfficeFileLinks.parseFileId(item.link, session.serverUrl)
|
||||
val isOffice = OfficeFiles.isOfficeFile(item.title) ||
|
||||
OfficeFiles.isOfficeFile(item.link.substringAfterLast('/'))
|
||||
if (fileId == null || !isOffice) {
|
||||
_state.value = _state.value.copy(
|
||||
error = "Открытие через Office доступно только для документов Word/Excel",
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
_state.value = _state.value.copy(openingFile = item.title, error = null)
|
||||
runCatching {
|
||||
officeFileOpener.prepareLaunch(
|
||||
session,
|
||||
fileId,
|
||||
OfficeFileLinks.titleFromLink(item.link, item.title),
|
||||
)
|
||||
}
|
||||
.onSuccess { launch ->
|
||||
_state.value = _state.value.copy(
|
||||
openingFile = null,
|
||||
editorLaunch = launch,
|
||||
)
|
||||
}
|
||||
.onFailure { t ->
|
||||
_state.value = _state.value.copy(
|
||||
openingFile = null,
|
||||
error = t.message ?: "Не удалось открыть документ",
|
||||
unauthorized = t is UnauthorizedException,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearEditorLaunch() {
|
||||
_state.value = _state.value.copy(editorLaunch = null)
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -1,8 +1,13 @@
|
||||
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC -Dfile.encoding=UTF-8
|
||||
org.gradle.parallel=false
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
android.useAndroidX=true
|
||||
# enableJetifier ОБЯЗАТЕЛЕН: vendor-форк talk-android тянет legacy-зависимости с
|
||||
# android.support.* (напр. FlexibleAdapter → android.support.v7.widget.RecyclerView) —
|
||||
# без jetifier сборка падает (проверено). Убрать можно только после чистки vendor-депов.
|
||||
android.enableJetifier=true
|
||||
# nonTransitiveRClass ОСТАВЛЕН false: vendor ссылается на R транзитивно (R.color.transparent,
|
||||
# R.id.container из зависимостей) — true ломает ~сотни ссылок в форке (проверено).
|
||||
android.nonTransitiveRClass=false
|
||||
kotlin.code.style=official
|
||||
f7cloudAsLibrary=true
|
||||
|
||||
@@ -19,12 +19,10 @@ rootProject.name = "F7CloudMobile"
|
||||
|
||||
include(":app")
|
||||
include(":core:auth")
|
||||
include(":core:data")
|
||||
include(":core:database")
|
||||
include(":core:designsystem")
|
||||
include(":core:network")
|
||||
include(":core:push")
|
||||
include(":core:ui")
|
||||
include(":feature:files")
|
||||
include(":feature:talk")
|
||||
include(":feature:talk-native")
|
||||
|
||||
Reference in New Issue
Block a user