Initial import of f7cloud-mobile native Android app.

Kotlin/Compose client for F7cloud (mail, files, talk, calendar, contacts, tasks, support).
Current version: 0.5.113 (build 121).
This commit is contained in:
F7cloud Mobile
2026-07-07 12:05:18 +03:00
commit fd17df80a8
1789 changed files with 246889 additions and 0 deletions
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />
@@ -0,0 +1,121 @@
package ru.forbion.f7cloud.feature.deck
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 DeckRepository {
private fun apiBase(session: AuthSession): String {
return "${session.serverUrl.trimEnd('/')}/index.php/apps/deck/api/v1.0"
}
suspend fun loadBoards(session: AuthSession): List<DeckBoard> {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val json = getJson(client, "${apiBase(session)}/boards")
val array = when (json) {
is JSONArray -> json
is JSONObject -> JSONArray().put(json)
else -> JSONArray()
}
val out = mutableListOf<DeckBoard>()
for (i in 0 until array.length()) {
val board = array.optJSONObject(i) ?: continue
val id = board.optInt("id", 0)
val title = board.optString("title")
if (id > 0 && title.isNotBlank()) {
out += DeckBoard(id = id, title = title, color = board.optString("color"))
}
}
return out
}
suspend fun loadCard(session: AuthSession, cardId: Int): DeckCardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val json = getJson(client, "${apiBase(session)}/cards/$cardId") as JSONObject
val boardId = json.optInt("boardId", json.optInt("board_id", 0))
if (boardId <= 0) error("Карточка не найдена")
return DeckCardDetail(
cardId = cardId,
boardId = boardId,
title = json.optString("title"),
)
}
suspend fun loadBoardDetail(session: AuthSession, boardId: Int): DeckBoardDetail {
val client = NetworkFactory.newAuthedClient(session.username, session.appPassword, session.trustAllCerts)
val stacksJson = getJson(client, "${apiBase(session)}/boards/$boardId/stacks")
val stacksArray = when (stacksJson) {
is JSONArray -> stacksJson
else -> JSONArray()
}
val stacks = mutableListOf<DeckStack>()
for (i in 0 until stacksArray.length()) {
val stack = stacksArray.optJSONObject(i) ?: continue
val stackId = stack.optInt("id", 0)
val title = stack.optString("title")
val cards = mutableListOf<DeckCard>()
val cardsArray = stack.optJSONArray("cards") ?: JSONArray()
for (c in 0 until cardsArray.length()) {
val card = cardsArray.optJSONObject(c) ?: continue
val cardTitle = card.optString("title")
if (cardTitle.isNotBlank()) {
cards += DeckCard(
id = card.optInt("id", 0),
title = cardTitle,
done = card.has("done") && !card.isNull("done"),
)
}
}
if (stackId > 0) {
stacks += DeckStack(id = stackId, title = title.ifBlank { "Stack" }, cards = cards)
}
}
return DeckBoardDetail(boardId = boardId, stacks = stacks)
}
private fun getJson(client: okhttp3.OkHttpClient, url: String): Any {
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
if (response.code == 401) throw UnauthorizedException()
if (!response.isSuccessful || response.body == null) {
error("Deck API HTTP ${response.code}")
}
val body = response.body!!.string().trim()
if (body.startsWith("[")) return JSONArray(body)
if (body.startsWith("{")) return JSONObject(body)
return JSONArray()
}
}
}
data class DeckBoard(
val id: Int,
val title: String,
val color: String,
)
data class DeckStack(
val id: Int,
val title: String,
val cards: List<DeckCard>,
)
data class DeckCard(
val id: Int,
val title: String,
val done: Boolean,
)
data class DeckBoardDetail(
val boardId: Int,
val stacks: List<DeckStack>,
)
data class DeckCardDetail(
val cardId: Int,
val boardId: Int,
val title: String,
)
@@ -0,0 +1,82 @@
package ru.forbion.f7cloud.feature.deck
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.Modifier
import androidx.lifecycle.viewmodel.compose.viewModel
import ru.forbion.f7cloud.core.auth.AuthSession
import ru.forbion.f7cloud.core.designsystem.F7ListCard
import ru.forbion.f7cloud.core.designsystem.F7ModuleScreen
import ru.forbion.f7cloud.core.designsystem.F7OverlayDismissHandler
import ru.forbion.f7cloud.core.designsystem.F7SecondaryButton
@Composable
fun DeckScreen(
session: AuthSession,
modifier: Modifier = Modifier,
openCardId: Int? = null,
onOpenCardConsumed: () -> Unit = {},
onUnauthorized: () -> Unit = {},
) {
val vm: DeckViewModel = viewModel()
val state by vm.state.collectAsState()
LaunchedEffect(session.serverUrl, session.username) {
vm.load(session)
}
LaunchedEffect(openCardId) {
val cardId = openCardId ?: return@LaunchedEffect
vm.openCardById(session, cardId)
onOpenCardConsumed()
}
LaunchedEffect(state.unauthorized) {
if (state.unauthorized) onUnauthorized()
}
F7OverlayDismissHandler(
enabled = state.boardDetail != null,
onDismiss = vm::closeBoard,
)
F7ModuleScreen(
modifier = modifier,
loading = state.loading && state.boards.isEmpty() && state.boardDetail == null,
error = state.error,
onRefresh = { vm.load(session) },
headerActions = {
if (state.selectedBoardId != null) {
F7SecondaryButton(text = "Назад", onClick = { vm.closeBoard() })
}
},
) {
val detail = state.boardDetail
if (detail != null) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(detail.stacks, key = { it.id }) { stack ->
F7ListCard {
Text(stack.title, style = MaterialTheme.typography.titleSmall)
stack.cards.forEach { card ->
val prefix = if (card.done) "" else ""
Text(prefix + card.title, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(state.boards, key = { it.id }) { board ->
F7ListCard(onClick = { vm.openBoard(session, board.id) }) {
Text(board.title, style = MaterialTheme.typography.titleSmall)
}
}
}
}
}
}
@@ -0,0 +1,87 @@
package ru.forbion.f7cloud.feature.deck
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
data class DeckUiState(
val loading: Boolean = false,
val boards: List<DeckBoard> = emptyList(),
val selectedBoardId: Int? = null,
val boardDetail: DeckBoardDetail? = null,
val error: String? = null,
val unauthorized: Boolean = false,
)
class DeckViewModel(
private val repository: DeckRepository = DeckRepository(),
) : ViewModel() {
private val _state = MutableStateFlow(DeckUiState())
val state: StateFlow<DeckUiState> = _state.asStateFlow()
fun load(session: AuthSession) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching { repository.loadBoards(session) }
.onSuccess { boards ->
_state.value = _state.value.copy(loading = false, boards = boards, error = null)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun openBoard(session: AuthSession, boardId: Int) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(
loading = true,
selectedBoardId = boardId,
boardDetail = null,
error = null,
)
runCatching { repository.loadBoardDetail(session, boardId) }
.onSuccess { detail ->
_state.value = _state.value.copy(loading = false, boardDetail = detail)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message,
unauthorized = t is UnauthorizedException,
)
}
}
}
fun closeBoard() {
_state.value = _state.value.copy(selectedBoardId = null, boardDetail = null)
}
fun openCardById(session: AuthSession, cardId: Int) {
viewModelScope.launch(Dispatchers.IO) {
_state.value = _state.value.copy(loading = true, error = null)
runCatching { repository.loadCard(session, cardId) }
.onSuccess { card ->
openBoard(session, card.boardId)
}
.onFailure { t ->
_state.value = _state.value.copy(
loading = false,
error = t.message ?: "Не удалось открыть карточку",
unauthorized = t is UnauthorizedException,
)
}
}
}
}