diff --git a/apps/mobile/app/src/main/java/com/example/avtex/Navigation.kt b/apps/mobile/app/src/main/java/com/example/avtex/Navigation.kt index 44ca1f1..a56ecba 100644 --- a/apps/mobile/app/src/main/java/com/example/avtex/Navigation.kt +++ b/apps/mobile/app/src/main/java/com/example/avtex/Navigation.kt @@ -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)) } } } diff --git a/apps/mobile/app/src/main/java/com/example/avtex/ui/MindmapsScreen.kt b/apps/mobile/app/src/main/java/com/example/avtex/ui/MindmapsScreen.kt new file mode 100644 index 0000000..0c5e0f4 --- /dev/null +++ b/apps/mobile/app/src/main/java/com/example/avtex/ui/MindmapsScreen.kt @@ -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(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): List = 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() + 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 + } +}