feat(mobile): add Mindmaps & Canvas interactive screen with server sync and embedded flow viewer

This commit is contained in:
2026-09-28 12:07:15 +05:00
parent 176afcff39
commit f60ac2a5f3
2 changed files with 518 additions and 11 deletions
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
@@ -31,7 +31,7 @@ import com.example.avtex.theme.ObsidianSurfaceElevated
import com.example.avtex.theme.TextMuted
import com.example.avtex.ui.DashboardScreen
import com.example.avtex.ui.ListsScreen
import com.example.avtex.ui.OnePlusGuideScreen
import com.example.avtex.ui.MindmapsScreen
import com.example.avtex.ui.SettingsScreen
@Composable
@@ -58,10 +58,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
icon = { Icon(Icons.Default.Hub, contentDescription = "Майндкарты") },
label = {
Text(
"Сон",
"Карты",
fontSize = 11.sp,
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
)
@@ -84,10 +84,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Сон") },
label = {
Text(
"Настройки",
"Сон",
fontSize = 11.sp,
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
)
@@ -97,10 +97,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
label = {
Text(
"OnePlus",
"Настройки",
fontSize = 11.sp,
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
)
@@ -111,10 +111,10 @@ fun MainNavigation() {
}
) { innerPadding ->
when (selectedTab) {
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
0 -> MindmapsScreen(modifier = Modifier.padding(innerPadding))
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
3 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
}
}
}
@@ -0,0 +1,507 @@
package com.example.avtex.ui
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.view.ViewGroup
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.avtex.theme.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class CanvasItem(
val id: String,
val name: String,
val description: String = "",
val nodeCount: Int = 0,
val isPublic: Boolean = false,
val updatedAt: String = ""
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MindmapsScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
val serverUrl = remember {
mutableStateOf(prefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
}
var canvases by remember {
mutableStateOf(
listOf(
CanvasItem("c-1", "Архитектура XFlow (Monorepo & Onion)", "Ядро системы, Nuxt 4 и модули", 14, true, "Сегодня"),
CanvasItem("c-2", "План развития 2026", "AI ассистент, списки, трекер сна", 9, false, "Вчера"),
CanvasItem("c-3", "Идеи и проекты (Brainstorm)", "Синхронизация Obsidian и Mobile", 6, false, "25 сен")
)
)
}
var isLoading by remember { mutableStateOf(false) }
var selectedCanvasId by remember { mutableStateOf<String?>(null) }
var showCreateDialog by remember { mutableStateOf(false) }
var newCanvasName by remember { mutableStateOf("") }
// Если открыт конкретный канвас — отображаем интерактивный редактор Flow через WebView
if (selectedCanvasId != null) {
BackHandler {
selectedCanvasId = null
}
CanvasEditorView(
canvasId = selectedCanvasId!!,
serverUrl = serverUrl.value,
onClose = { selectedCanvasId = null }
)
return
}
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = ObsidianBg,
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Майндкарты",
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(NeonViolet.copy(alpha = 0.2f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text("XFlow Canvas", fontSize = 10.sp, color = NeonViolet, fontWeight = FontWeight.Bold)
}
}
},
actions = {
IconButton(onClick = {
isLoading = true
scope.launch {
canvases = fetchCanvases(serverUrl.value, canvases)
isLoading = false
}
}) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить", tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = ObsidianSurface)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true },
containerColor = NeonViolet,
contentColor = Color.White,
shape = CircleShape
) {
Icon(Icons.Default.Add, contentDescription = "Создать майндкарту")
}
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
// Баннер интеграции
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(
Brush.horizontalGradient(
listOf(
NeonViolet.copy(alpha = 0.2f),
ElectricIndigo.copy(alpha = 0.15f),
Color.Transparent
)
)
)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(NeonViolet.copy(alpha = 0.25f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Hub, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Интерактивный граф узлов", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
Text(
"Полная совместимость с Obsidian Canvas и веб-сервером XFlow",
color = TextMuted,
fontSize = 12.sp,
maxLines = 2
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
if (isLoading) {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NeonViolet)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp)
) {
items(canvases, key = { it.id }) { canvas ->
CanvasCard(
canvas = canvas,
onClick = { selectedCanvasId = canvas.id }
)
}
}
}
}
}
if (showCreateDialog) {
AlertDialog(
onDismissRequest = { showCreateDialog = false },
containerColor = ObsidianSurfaceElevated,
title = { Text("Новая майндкарта", color = Color.White, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = newCanvasName,
onValueChange = { newCanvasName = it },
placeholder = { Text("Название майндкарты (напр. Проект X)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = NeonViolet,
unfocusedBorderColor = GlassBorder
),
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
Button(
onClick = {
if (newCanvasName.isNotBlank()) {
val newItem = CanvasItem(
id = "c-" + System.currentTimeMillis(),
name = newCanvasName.trim(),
description = "Создано с мобильного XFlow",
nodeCount = 1,
updatedAt = "Только что"
)
canvases = listOf(newItem) + canvases
newCanvasName = ""
showCreateDialog = false
selectedCanvasId = newItem.id
}
},
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Text("Создать")
}
},
dismissButton = {
TextButton(onClick = { showCreateDialog = false }) {
Text("Отмена", color = TextMuted)
}
}
)
}
}
@Composable
fun CanvasCard(
canvas: CanvasItem,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurfaceElevated),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.AccountTree, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = canvas.name,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (canvas.description.isNotBlank()) {
Text(
text = canvas.description,
color = TextMuted,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${canvas.nodeCount} узлов",
color = CyanAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = canvas.updatedAt,
color = TextMuted,
fontSize = 11.sp
)
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = TextMuted,
modifier = Modifier.size(20.dp)
)
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun CanvasEditorView(
canvasId: String,
serverUrl: String,
onClose: () -> Unit
) {
var webViewLoading by remember { mutableStateOf(true) }
var hasError by remember { mutableStateOf(false) }
val editorUrl = remember(canvasId, serverUrl) {
val base = serverUrl.trimEnd('/')
if (canvasId.startsWith("c-")) {
"$base/canvas"
} else {
"$base/canvas/$canvasId"
}
}
Box(modifier = Modifier.fillMaxSize().background(ObsidianBg)) {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
webViewLoading = true
hasError = false
}
override fun onPageFinished(view: WebView?, url: String?) {
webViewLoading = false
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
webViewLoading = false
hasError = true
}
}
loadUrl(editorUrl)
}
},
modifier = Modifier.fillMaxSize()
)
// Верхняя панель закрытия редактора
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onClose,
modifier = Modifier
.clip(CircleShape)
.background(ObsidianSurfaceElevated.copy(alpha = 0.85f))
.border(1.dp, GlassBorder, CircleShape)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = Color.White)
}
Text(
"XFlow Canvas Viewer",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(ObsidianSurfaceElevated.copy(alpha = 0.85f))
.border(1.dp, GlassBorder, RoundedCornerShape(20.dp))
.padding(horizontal = 14.dp, vertical = 6.dp)
)
}
if (webViewLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = NeonViolet)
}
}
if (hasError) {
Box(
modifier = Modifier
.fillMaxSize()
.background(ObsidianBg.copy(alpha = 0.95f))
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.CloudOff, contentDescription = null, tint = TextMuted, modifier = Modifier.size(54.dp))
Spacer(modifier = Modifier.height(16.dp))
Text("Редактор оффлайн", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Не удалось подключиться к $editorUrl.\nПроверьте подключение к серверу XFlow в Настройках.",
color = TextMuted,
fontSize = 13.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = onClose,
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Text("Вернуться к списку")
}
}
}
}
}
}
suspend fun fetchCanvases(serverUrl: String, fallback: List<CanvasItem>): List<CanvasItem> = withContext(Dispatchers.IO) {
val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
try {
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}/api/canvas")
.get()
.build()
val response = client.newCall(request).execute()
response.use {
if (it.isSuccessful) {
val bodyStr = it.body?.string() ?: return@withContext fallback
val json = JSONObject(bodyStr)
val list = mutableListOf<CanvasItem>()
val arr = json.optJSONArray("canvases") ?: JSONArray()
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
list.add(
CanvasItem(
id = obj.optString("id", "c-$i"),
name = obj.optString("name", "Майндкарта"),
description = obj.optString("description", ""),
nodeCount = obj.optJSONArray("nodes")?.length() ?: 0,
isPublic = obj.optBoolean("isPublic", false),
updatedAt = obj.optString("updatedAt", "Недавно")
)
)
}
if (list.isNotEmpty()) list else fallback
} else {
fallback
}
}
} catch (e: Exception) {
fallback
}
}