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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user