4 Commits

49 changed files with 299 additions and 1916 deletions
+9 -46
View File
@@ -1,55 +1,18 @@
# Version Control
.git
.gitignore
# Dependencies & Cache
node_modules
**/node_modules
.output
.nuxt
dist
.cache
*.log
# Environment variables
node_modules/.cache
.git
.gitignore
.env
.env.*
.env.local
!.env.example
!.env.example.coolify
# Native Mobile (Android)
apps/mobile
app
.gradle
**/.gradle
.kotlin
build
**/build
local.properties
*.apk
*.aab
# Desktop CLI & Contracts build
apps/desktop
packages/shared-contracts/dist
# Legacy configs & DB dumps
docker/legacy-server
*.sql
*.sqlite
*.db
# Notes, Mindmaps & Brainstorming
.obsidian
.qwen
scratch
scripts
dist
*.log
*.canvas
# IDE settings
.idea
.vscode
*.iml
*.ipr
*.iws
# Exclude non-web monorepo apps from Docker build context
apps/mobile
apps/desktop
packages/shared-contracts/dist
-4
View File
@@ -39,7 +39,3 @@ server/api/node_modules/
obsidian-git/
/app/generated/prisma
*.apk
local.properties
.gradle/
+13 -17
View File
@@ -1,7 +1,7 @@
# ==========================================
# Stage 1: builder
# ==========================================
FROM node:22-slim AS builder
FROM node:22-alpine AS builder
ENV HUSKY=0
ENV NODE_ENV=development
@@ -9,8 +9,7 @@ ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends openssl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache libc6-compat git openssl
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
@@ -20,12 +19,12 @@ ARG OPEN_ROUTER_API_KEY=""
ARG NUXT_OPEN_ROUTER_API_KEY=""
ARG NUXT_QWEN_API_KEY=""
ARG DEEPSEEK_API_KEY=""
ARG FIREBASE_API_KEY="AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck"
ARG FIREBASE_AUTH_DOMAIN="xflow-d8dd5.firebaseapp.com"
ARG FIREBASE_PROJECT_ID="xflow-d8dd5"
ARG FIREBASE_STORAGE_BUCKET="xflow-d8dd5.firebasestorage.app"
ARG FIREBASE_MESSAGING_SENDER_ID="944408960366"
ARG FIREBASE_APP_ID="1:944408960366:web:4d2cd393e85a0ca9b4357f"
ARG FIREBASE_API_KEY=""
ARG FIREBASE_AUTH_DOMAIN=""
ARG FIREBASE_PROJECT_ID=""
ARG FIREBASE_STORAGE_BUCKET=""
ARG FIREBASE_MESSAGING_SENDER_ID=""
ARG FIREBASE_APP_ID=""
ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \
@@ -59,11 +58,10 @@ ENV SITE_URL=${SITE_URL} \
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
TZ=${TZ}
# Копируем package.json, prisma схемы и конфиг
# Копируем package.json и prisma схемы из apps/web
COPY apps/web/package*.json ./
COPY apps/web/prisma ./prisma
COPY apps/web/prisma.config.ts ./
RUN npm ci --include=optional && npx prisma generate
RUN npm ci --ignore-scripts && npx prisma generate
# Копируем исходный код apps/web
COPY apps/web ./
@@ -72,15 +70,14 @@ RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=developmen
# ==========================================
# Stage 2: runner
# ==========================================
FROM node:22-slim AS runner
FROM node:22-alpine AS runner
ENV HUSKY=0
ENV NODE_ENV=production
WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends openssl tzdata ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache tzdata openssl
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
@@ -99,7 +96,6 @@ ENV SITE_URL=${SITE_URL} \
COPY --from=builder /src/.output ./.output
COPY --from=builder /src/prisma ./prisma
COPY --from=builder /src/prisma.config.ts ./
COPY --from=builder /src/package.json ./
COPY --from=builder /src/node_modules ./node_modules
@@ -112,4 +108,4 @@ ENV NITRO_PORT=3000
EXPOSE 3000
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && exec node .output/server/index.mjs"]
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && exec node .output/server/index.mjs"]
-71
View File
@@ -1,71 +0,0 @@
.PHONY: help up start dev-node db dev down restart logs logs-app logs-db ps db-push db-shell clean install-docker
CYAN := \033[36m
GREEN := \033[32m
YELLOW:= \033[33m
RESET := \033[0m
help:
@echo "$(CYAN)XFlow Stack Management:$(RESET)"
@echo " $(GREEN)make up$(RESET) - Запустить стек (в Docker если установлен, иначе локально через Node)"
@echo " $(GREEN)make start$(RESET) - Запустить собранный Nuxt-сервер на Node (порт 3000)"
@echo " $(GREEN)make db$(RESET) - Запустить базы (PostgreSQL + Redis) в Docker"
@echo " $(GREEN)make dev$(RESET) - Поднять DB в Docker + запустить локальный Nuxt"
@echo " $(GREEN)make down$(RESET) - Остановить контейнеры Docker"
@echo " $(GREEN)make logs$(RESET) - Логи Docker-контейнеров"
@echo " $(GREEN)make install-docker$(RESET) - Инструкция по установке Docker на Ubuntu"
up:
@if command -v docker >/dev/null 2>&1; then \
echo "$(GREEN)Запуск контейнеров через Docker Compose...$(RESET)"; \
docker compose up -d --build; \
else \
echo "$(YELLOW)Docker не установлен в системе.$(RESET)"; \
echo "$(CYAN)Чтобы установить Docker: sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2 && sudo usermod -aG docker \$$USER$(RESET)"; \
echo "$(GREEN)Запускаем XFlow через Node.js на порту 3000...$(RESET)"; \
$(MAKE) start; \
fi
start:
@PORT=3000 node /home/alexives/Desktop/StudioProjects/XFlow/apps/web/.output/server/index.mjs
db:
docker compose up -d db redis
@echo "$(GREEN)PostgreSQL (5432) и Redis (6379) запущены!$(RESET)"
dev: db
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npm --prefix apps/web run prisma db push || true
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npm --prefix apps/web run dev
down:
@if command -v docker >/dev/null 2>&1; then docker compose down; fi
restart:
@if command -v docker >/dev/null 2>&1; then docker compose restart; else $(MAKE) start; fi
logs:
docker compose logs -f
logs-app:
docker compose logs -f app
logs-db:
docker compose logs -f db
ps:
docker compose ps
db-push:
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npx --prefix apps/web prisma db push
db-shell:
docker compose exec db psql -U xflow -d xflow
clean:
docker compose down -v --remove-orphans
install-docker:
@echo "$(CYAN)Для установки Docker выполните:$(RESET)"
@echo "sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2"
@echo "sudo usermod -aG docker \$$USER"
@echo "$(CYAN)Затем обновите сессию: newgrp docker$(RESET)"
+2 -7
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.example.avtex"
minSdk = 26
targetSdk = 36
versionCode = 5
versionName = "1.4"
versionCode = 1
versionName = "1.0"
}
signingConfigs {
@@ -49,11 +49,6 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
}
kotlin {
+8 -43
View File
@@ -15,62 +15,29 @@
<application
android:name=".AvtexApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher_avtex"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_avtex_round"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Avtex">
<activity
android:name=".MainActivity"
android:exported="true"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:icon="@mipmap/ic_launcher_avtex"
android:roundIcon="@mipmap/ic_launcher_avtex_round">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link intent-filter for Gemini / Assistant App Actions & XFlow links -->
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="xflow" />
</intent-filter>
<!-- Google Assistant Native Note-Taking Action ("Сделай заметку...") -->
<intent-filter>
<action android:name="android.intent.action.CREATE_NOTE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Android System Share Action (Поделиться текстом из любого приложения или ассистента) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Google Voice Actions Note Creation -->
<intent-filter>
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Android Text Selection Process Action (выделение текста в любом месте -> отправить в Avtex) -->
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
<data
android:scheme="xflow"
android:host="lists" />
</intent-filter>
</activity>
@@ -99,9 +66,7 @@
<receiver
android:name=".engine.BootReceiver"
android:exported="true"
android:icon="@mipmap/ic_launcher_avtex"
android:roundIcon="@mipmap/ic_launcher_avtex_round">
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
@@ -1,52 +1,21 @@
package com.example.avtex
import android.app.Activity
import android.app.role.RoleManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.speech.RecognizerIntent
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.example.avtex.theme.AvtexTheme
import kotlinx.coroutines.launch
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
class MainActivity : ComponentActivity() {
private val speechRecognizerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val matches = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
val spokenText = matches?.firstOrNull()
if (!spokenText.isNullOrBlank()) {
routeAndSaveNote(spokenText, preferredList = null)
}
}
}
private val roleRequestLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
Toast.makeText(this, "Avtex назначен приложением заметок по умолчанию!", Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIncomingIntent(intent)
@@ -54,10 +23,6 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
AvtexTheme {
CompositionLocalProvider(
LocalVoiceInputLauncher provides { launchVoiceInput() },
LocalRoleNotesRequester provides { requestDefaultNotesRole() }
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
@@ -67,224 +32,55 @@ class MainActivity : ComponentActivity() {
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIncomingIntent(intent)
}
fun launchVoiceInput() {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ru-RU")
putExtra(RecognizerIntent.EXTRA_PROMPT, "Скажите заметку, задачу или фильм...")
}
try {
speechRecognizerLauncher.launch(intent)
} catch (e: Exception) {
Log.e(TAG, "Speech recognizer not available", e)
Toast.makeText(this, "Голосовой ввод недоступен на этом устройстве", Toast.LENGTH_SHORT).show()
}
}
fun requestDefaultNotesRole() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
val roleManager = getSystemService(RoleManager::class.java)
if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_NOTES)) {
val roleIntent = roleManager.createRequestRoleIntent(RoleManager.ROLE_NOTES)
roleRequestLauncher.launch(roleIntent)
return
}
} catch (e: Exception) {
Log.e(TAG, "Failed to request ROLE_NOTES", e)
}
}
try {
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
startActivity(settingsIntent)
} catch (e: Exception) {
Toast.makeText(this, "Откройте: Настройки -> Приложения -> Приложения по умолчанию", Toast.LENGTH_LONG).show()
}
}
private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return
val action = intent.action
Log.d(TAG, "Incoming intent action: $action, data: ${intent.data}")
// 1. Google Assistant / Android System Note Action (android.intent.action.CREATE_NOTE)
if (action == Intent.ACTION_CREATE_NOTE) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
?: intent.getStringExtra("android.intent.extra.TEXT")
?: intent.getStringExtra(Intent.EXTRA_TITLE)
val title = intent.getStringExtra(Intent.EXTRA_TITLE)
if (!text.isNullOrBlank()) {
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
routeAndSaveNote(fullText, preferredList = null)
return
} else {
launchVoiceInput()
return
}
}
// 2. Google Voice Actions (com.google.android.gms.actions.CREATE_NOTE)
if (action == "com.google.android.gms.actions.CREATE_NOTE") {
val text = intent.getStringExtra("com.google.android.gms.actions.extra.TEXT")
?: intent.getStringExtra(Intent.EXTRA_TEXT)
val name = intent.getStringExtra("com.google.android.gms.actions.extra.NAME")
if (!text.isNullOrBlank()) {
val fullText = if (!name.isNullOrBlank() && name != text) "$name: $text" else text
routeAndSaveNote(fullText, preferredList = null)
return
} else {
launchVoiceInput()
return
}
}
// 3. Android Text Selection (ACTION_PROCESS_TEXT)
if (action == Intent.ACTION_PROCESS_TEXT) {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
if (!text.isNullOrBlank()) {
routeAndSaveNote(text, preferredList = null)
return
}
}
// 4. Android Share Sheet Action (android.intent.action.SEND with text/plain)
if (action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (!sharedText.isNullOrBlank()) {
val fullText = if (!subject.isNullOrBlank()) "$subject — $sharedText" else sharedText
routeAndSaveNote(fullText, preferredList = null)
return
}
}
// 5. Deep Link Action (xflow://...)
// 1. Обработка App Actions через Deep Link xflow://lists/add
val data = intent.data
if (data != null && data.scheme == "xflow") {
val host = data.host ?: ""
val path = data.path ?: ""
if (host == "voice" || path.contains("record")) {
launchVoiceInput()
return
}
val listName = data.getQueryParameter("list_name")
?: data.getQueryParameter("name")
?: data.getQueryParameter("list")
val itemName = data.getQueryParameter("item_name")
?: data.getQueryParameter("item")
?: data.getQueryParameter("note_text")
?: data.getQueryParameter("text")
?: data.getQueryParameter("note_title")
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
if (!itemName.isNullOrBlank()) {
routeAndSaveNote(itemName, preferredList = listName)
saveItemFromAssistant(itemName, listName)
return
}
}
// 6. Google Assistant / Gemini Built-in Intent Extras
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
val extras = intent.extras
if (extras != null) {
val itemName = extras.getString("itemListElement.name")
?: extras.getString("item_name")
?: extras.getString("note.text")
?: extras.getString("note_text")
?: extras.getString("text")
val listName = extras.getString("itemList.name")
?: extras.getString("list_name")
?: extras.getString("category")
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
if (!itemName.isNullOrBlank()) {
routeAndSaveNote(itemName, preferredList = listName)
saveItemFromAssistant(itemName, listName)
}
}
}
private fun routeAndSaveNote(rawText: String, preferredList: String?) {
val cleanText = rawText.trim()
if (cleanText.isBlank()) return
val (targetList, category) = resolveListAndCategory(cleanText, preferredList)
private fun saveItemFromAssistant(itemName: String, listName: String) {
lifecycleScope.launch {
try {
val repo = AvtexApplication.instance.notesRepository
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
repo.addNoteItem(
title = cleanText,
listTitle = targetList,
title = itemName,
listTitle = listName,
category = category
)
Toast.makeText(
this@MainActivity,
"✅ Avtex: Записано в «$targetList»:\n$cleanText",
"✅ Записано в XFlow: $itemName ($listName)",
Toast.LENGTH_LONG
).show()
AvtexApplication.instance.settingsRepository.addLog(
tag = "Assistant",
message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"",
isImportant = true
)
} catch (e: Exception) {
Log.e(TAG, "Failed to save note from assistant", e)
Toast.makeText(
this@MainActivity,
"Ошибка сохранения: ${e.localizedMessage}",
Toast.LENGTH_SHORT
).show()
}
}
}
private fun resolveListAndCategory(text: String, preferredList: String?): Pair<String, String> {
val lower = text.lowercase()
// Если список был явно передан в интенте
if (!preferredList.isNullOrBlank()) {
val prefLower = preferredList.lowercase()
return when {
prefLower.contains("фильм") || prefLower.contains("кино") || prefLower.contains("сериал") || prefLower.contains("movie") ->
Pair(preferredList, "movies")
prefLower.contains("покупк") || prefLower.contains("магаз") || prefLower.contains("купить") || prefLower.contains("shop") ->
Pair(preferredList, "shopping")
prefLower.contains("задач") || prefLower.contains("дело") || prefLower.contains("task") || prefLower.contains("todo") ->
Pair(preferredList, "tasks")
else -> Pair(preferredList, "general")
}
}
// Авто-маршрутизация по содержанию текста
return when {
lower.contains("фильм") || lower.contains("сериал") || lower.contains("кино") ||
lower.contains("посмотреть") || lower.contains("сезон") || lower.contains("актер") ||
lower.contains("режиссер") || lower.contains("movie") || lower.contains("cinema") ->
Pair("Фильмы на просмотр", "movies")
lower.contains("купить") || lower.contains("покупки") || lower.contains("магазин") ||
lower.contains("взять") || lower.contains("продукты") || lower.contains("цена") ||
lower.contains("аптек") || lower.contains("buy") || lower.contains("shop") ->
Pair("Покупки", "shopping")
lower.contains("задача") || lower.contains("сделать") || lower.contains("напомни") ||
lower.contains("позвонить") || lower.contains("отправить") || lower.contains("todo") ->
Pair("Задачи", "tasks")
else -> Pair("Быстрые заметки", "general")
}
}
companion object {
private const val TAG = "AvtexMainActivity"
}
}
@@ -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.Hub
import androidx.compose.material.icons.filled.Info
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.MindmapsScreen
import com.example.avtex.ui.OnePlusGuideScreen
import com.example.avtex.ui.SettingsScreen
@Composable
@@ -58,10 +58,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Hub, contentDescription = "Майндкарты") },
icon = { Icon(Icons.Default.Bedtime, 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.Bedtime, contentDescription = "Сон") },
icon = { Icon(Icons.Default.Settings, 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.Settings, contentDescription = "Настройки") },
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
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 -> MindmapsScreen(modifier = Modifier.padding(innerPadding))
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
3 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
}
}
}
@@ -2,7 +2,6 @@ package com.example.avtex.notes
import android.content.ContentValues
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,10 +11,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
class NotesRepository(private val context: Context) {
class NotesRepository(context: Context) {
private val dbHelper = XFlowDatabaseHelper(context)
private val syncClient = XFlowSyncClient(context)
private val scope = CoroutineScope(Dispatchers.IO)
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
@@ -155,26 +153,6 @@ class NotesRepository(private val context: Context) {
reload()
// Фоновая отправка в XFlow Cloud (Coolify)
scope.launch {
try {
val prefs = context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("xflow_server_url", "https://xflow.controlsystem.kz")
?: "https://xflow.controlsystem.kz"
if (serverUrl.isNotBlank()) {
syncClient.syncItemToServer(
serverUrl = serverUrl,
title = title.trim(),
listTitle = listTitle.trim(),
category = category
)
}
} catch (e: Exception) {
Log.w("NotesRepository", "Background cloud sync failed: ${e.message}")
}
}
NoteItem(
id = newItemId,
listId = targetListId,
@@ -1,10 +1,5 @@
package com.example.avtex.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -22,34 +17,24 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
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.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Info
import com.example.avtex.LocalVoiceInputLauncher
import com.example.avtex.LocalRoleNotesRequester
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
@@ -63,18 +48,17 @@ import androidx.compose.runtime.setValue
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.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.avtex.AvtexApplication
import com.example.avtex.MainActivity
import com.example.avtex.notes.NoteItem
import com.example.avtex.notes.NoteList
import com.example.avtex.theme.CyanAccent
import com.example.avtex.theme.ElectricIndigo
import com.example.avtex.theme.EmeraldMint
import com.example.avtex.theme.GlassBorder
import com.example.avtex.theme.NeonViolet
@@ -90,31 +74,35 @@ import kotlinx.coroutines.launch
@Composable
fun ListsScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val repository = remember { AvtexApplication.instance.notesRepository }
val lists by repository.listsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope()
var selectedCategory by remember { mutableStateOf("all") }
var newItemText by remember { mutableStateOf("") }
var showAssistantTester by remember { mutableStateOf(false) }
val voiceLauncher = LocalVoiceInputLauncher.current
var isAddingActive by remember { mutableStateOf(false) }
val categories = listOf(
"all" to "Все",
"movies" to "🎬 Фильмы",
"shopping" to "🛒 Покупки",
"tasks" to "⚡ Дела"
)
val filteredLists = remember(lists, selectedCategory) {
if (selectedCategory == "all") lists
else lists.filter { it.category == selectedCategory }
if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(ObsidianBg),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
.background(ObsidianBg)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// 1. Header
// Header
item {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -122,118 +110,77 @@ fun ListsScreen(modifier: Modifier = Modifier) {
) {
Column {
Text(
text = "Списки и Заметки",
fontSize = 24.sp,
text = "XFlow Заметки & Списки",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "XFlow синхронизация • Голосовой ввод Ассистента",
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
fontSize = 12.sp,
color = TextMuted
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Direct Voice Button
Button(
onClick = { voiceLauncher?.invoke() },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
IconButton(
onClick = { repository.reload() },
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, CircleShape)
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Голос",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
imageVector = Icons.Default.Refresh,
contentDescription = "Обновить",
tint = CyanAccent,
modifier = Modifier.size(18.dp)
)
}
}
}
// Button to toggle Assistant Tester
OutlinedButton(
onClick = { showAssistantTester = !showAssistantTester },
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f)),
colors = ButtonDefaults.outlinedButtonColors(
containerColor = if (showAssistantTester) NeonViolet.copy(alpha = 0.2f) else ObsidianSurface
),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
// Category Filter Chips
item {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = NeonViolet,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (showAssistantTester) "Скрыть тест" else "Тест Ассистента",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = NeonViolet
)
}
}
}
}
}
// 2. Interactive Assistant Simulator Card
item {
AnimatedVisibility(visible = showAssistantTester) {
AssistantTesterCard(context = context)
}
}
// 3. Category Filter Chips
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryChip(
label = "Все",
selected = selectedCategory == "all",
onClick = { selectedCategory = "all" }
)
CategoryChip(
label = "Фильмы 🎬",
selected = selectedCategory == "movies",
onClick = { selectedCategory = "movies" }
)
CategoryChip(
label = "Покупки 🛒",
selected = selectedCategory == "shopping",
onClick = { selectedCategory = "shopping" }
)
CategoryChip(
label = "Задачи 📋",
selected = selectedCategory == "tasks",
onClick = { selectedCategory = "tasks" }
)
}
}
// 4. Manual Quick Add Box
item {
items(categories) { (key, label) ->
val isSelected = selectedCategory == key
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
.border(
1.dp,
if (isSelected) NeonViolet else GlassBorder,
RoundedCornerShape(12.dp)
)
.clickable { selectedCategory = key }
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Text(
text = label,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) TextPrimary else TextSecondary
)
}
}
}
}
// Quick Add Item Bar
item {
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurfaceElevated)
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -242,9 +189,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
onValueChange = { newItemText = it },
placeholder = {
Text(
if (selectedCategory == "movies") "Название фильма на просмотр..."
else if (selectedCategory == "shopping") "Купить..."
else "Новая запись или заметка...",
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
fontSize = 13.sp,
color = TextMuted
)
@@ -269,8 +214,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
val listTitle = when (targetCategory) {
"movies" -> "Фильмы на просмотр"
"shopping" -> "Покупки"
"tasks" -> "Задачи"
else -> "Быстрые заметки"
else -> "Общий список"
}
repository.addNoteItem(
title = newItemText.trim(),
@@ -291,33 +235,22 @@ fun ListsScreen(modifier: Modifier = Modifier) {
}
}
// 5. Lists and Items
// Lists and Items
if (filteredLists.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.padding(vertical = 40.dp, horizontal = 20.dp),
.padding(vertical = 40.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Нет записей в выбранной категории",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
fontSize = 12.sp,
"Нет записей. Добавьте первую заметку или скажите Gemini!",
fontSize = 13.sp,
color = TextMuted
)
}
}
}
} else {
items(filteredLists, key = { it.id }) { list ->
ListCard(
@@ -334,302 +267,6 @@ fun ListsScreen(modifier: Modifier = Modifier) {
}
}
@Composable
private fun AssistantTesterCard(context: Context) {
val voiceLauncher = LocalVoiceInputLauncher.current
val roleRequester = LocalRoleNotesRequester.current
var customVoiceQuery by remember { mutableStateOf("") }
var selectedPreset by remember { mutableStateOf("") }
val clipboard = remember { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
val adbCommand = remember(customVoiceQuery) {
val query = if (customVoiceQuery.isNotBlank()) customVoiceQuery else "Интерстеллар"
"adb shell am start -a android.intent.action.CREATE_NOTE -e android.intent.extra.TEXT \"$query\" com.example.avtex"
}
fun simulateVoiceCommand(text: String) {
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_CREATE_NOTE
putExtra(Intent.EXTRA_TEXT, text)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
context.startActivity(intent)
}
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, NeonViolet.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
.padding(16.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(NeonViolet.copy(alpha = 0.2f))
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = NeonViolet,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(
text = "Симулятор голосового Ассистента",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "Проверка распознавания и авто-сортировки заметок",
fontSize = 11.sp,
color = TextMuted
)
}
}
}
// Direct Voice Action & System Role
Button(
onClick = { voiceLauncher?.invoke() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text("🎤 Сказать голосом прямо сейчас", fontWeight = FontWeight.Bold, color = Color.White)
}
OutlinedButton(
onClick = { roleRequester?.invoke() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Settings, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("⚙️ Сделать Avtex заметками по умолчанию", fontSize = 12.sp, color = NeonViolet)
}
// Explanatory note about Google Keep
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
.padding(10.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Info, contentDescription = null, tint = SolarAmber, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Почему Ассистент отправляет в Google Keep?", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = SolarAmber)
}
Text(
text = "Google зафиксировал Keep как облачного провайдера заметок. Чтобы переключить:\n• Откройте: Настройки -> Google -> Google Ассистент -> «Заметки и списки» -> выберите «Не синхронизировать».\n• Либо произносите явно: «Окей Google, открой Avtex и запиши...».\n• Либо нажмите кнопку «Сказать голосом» выше или зажмите иконку Avtex на рабочем столе!",
fontSize = 10.sp,
color = TextSecondary,
lineHeight = 14.sp
)
}
}
Text(
text = "Быстрые сценарии тестирования (нажмите для проверки):",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
// Preset Buttons Grid
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
AssistantPresetButton(
label = "🎬 Фильм: «Запиши фильм Начало на вечер»",
onClick = { simulateVoiceCommand("фильм Начало на вечер") }
)
AssistantPresetButton(
label = "🛒 Покупки: «Купить молоко, кофе и сыр»",
onClick = { simulateVoiceCommand("купить молоко, кофе и сыр") }
)
AssistantPresetButton(
label = "📋 Задача: «Позвонить автомеханику в 11»",
onClick = { simulateVoiceCommand("задача позвонить автомеханику в 11") }
)
AssistantPresetButton(
label = "💡 Заметка: «Идея для нового проекта XFlow»",
onClick = { simulateVoiceCommand("Идея для нового проекта XFlow") }
)
}
Spacer(modifier = Modifier.height(4.dp))
// Custom text input
Text(
text = "Своя фраза для проверки:",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = customVoiceQuery,
onValueChange = { customVoiceQuery = it },
placeholder = { Text("Например: сериал Локи 2 сезон", fontSize = 12.sp, color = TextMuted) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(10.dp),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NeonViolet,
unfocusedBorderColor = GlassBorder,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
Button(
onClick = {
if (customVoiceQuery.isNotBlank()) {
simulateVoiceCommand(customVoiceQuery.trim())
customVoiceQuery = ""
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Запуск", tint = Color.White)
}
}
// ADB command helper
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
.padding(10.dp)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Terminal, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Команда для теста через ADB с ПК:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = CyanAccent
)
}
IconButton(
onClick = {
clipboard.setPrimaryClip(ClipData.newPlainText("adb command", adbCommand))
Toast.makeText(context, "Команда скопирована в буфер", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(24.dp)
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", tint = TextMuted, modifier = Modifier.size(14.dp))
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = adbCommand,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = TextSecondary,
lineHeight = 13.sp
)
}
}
}
}
}
@Composable
private fun AssistantPresetButton(
label: String,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(8.dp))
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = TextPrimary
)
Text(
text = "ТЕСТ ▶",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = NeonViolet
)
}
}
}
@Composable
private fun CategoryChip(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, fontSize = 12.sp) },
shape = RoundedCornerShape(10.dp),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NeonViolet.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
containerColor = ObsidianSurfaceElevated,
labelColor = TextMuted
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = GlassBorder,
selectedBorderColor = NeonViolet.copy(alpha = 0.6f)
)
)
}
@Composable
private fun ListCard(
list: NoteList,
@@ -646,9 +283,9 @@ private fun ListCard(
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp))
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
@@ -670,25 +307,17 @@ private fun ListCard(
)
Text(
text = list.title,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
color = TextPrimary
)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(accentColor.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = "${list.items.count { !it.isCompleted }} активных",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = accentColor
color = TextMuted
)
}
}
// Items list
if (list.items.isEmpty()) {
@@ -696,7 +325,7 @@ private fun ListCard(
text = "Список пуст",
fontSize = 12.sp,
color = TextMuted,
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
modifier = Modifier.padding(start = 16.dp)
)
} else {
list.items.forEach { item ->
@@ -722,10 +351,10 @@ private fun ItemRow(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface)
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.clickable { onToggle() }
.padding(horizontal = 10.dp, vertical = 8.dp),
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
@@ -752,15 +381,13 @@ private fun ItemRow(
Text(
text = "🎬 Буду смотреть",
fontSize = 10.sp,
color = CyanAccent,
fontWeight = FontWeight.Medium
color = CyanAccent
)
} else if (item.status == "watched") {
Text(
text = "✔ Просмотрено",
fontSize = 10.sp,
color = EmeraldMint,
fontWeight = FontWeight.Medium
color = EmeraldMint
)
}
}
@@ -1,507 +0,0 @@
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
}
}
@@ -1,7 +1,5 @@
package com.example.avtex.ui
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -82,12 +80,6 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) }
val context = androidx.compose.ui.platform.LocalContext.current
val xflowPrefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
var xflowServerUrl by remember {
mutableStateOf(xflowPrefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
}
var xflowTestStatus by remember { mutableStateOf<String?>(null) }
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
var isSaved by remember { mutableStateOf(false) }
@@ -390,107 +382,6 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
}
}
// XFlow Cloud Server Card
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
.padding(18.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(CyanAccent.copy(alpha = 0.15f))
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = CyanAccent,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(
text = "Сервер XFlow (Coolify / Cloud)",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = TextPrimary
)
Text(
text = "Синхронизация Canvas майндкарт и заметок",
fontSize = 11.sp,
color = TextMuted
)
}
}
Spacer(modifier = Modifier.height(14.dp))
OutlinedTextField(
value = xflowServerUrl,
onValueChange = { xflowServerUrl = it; isSaved = false },
label = { Text("URL сервера XFlow") },
placeholder = { Text("https://xflow.controlsystem.kz") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
singleLine = true,
colors = customTextFieldColors()
)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = xflowTestStatus ?: "Статус: https://xflow.controlsystem.kz",
fontSize = 12.sp,
color = if (xflowTestStatus?.contains("OK") == true) EmeraldMint else TextMuted,
modifier = Modifier.weight(1f)
)
androidx.compose.material3.TextButton(
onClick = {
coroutineScope.launch {
xflowTestStatus = "Проверка..."
try {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.build()
val req = okhttp3.Request.Builder()
.url("${xflowServerUrl.trimEnd('/')}/api/canvas")
.build()
val resp = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
client.newCall(req).execute()
}
resp.use {
if (it.isSuccessful) {
xflowTestStatus = "Подключено: HTTP ${it.code} OK"
} else {
xflowTestStatus = "Ответ: HTTP ${it.code}"
}
}
} catch (e: Exception) {
xflowTestStatus = "Ошибка: ${e.localizedMessage}"
}
}
}
) {
Text("Проверить", color = CyanAccent, fontSize = 13.sp)
}
}
}
}
}
// 4. Save Button
item {
Button(
@@ -507,7 +398,6 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
confidenceThreshold = confidenceThreshold.roundToInt(),
broadcastIntents = broadcastIntents
)
xflowPrefs.edit().putString("xflow_server_url", xflowServerUrl.trim()).apply()
isSaved = true
}
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 292 KiB

@@ -5,6 +5,6 @@
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#131929"
android:fillColor="#0D0E1A"
android:pathData="M0,0h108v108h-108z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,5 +1,3 @@
<resources>
<string name="app_name">Avtex</string>
<string name="shortcut_voice_note">Голосовая заметка</string>
<string name="shortcut_voice_note_desc">Быстрая запись заметки голосом в Avtex</string>
<string name="app_name">XFlow</string>
</resources>
+1 -32
View File
@@ -1,38 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Шорткат по долгому нажатию на иконку: Голосовая заметка -->
<shortcut
android:shortcutId="voice_note"
android:enabled="true"
android:icon="@mipmap/ic_launcher_avtex"
android:shortcutShortLabel="@string/shortcut_voice_note"
android:shortcutLongLabel="@string/shortcut_voice_note_desc">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://voice/record" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<!-- Создание заметки через Gemini / Google Assistant ("Сделай заметку...") -->
<capability android:name="actions.intent.CREATE_NOTE">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://notes/create">
<parameter
android:name="note.text"
android:key="note_text" />
<parameter
android:name="note.name"
android:key="note_title" />
</intent>
</capability>
<!-- Добавление элемента в список через Gemini / Google Assistant ("Добавь фильм в Avtex...") -->
<!-- Добавление элемента в список через Gemini / Google Assistant -->
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
<intent
android:action="android.intent.action.VIEW"
Vendored Executable → Regular
View File
+6 -18
View File
@@ -1,5 +1,4 @@
import { jwtDecode } from 'jwt-decode'
import { getApps } from 'firebase/app'
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
interface User {
@@ -16,6 +15,8 @@ export const useAuth = () => {
sameSite: 'lax'
})
const isInitializing = useState('auth_initializing', () => true)
const user = computed<User | null>(() => {
if (!token.value) return null
try {
@@ -29,10 +30,6 @@ export const useAuth = () => {
const isAdmin = computed(() => user.value?.role === 'admin')
const googleSignIn = async () => {
if (!getApps().length) {
alert('Ошибка: Firebase не настроен. Проверьте переменные окружения FIREBASE_API_KEY в Coolify.')
return
}
const auth = getAuth()
const provider = new GoogleAuthProvider()
const result = await signInWithPopup(auth, provider)
@@ -49,10 +46,8 @@ export const useAuth = () => {
const logout = async () => {
try {
if (getApps().length) {
const auth = getAuth()
await signOut(auth)
}
} catch (e) {
console.warn('[Firebase] Sign out failed or not initialized', e)
} finally {
@@ -63,12 +58,7 @@ export const useAuth = () => {
const initAuth = () => {
if (process.server) return
if (!getApps().length) {
console.warn('[Firebase] Firebase App not initialized, skipping onAuthStateChanged.')
return
}
try {
const auth = getAuth()
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
// If user is authenticated in Firebase but we lost our backend token, try to restore
@@ -88,19 +78,17 @@ export const useAuth = () => {
console.error('Failed to restore backend auth from Firebase', e)
}
}
isInitializing.value = false
})
onUnmounted(() => unsubscribe())
} catch (err) {
console.warn('[Firebase] Auth initialization skipped:', err)
}
return unsubscribe
}
return {
user,
token,
user,
isAuthenticated,
isAdmin,
isInitializing,
googleSignIn,
logout,
initAuth
+6 -7
View File
@@ -4,12 +4,11 @@ export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const firebaseConfig = {
apiKey: config.public.firebaseApiKey || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck',
authDomain: config.public.firebaseAuthDomain || 'xflow-d8dd5.firebaseapp.com',
projectId: config.public.firebaseProjectId || 'xflow-d8dd5',
appId: config.public.firebaseAppId || '1:944408960366:web:4d2cd393e85a0ca9b4357f',
messagingSenderId: config.public.firebaseMessagingSenderId || '944408960366',
storageBucket: (config.public as any).firebaseStorageBucket || 'xflow-d8dd5.firebasestorage.app'
apiKey: config.public.firebaseApiKey,
authDomain: config.public.firebaseAuthDomain,
projectId: config.public.firebaseProjectId,
appId: config.public.firebaseAppId,
messagingSenderId: config.public.firebaseMessagingSenderId
}
if (!firebaseConfig.apiKey) {
@@ -19,6 +18,6 @@ export default defineNuxtPlugin(() => {
if (!getApps().length) {
initializeApp(firebaseConfig)
console.log('[Firebase] Initialized with project:', firebaseConfig.projectId)
console.log('[Firebase] Initialized')
}
})
+6 -7
View File
@@ -62,7 +62,7 @@ export default defineNuxtConfig({
runtimeConfig: {
jwtSecret: process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key',
firebaseApiKey: process.env.NUXT_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck',
firebaseApiKey: process.env.NUXT_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || '',
giteaUrl: process.env.GITEA_URL || '',
giteaToken: process.env.GITEA_TOKEN || '',
giteaOrg: process.env.GITEA_ORG || '',
@@ -72,12 +72,11 @@ export default defineNuxtConfig({
qwenApiKey: '',
public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck',
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN || process.env.FIREBASE_AUTH_DOMAIN || 'xflow-d8dd5.firebaseapp.com',
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID || process.env.FIREBASE_PROJECT_ID || 'xflow-d8dd5',
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID || process.env.FIREBASE_APP_ID || '1:944408960366:web:4d2cd393e85a0ca9b4357f',
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || process.env.FIREBASE_MESSAGING_SENDER_ID || '944408960366',
firebaseStorageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET || process.env.FIREBASE_STORAGE_BUCKET || 'xflow-d8dd5.firebasestorage.app'
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || '',
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN || process.env.FIREBASE_AUTH_DOMAIN || '',
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID || process.env.FIREBASE_PROJECT_ID || '',
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID || process.env.FIREBASE_APP_ID || '',
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || process.env.FIREBASE_MESSAGING_SENDER_ID || ''
}
},
+2 -1
View File
@@ -1,11 +1,12 @@
import { defineConfig } from "prisma/config";
// Призма 7 иногда требует явной загрузки .env для некоторых команд
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env.DATABASE_URL || "postgresql://xflow:xflow@db:5432/xflow",
url: process.env.DATABASE_URL || "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db",
},
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<text x="16" y="22" font-size="18" text-anchor="middle" fill="#3b82f6" font-family="system-ui">N</text>
</svg>

Before

Width:  |  Height:  |  Size: 252 B

-34
View File
@@ -1,34 +0,0 @@
{
"id": "/",
"name": "XFlow",
"short_name": "XFlow",
"description": "XFlow Intelligent Unified Platform",
"lang": "ru",
"categories": ["productivity", "business"],
"theme_color": "#090A0F",
"background_color": "#090A0F",
"display": "standalone",
"orientation": "portrait",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
-34
View File
@@ -1,34 +0,0 @@
{
"id": "/",
"name": "XFlow",
"short_name": "XFlow",
"description": "XFlow Intelligent Unified Platform",
"lang": "ru",
"categories": ["productivity", "business"],
"theme_color": "#090A0F",
"background_color": "#090A0F",
"display": "standalone",
"orientation": "portrait",
"start_url": "/",
"scope": "/",
"icons": [
{
"src": "/pwa-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#0f172a"/>
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
</svg>

Before

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
<rect width="192" height="192" rx="24" fill="#0f172a"/>
<text x="96" y="128" font-size="96" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
</svg>

Before

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

-4
View File
@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#0f172a"/>
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
</svg>

Before

Width:  |  Height:  |  Size: 281 B

+2 -16
View File
@@ -47,12 +47,11 @@ export default defineEventHandler(async (event) => {
const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')
const userId = fbUser.localId
let user: any
try {
user = await prisma.user.upsert({
const user = await prisma.user.upsert({
where: { email },
update: {
username
// Мы не затираем профиль при каждом входе, но нам нужно его получить
},
create: {
id: userId,
@@ -71,19 +70,6 @@ export default defineEventHandler(async (event) => {
profile: true
}
})
} catch (dbErr: any) {
console.warn('[Auth] Database offline, creating local dev session for:', email, dbErr.message)
user = {
id: userId,
email,
username,
role: 'user',
profile: {
displayName: fbUser.displayName || username,
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
}
}
}
const token = generateToken({
id: user.id,
+9 -23
View File
@@ -7,9 +7,7 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
let profile: any = null
try {
profile = await prisma.profile.findFirst({
let profile = await prisma.profile.findFirst({
where: {
OR: [
{ userId: userId },
@@ -27,6 +25,7 @@ export default defineEventHandler(async (event) => {
}
})
// If profile doesn't exist but user does, create it
if (!profile) {
const user = await prisma.user.findFirst({
where: {
@@ -34,7 +33,10 @@ export default defineEventHandler(async (event) => {
}
})
if (user) {
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
profile = await prisma.profile.create({
data: {
userId: user.id,
@@ -52,29 +54,13 @@ export default defineEventHandler(async (event) => {
}
})
}
}
} catch (e: any) {
console.warn(`[Profile API] Database unreachable (${e.message}), returning dev profile for ${userId}`)
}
if (!profile) {
profile = {
userId,
displayName: userId.length > 20 ? 'User' : userId,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`,
bio: '',
user: {
_count: { canvases: 0, followers: 0, following: 0 }
}
}
}
return {
profile: {
...profile,
canvasCount: profile.user?._count?.canvases || 0,
followerCount: profile.user?._count?.followers || 0,
followingCount: profile.user?._count?.following || 0
canvasCount: profile.user._count.canvases,
followerCount: profile.user._count.followers,
followingCount: profile.user._count.following
}
}
})
+29 -30
View File
@@ -12,19 +12,19 @@ services:
dockerfile: Dockerfile.coolify
args:
SITE_URL: '${SITE_URL:-http://localhost:3000}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
JWT_SECRET: '${JWT_SECRET:-super-secret-jwt-key}'
OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
NUXT_OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
TZ: '${TZ:-Asia/Almaty}'
depends_on:
- db
@@ -49,9 +49,9 @@ services:
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
NUXT_REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
@@ -64,33 +64,32 @@ services:
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:?}'
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
POSTGRES_HOST_AUTH_METHOD: 'trust'
volumes:
- xflow-pg-data:/var/lib/postgresql/data
-74
View File
@@ -1,74 +0,0 @@
version: '3.8'
services:
app:
container_name: xflow-app
build:
context: .
dockerfile: Dockerfile.coolify
args:
SITE_URL: http://localhost:3000
DATABASE_URL: postgresql://xflow:xflow_pass@db:5432/xflow
REDIS_URL: redis://redis:6379
JWT_SECRET: dev-super-secret-jwt-key
ports:
- "3000:3000"
environment:
NODE_ENV: development
HOST: "0.0.0.0"
PORT: "3000"
DATABASE_URL: postgresql://xflow:xflow_pass@db:5432/xflow
NUXT_DATABASE_URL: postgresql://xflow:xflow_pass@db:5432/xflow
REDIS_URL: redis://redis:6379
NUXT_REDIS_URL: redis://redis:6379
JWT_SECRET: dev-super-secret-jwt-key
NUXT_JWT_SECRET: dev-super-secret-jwt-key
SITE_URL: http://localhost:3000
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
networks:
- xflow-network
db:
container_name: xflow-db
image: postgres:16-alpine
restart: unless-stopped
ports:
- "5432:5432"
environment:
POSTGRES_USER: xflow
POSTGRES_PASSWORD: xflow_pass
POSTGRES_DB: xflow
volumes:
- xflow-local-pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U xflow -d xflow"]
interval: 3s
timeout: 3s
retries: 5
networks:
- xflow-network
redis:
container_name: xflow-redis
image: redis:7.2-alpine
restart: unless-stopped
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- xflow-local-redisdata:/data
networks:
- xflow-network
volumes:
xflow-local-pgdata:
xflow-local-redisdata:
networks:
xflow-network:
driver: bridge