4 Commits

58 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
**/node_modules
.output .output
.nuxt .nuxt
dist node_modules/.cache
.cache .git
*.log .gitignore
# Environment variables
.env .env
.env.* .env.*
.env.local
!.env.example !.env.example
!.env.example.coolify !.env.example.coolify
dist
# Native Mobile (Android) *.log
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
*.canvas *.canvas
# IDE settings # Exclude non-web monorepo apps from Docker build context
.idea apps/mobile
.vscode apps/desktop
*.iml packages/shared-contracts/dist
*.ipr
*.iws
-4
View File
@@ -39,7 +39,3 @@ server/api/node_modules/
obsidian-git/ obsidian-git/
/app/generated/prisma /app/generated/prisma
*.apk
local.properties
.gradle/
+13 -17
View File
@@ -1,7 +1,7 @@
# ========================================== # ==========================================
# Stage 1: builder # Stage 1: builder
# ========================================== # ==========================================
FROM node:22-slim AS builder FROM node:22-alpine AS builder
ENV HUSKY=0 ENV HUSKY=0
ENV NODE_ENV=development ENV NODE_ENV=development
@@ -9,8 +9,7 @@ ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /src WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends openssl git ca-certificates \ RUN apk add --no-cache libc6-compat git openssl
&& rm -rf /var/lib/apt/lists/*
ARG SITE_URL="" ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow" 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_OPEN_ROUTER_API_KEY=""
ARG NUXT_QWEN_API_KEY="" ARG NUXT_QWEN_API_KEY=""
ARG DEEPSEEK_API_KEY="" ARG DEEPSEEK_API_KEY=""
ARG FIREBASE_API_KEY="AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck" ARG FIREBASE_API_KEY=""
ARG FIREBASE_AUTH_DOMAIN="xflow-d8dd5.firebaseapp.com" ARG FIREBASE_AUTH_DOMAIN=""
ARG FIREBASE_PROJECT_ID="xflow-d8dd5" ARG FIREBASE_PROJECT_ID=""
ARG FIREBASE_STORAGE_BUCKET="xflow-d8dd5.firebasestorage.app" ARG FIREBASE_STORAGE_BUCKET=""
ARG FIREBASE_MESSAGING_SENDER_ID="944408960366" ARG FIREBASE_MESSAGING_SENDER_ID=""
ARG FIREBASE_APP_ID="1:944408960366:web:4d2cd393e85a0ca9b4357f" ARG FIREBASE_APP_ID=""
ARG TZ="Asia/Almaty" ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \ ENV SITE_URL=${SITE_URL} \
@@ -59,11 +58,10 @@ ENV SITE_URL=${SITE_URL} \
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \ NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
TZ=${TZ} TZ=${TZ}
# Копируем package.json, prisma схемы и конфиг # Копируем package.json и prisma схемы из apps/web
COPY apps/web/package*.json ./ COPY apps/web/package*.json ./
COPY apps/web/prisma ./prisma COPY apps/web/prisma ./prisma
COPY apps/web/prisma.config.ts ./ RUN npm ci --ignore-scripts && npx prisma generate
RUN npm ci --include=optional && npx prisma generate
# Копируем исходный код apps/web # Копируем исходный код apps/web
COPY 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 # Stage 2: runner
# ========================================== # ==========================================
FROM node:22-slim AS runner FROM node:22-alpine AS runner
ENV HUSKY=0 ENV HUSKY=0
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /src WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends openssl tzdata ca-certificates \ RUN apk add --no-cache tzdata openssl
&& rm -rf /var/lib/apt/lists/*
ARG SITE_URL="" ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow" 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/.output ./.output
COPY --from=builder /src/prisma ./prisma COPY --from=builder /src/prisma ./prisma
COPY --from=builder /src/prisma.config.ts ./
COPY --from=builder /src/package.json ./ COPY --from=builder /src/package.json ./
COPY --from=builder /src/node_modules ./node_modules COPY --from=builder /src/node_modules ./node_modules
@@ -112,4 +108,4 @@ ENV NITRO_PORT=3000
EXPOSE 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" applicationId = "com.example.avtex"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 4 versionCode = 1
versionName = "1.3" versionName = "1.0"
} }
signingConfigs { signingConfigs {
@@ -49,11 +49,6 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}" excludes += "/META-INF/{AL2.0,LGPL2.1}"
} }
} }
lint {
abortOnError = false
checkReleaseBuilds = false
}
} }
kotlin { kotlin {
+8 -43
View File
@@ -15,62 +15,29 @@
<application <application
android:name=".AvtexApplication" android:name=".AvtexApplication"
android:allowBackup="true" android:allowBackup="true"
android:icon="@mipmap/ic_launcher_avtex" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_avtex_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Avtex"> android:theme="@style/Theme.Avtex">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true">
android:showWhenLocked="true"
android:turnScreenOn="true"
android:icon="@mipmap/ic_launcher_avtex"
android:roundIcon="@mipmap/ic_launcher_avtex_round">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<!-- Deep link intent-filter for Gemini / Assistant App Actions & XFlow links --> <!-- Deep link intent-filter for Gemini / Assistant App Actions -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="xflow" /> <data
</intent-filter> android:scheme="xflow"
android:host="lists" />
<!-- 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" />
</intent-filter> </intent-filter>
</activity> </activity>
@@ -99,9 +66,7 @@
<receiver <receiver
android:name=".engine.BootReceiver" android:name=".engine.BootReceiver"
android:exported="true" android:exported="true">
android:icon="@mipmap/ic_launcher_avtex"
android:roundIcon="@mipmap/ic_launcher_avtex_round">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter> </intent-filter>
@@ -1,52 +1,21 @@
package com.example.avtex package com.example.avtex
import android.app.Activity
import android.app.role.RoleManager
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.speech.RecognizerIntent
import android.util.Log
import android.widget.Toast import android.widget.Toast
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.example.avtex.theme.AvtexTheme import com.example.avtex.theme.AvtexTheme
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
class MainActivity : ComponentActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
handleIncomingIntent(intent) handleIncomingIntent(intent)
@@ -54,16 +23,11 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
AvtexTheme { AvtexTheme {
CompositionLocalProvider( Surface(
LocalVoiceInputLauncher provides { launchVoiceInput() }, modifier = Modifier.fillMaxSize(),
LocalRoleNotesRequester provides { requestDefaultNotesRole() } color = MaterialTheme.colorScheme.background
) { ) {
Surface( MainNavigation()
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainNavigation()
}
} }
} }
} }
@@ -74,217 +38,49 @@ class MainActivity : ComponentActivity() {
handleIncomingIntent(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?) { private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return 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) // 1. Обработка App Actions через Deep Link xflow://lists/add
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://...)
val data = intent.data val data = intent.data
if (data != null && data.scheme == "xflow") { if (data != null && data.scheme == "xflow") {
val host = data.host ?: "" val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
val path = data.path ?: "" val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
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")
if (!itemName.isNullOrBlank()) { if (!itemName.isNullOrBlank()) {
routeAndSaveNote(itemName, preferredList = listName) saveItemFromAssistant(itemName, listName)
return return
} }
} }
// 6. Google Assistant / Gemini Built-in Intent Extras // 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
val extras = intent.extras val extras = intent.extras
if (extras != null) { if (extras != null) {
val itemName = extras.getString("itemListElement.name") val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
?: extras.getString("item_name") val listName = extras.getString("itemList.name") ?: extras.getString("list_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")
if (!itemName.isNullOrBlank()) { if (!itemName.isNullOrBlank()) {
routeAndSaveNote(itemName, preferredList = listName) saveItemFromAssistant(itemName, listName)
} }
} }
} }
private fun routeAndSaveNote(rawText: String, preferredList: String?) { private fun saveItemFromAssistant(itemName: String, listName: String) {
val cleanText = rawText.trim()
if (cleanText.isBlank()) return
val (targetList, category) = resolveListAndCategory(cleanText, preferredList)
lifecycleScope.launch { lifecycleScope.launch {
try { val repo = AvtexApplication.instance.notesRepository
val repo = AvtexApplication.instance.notesRepository val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
repo.addNoteItem(
title = cleanText,
listTitle = targetList,
category = category
)
Toast.makeText( repo.addNoteItem(
this@MainActivity, title = itemName,
"✅ Avtex: Записано в «$targetList»:\n$cleanText", listTitle = listName,
Toast.LENGTH_LONG category = category
).show() )
AvtexApplication.instance.settingsRepository.addLog( Toast.makeText(
tag = "Assistant", this@MainActivity,
message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"", "✅ Записано в XFlow: $itemName ($listName)",
isImportant = true Toast.LENGTH_LONG
) ).show()
} 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.Icons
import androidx.compose.material.icons.filled.Bedtime import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted 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.material.icons.filled.Settings
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar 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.theme.TextMuted
import com.example.avtex.ui.DashboardScreen import com.example.avtex.ui.DashboardScreen
import com.example.avtex.ui.ListsScreen import com.example.avtex.ui.ListsScreen
import com.example.avtex.ui.MindmapsScreen import com.example.avtex.ui.OnePlusGuideScreen
import com.example.avtex.ui.SettingsScreen import com.example.avtex.ui.SettingsScreen
@Composable @Composable
@@ -58,10 +58,10 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 0, selected = selectedTab == 0,
onClick = { selectedTab = 0 }, onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Hub, contentDescription = "Майндкарты") }, icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
label = { label = {
Text( Text(
"Карты", "Сон",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
) )
@@ -84,10 +84,10 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 2, selected = selectedTab == 2,
onClick = { selectedTab = 2 }, onClick = { selectedTab = 2 },
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Сон") }, icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
label = { label = {
Text( Text(
"Сон", "Настройки",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
) )
@@ -97,10 +97,10 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 3, selected = selectedTab == 3,
onClick = { selectedTab = 3 }, onClick = { selectedTab = 3 },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") }, icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
label = { label = {
Text( Text(
"Настройки", "OnePlus",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
) )
@@ -111,10 +111,10 @@ fun MainNavigation() {
} }
) { innerPadding -> ) { innerPadding ->
when (selectedTab) { when (selectedTab) {
0 -> MindmapsScreen(modifier = Modifier.padding(innerPadding)) 0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
1 -> ListsScreen(modifier = Modifier.padding(innerPadding)) 1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding)) 2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
3 -> 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.ContentValues
import android.content.Context import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -12,10 +11,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.util.UUID import java.util.UUID
class NotesRepository(private val context: Context) { class NotesRepository(context: Context) {
private val dbHelper = XFlowDatabaseHelper(context) private val dbHelper = XFlowDatabaseHelper(context)
private val syncClient = XFlowSyncClient(context)
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList()) private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
@@ -155,26 +153,6 @@ class NotesRepository(private val context: Context) {
reload() 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( NoteItem(
id = newItemId, id = newItemId,
listId = targetListId, listId = targetListId,
@@ -1,10 +1,5 @@
package com.example.avtex.ui 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.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check 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.Delete
import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.TaskAlt
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.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -63,18 +48,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.example.avtex.AvtexApplication import com.example.avtex.AvtexApplication
import com.example.avtex.MainActivity
import com.example.avtex.notes.NoteItem import com.example.avtex.notes.NoteItem
import com.example.avtex.notes.NoteList import com.example.avtex.notes.NoteList
import com.example.avtex.theme.CyanAccent import com.example.avtex.theme.CyanAccent
import com.example.avtex.theme.ElectricIndigo
import com.example.avtex.theme.EmeraldMint import com.example.avtex.theme.EmeraldMint
import com.example.avtex.theme.GlassBorder import com.example.avtex.theme.GlassBorder
import com.example.avtex.theme.NeonViolet import com.example.avtex.theme.NeonViolet
@@ -90,150 +74,113 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun ListsScreen(modifier: Modifier = Modifier) { fun ListsScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val repository = remember { AvtexApplication.instance.notesRepository } val repository = remember { AvtexApplication.instance.notesRepository }
val lists by repository.listsFlow.collectAsState() val lists by repository.listsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var selectedCategory by remember { mutableStateOf("all") } var selectedCategory by remember { mutableStateOf("all") }
var newItemText by remember { mutableStateOf("") } var newItemText by remember { mutableStateOf("") }
var showAssistantTester by remember { mutableStateOf(false) } var isAddingActive by remember { mutableStateOf(false) }
val voiceLauncher = LocalVoiceInputLauncher.current
val categories = listOf(
"all" to "Все",
"movies" to "🎬 Фильмы",
"shopping" to "🛒 Покупки",
"tasks" to "⚡ Дела"
)
val filteredLists = remember(lists, selectedCategory) { val filteredLists = remember(lists, selectedCategory) {
if (selectedCategory == "all") lists if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
else lists.filter { it.category == selectedCategory }
} }
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
.fillMaxSize() .fillMaxSize()
.background(ObsidianBg), .background(ObsidianBg)
contentPadding = PaddingValues(16.dp), .padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp) contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) { ) {
// 1. Header // Header
item { item {
Column(modifier = Modifier.padding(vertical = 4.dp)) { Row(
Row( modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically ) {
Column {
Text(
text = "XFlow Заметки & Списки",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
fontSize = 12.sp,
color = TextMuted
)
}
IconButton(
onClick = { repository.reload() },
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, CircleShape)
) { ) {
Column { Icon(
Text( imageVector = Icons.Default.Refresh,
text = "Списки и Заметки", contentDescription = "Обновить",
fontSize = 24.sp, tint = CyanAccent,
fontWeight = FontWeight.Bold, modifier = Modifier.size(18.dp)
color = TextPrimary )
) }
Text( }
text = "XFlow синхронизация • Голосовой ввод Ассистента", }
fontSize = 12.sp,
color = TextMuted
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { // Category Filter Chips
// Direct Voice Button item {
Button( LazyRow(
onClick = { voiceLauncher?.invoke() }, horizontalArrangement = Arrangement.spacedBy(8.dp),
shape = RoundedCornerShape(12.dp), modifier = Modifier.fillMaxWidth()
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet), ) {
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) items(categories) { (key, label) ->
) { val isSelected = selectedCategory == key
Icon( Box(
imageVector = Icons.Default.Mic, modifier = Modifier
contentDescription = null, .clip(RoundedCornerShape(12.dp))
tint = Color.White, .background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
modifier = Modifier.size(16.dp) .border(
1.dp,
if (isSelected) NeonViolet else GlassBorder,
RoundedCornerShape(12.dp)
) )
Spacer(modifier = Modifier.width(4.dp)) .clickable { selectedCategory = key }
Text( .padding(horizontal = 14.dp, vertical = 8.dp)
text = "Голос",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
// 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)
) { ) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = NeonViolet,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = if (showAssistantTester) "Скрыть тест" else "Тест Ассистента", text = label,
fontSize = 11.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = NeonViolet color = if (isSelected) TextPrimary else TextSecondary
) )
} }
}
} }
} }
} }
// 2. Interactive Assistant Simulator Card // Quick Add Item Bar
item { item {
AnimatedVisibility(visible = showAssistantTester) { Column(
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 {
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(ObsidianSurfaceElevated) .background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp)) .border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(12.dp) .padding(12.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@@ -242,9 +189,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
onValueChange = { newItemText = it }, onValueChange = { newItemText = it },
placeholder = { placeholder = {
Text( Text(
if (selectedCategory == "movies") "Название фильма на просмотр..." if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
else if (selectedCategory == "shopping") "Купить..."
else "Новая запись или заметка...",
fontSize = 13.sp, fontSize = 13.sp,
color = TextMuted color = TextMuted
) )
@@ -269,8 +214,7 @@ fun ListsScreen(modifier: Modifier = Modifier) {
val listTitle = when (targetCategory) { val listTitle = when (targetCategory) {
"movies" -> "Фильмы на просмотр" "movies" -> "Фильмы на просмотр"
"shopping" -> "Покупки" "shopping" -> "Покупки"
"tasks" -> "Задачи" else -> "Общий список"
else -> "Быстрые заметки"
} }
repository.addNoteItem( repository.addNoteItem(
title = newItemText.trim(), title = newItemText.trim(),
@@ -291,31 +235,20 @@ fun ListsScreen(modifier: Modifier = Modifier) {
} }
} }
// 5. Lists and Items // Lists and Items
if (filteredLists.isEmpty()) { if (filteredLists.isEmpty()) {
item { item {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(16.dp)) .padding(vertical = 40.dp),
.background(ObsidianSurface)
.padding(vertical = 40.dp, horizontal = 20.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(
Text( "Нет записей. Добавьте первую заметку или скажите Gemini!",
text = "Нет записей в выбранной категории", fontSize = 13.sp,
fontSize = 14.sp, color = TextMuted
fontWeight = FontWeight.Medium, )
color = TextSecondary
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
fontSize = 12.sp,
color = TextMuted
)
}
} }
} }
} else { } else {
@@ -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 @Composable
private fun ListCard( private fun ListCard(
list: NoteList, list: NoteList,
@@ -646,9 +283,9 @@ private fun ListCard(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(18.dp)) .clip(RoundedCornerShape(16.dp))
.background(ObsidianSurfaceElevated) .background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp)) .border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
@@ -670,24 +307,16 @@ private fun ListCard(
) )
Text( Text(
text = list.title, text = list.title,
fontSize = 16.sp, fontSize = 15.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.SemiBold,
color = TextPrimary color = TextPrimary
) )
} }
Box( Text(
modifier = Modifier text = "${list.items.count { !it.isCompleted }} активных",
.clip(RoundedCornerShape(8.dp)) fontSize = 11.sp,
.background(accentColor.copy(alpha = 0.15f)) color = TextMuted
.padding(horizontal = 8.dp, vertical = 2.dp) )
) {
Text(
text = "${list.items.count { !it.isCompleted }} активных",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = accentColor
)
}
} }
// Items list // Items list
@@ -696,7 +325,7 @@ private fun ListCard(
text = "Список пуст", text = "Список пуст",
fontSize = 12.sp, fontSize = 12.sp,
color = TextMuted, color = TextMuted,
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp) modifier = Modifier.padding(start = 16.dp)
) )
} else { } else {
list.items.forEach { item -> list.items.forEach { item ->
@@ -722,10 +351,10 @@ private fun ItemRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface) .background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp)) .border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.clickable { onToggle() } .clickable { onToggle() }
.padding(horizontal = 10.dp, vertical = 8.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Checkbox( Checkbox(
@@ -752,15 +381,13 @@ private fun ItemRow(
Text( Text(
text = "🎬 Буду смотреть", text = "🎬 Буду смотреть",
fontSize = 10.sp, fontSize = 10.sp,
color = CyanAccent, color = CyanAccent
fontWeight = FontWeight.Medium
) )
} else if (item.status == "watched") { } else if (item.status == "watched") {
Text( Text(
text = "✔ Просмотрено", text = "✔ Просмотрено",
fontSize = 10.sp, fontSize = 10.sp,
color = EmeraldMint, color = EmeraldMint
fontWeight = FontWeight.Medium
) )
} }
} }
@@ -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 package com.example.avtex.ui
import android.content.Context
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@@ -82,12 +80,6 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) } var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) } var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.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 broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
var isSaved by remember { mutableStateOf(false) } 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 // 4. Save Button
item { item {
Button( Button(
@@ -507,7 +398,6 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
confidenceThreshold = confidenceThreshold.roundToInt(), confidenceThreshold = confidenceThreshold.roundToInt(),
broadcastIntents = broadcastIntents broadcastIntents = broadcastIntents
) )
xflowPrefs.edit().putString("xflow_server_url", xflowServerUrl.trim()).apply()
isSaved = true isSaved = true
} }
}, },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

After

Width:  |  Height:  |  Size: 292 KiB

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

Before

Width:  |  Height:  |  Size: 5.2 KiB

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: 986 B

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 B

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 668 B

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,5 +1,3 @@
<resources> <resources>
<string name="app_name">Avtex</string> <string name="app_name">XFlow</string>
<string name="shortcut_voice_note">Голосовая заметка</string>
<string name="shortcut_voice_note_desc">Быстрая запись заметки голосом в Avtex</string>
</resources> </resources>
+1 -32
View File
@@ -1,38 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android"> <shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Шорткат по долгому нажатию на иконку: Голосовая заметка --> <!-- Добавление элемента в список через Gemini / Google Assistant -->
<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...") -->
<capability android:name="actions.intent.ADD_ITEM_TO_LIST"> <capability android:name="actions.intent.ADD_ITEM_TO_LIST">
<intent <intent
android:action="android.intent.action.VIEW" android:action="android.intent.action.VIEW"
Vendored Executable → Regular
View File
+25 -37
View File
@@ -1,5 +1,4 @@
import { jwtDecode } from 'jwt-decode' import { jwtDecode } from 'jwt-decode'
import { getApps } from 'firebase/app'
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth' import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
interface User { interface User {
@@ -16,6 +15,8 @@ export const useAuth = () => {
sameSite: 'lax' sameSite: 'lax'
}) })
const isInitializing = useState('auth_initializing', () => true)
const user = computed<User | null>(() => { const user = computed<User | null>(() => {
if (!token.value) return null if (!token.value) return null
try { try {
@@ -29,10 +30,6 @@ export const useAuth = () => {
const isAdmin = computed(() => user.value?.role === 'admin') const isAdmin = computed(() => user.value?.role === 'admin')
const googleSignIn = async () => { const googleSignIn = async () => {
if (!getApps().length) {
alert('Ошибка: Firebase не настроен. Проверьте переменные окружения FIREBASE_API_KEY в Coolify.')
return
}
const auth = getAuth() const auth = getAuth()
const provider = new GoogleAuthProvider() const provider = new GoogleAuthProvider()
const result = await signInWithPopup(auth, provider) const result = await signInWithPopup(auth, provider)
@@ -49,10 +46,8 @@ export const useAuth = () => {
const logout = async () => { const logout = async () => {
try { try {
if (getApps().length) { const auth = getAuth()
const auth = getAuth() await signOut(auth)
await signOut(auth)
}
} catch (e) { } catch (e) {
console.warn('[Firebase] Sign out failed or not initialized', e) console.warn('[Firebase] Sign out failed or not initialized', e)
} finally { } finally {
@@ -63,44 +58,37 @@ export const useAuth = () => {
const initAuth = () => { const initAuth = () => {
if (process.server) return if (process.server) return
if (!getApps().length) {
console.warn('[Firebase] Firebase App not initialized, skipping onAuthStateChanged.')
return
}
try { const auth = getAuth()
const auth = getAuth() const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { // If user is authenticated in Firebase but we lost our backend token, try to restore
// If user is authenticated in Firebase but we lost our backend token, try to restore if (firebaseUser && !token.value) {
if (firebaseUser && !token.value) { try {
try { const idToken = await firebaseUser.getIdToken()
const idToken = await firebaseUser.getIdToken() const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', { method: 'POST',
method: 'POST', body: { idToken }
body: { idToken } })
}) token.value = res.token
token.value = res.token
if (useRoute().path === '/login') { if (useRoute().path === '/login') {
await navigateTo('/') await navigateTo('/')
}
} catch (e) {
console.error('Failed to restore backend auth from Firebase', e)
} }
} catch (e) {
console.error('Failed to restore backend auth from Firebase', e)
} }
}) }
isInitializing.value = false
onUnmounted(() => unsubscribe()) })
} catch (err) { return unsubscribe
console.warn('[Firebase] Auth initialization skipped:', err)
}
} }
return { return {
user,
token, token,
user,
isAuthenticated, isAuthenticated,
isAdmin, isAdmin,
isInitializing,
googleSignIn, googleSignIn,
logout, logout,
initAuth initAuth
+6 -7
View File
@@ -4,12 +4,11 @@ export default defineNuxtPlugin(() => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const firebaseConfig = { const firebaseConfig = {
apiKey: config.public.firebaseApiKey || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck', apiKey: config.public.firebaseApiKey,
authDomain: config.public.firebaseAuthDomain || 'xflow-d8dd5.firebaseapp.com', authDomain: config.public.firebaseAuthDomain,
projectId: config.public.firebaseProjectId || 'xflow-d8dd5', projectId: config.public.firebaseProjectId,
appId: config.public.firebaseAppId || '1:944408960366:web:4d2cd393e85a0ca9b4357f', appId: config.public.firebaseAppId,
messagingSenderId: config.public.firebaseMessagingSenderId || '944408960366', messagingSenderId: config.public.firebaseMessagingSenderId
storageBucket: (config.public as any).firebaseStorageBucket || 'xflow-d8dd5.firebasestorage.app'
} }
if (!firebaseConfig.apiKey) { if (!firebaseConfig.apiKey) {
@@ -19,6 +18,6 @@ export default defineNuxtPlugin(() => {
if (!getApps().length) { if (!getApps().length) {
initializeApp(firebaseConfig) 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: { runtimeConfig: {
jwtSecret: process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key', 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 || '', giteaUrl: process.env.GITEA_URL || '',
giteaToken: process.env.GITEA_TOKEN || '', giteaToken: process.env.GITEA_TOKEN || '',
giteaOrg: process.env.GITEA_ORG || '', giteaOrg: process.env.GITEA_ORG || '',
@@ -72,12 +72,11 @@ export default defineNuxtConfig({
qwenApiKey: '', qwenApiKey: '',
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api', apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck', 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 || 'xflow-d8dd5.firebaseapp.com', 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 || 'xflow-d8dd5', 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 || '1:944408960366:web:4d2cd393e85a0ca9b4357f', 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 || '944408960366', firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || process.env.FIREBASE_MESSAGING_SENDER_ID || ''
firebaseStorageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET || process.env.FIREBASE_STORAGE_BUCKET || 'xflow-d8dd5.firebasestorage.app'
} }
}, },
+2 -1
View File
@@ -1,11 +1,12 @@
import { defineConfig } from "prisma/config"; import { defineConfig } from "prisma/config";
// Призма 7 иногда требует явной загрузки .env для некоторых команд
export default defineConfig({ export default defineConfig({
schema: "prisma/schema.prisma", schema: "prisma/schema.prisma",
migrations: { migrations: {
path: "prisma/migrations", path: "prisma/migrations",
}, },
datasource: { 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

+16 -30
View File
@@ -47,43 +47,29 @@ export default defineEventHandler(async (event) => {
const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '') const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')
const userId = fbUser.localId const userId = fbUser.localId
let user: any const user = await prisma.user.upsert({
try { where: { email },
user = await prisma.user.upsert({ update: {
where: { email }, username
update: { // Мы не затираем профиль при каждом входе, но нам нужно его получить
username },
}, create: {
create: {
id: userId,
email,
username,
password: 'firebase-managed',
role: 'user',
profile: {
create: {
displayName: fbUser.displayName || username,
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
}
}
},
include: {
profile: true
}
})
} catch (dbErr: any) {
console.warn('[Auth] Database offline, creating local dev session for:', email, dbErr.message)
user = {
id: userId, id: userId,
email, email,
username, username,
password: 'firebase-managed',
role: 'user', role: 'user',
profile: { profile: {
displayName: fbUser.displayName || username, create: {
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}` displayName: fbUser.displayName || username,
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
}
} }
},
include: {
profile: true
} }
} })
const token = generateToken({ const token = generateToken({
id: user.id, id: user.id,
+37 -51
View File
@@ -7,14 +7,41 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'User ID is required' }) throw createError({ statusCode: 400, message: 'User ID is required' })
} }
let profile: any = null let profile = await prisma.profile.findFirst({
try { where: {
profile = await prisma.profile.findFirst({ OR: [
{ userId: userId },
{ user: { username: userId } }
]
},
include: {
user: {
include: {
_count: {
select: { canvases: true, followers: true, following: true }
}
}
}
}
})
// If profile doesn't exist but user does, create it
if (!profile) {
const user = await prisma.user.findFirst({
where: { where: {
OR: [ OR: [{ id: userId }, { username: userId }]
{ userId: userId }, }
{ user: { username: userId } } })
]
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
profile = await prisma.profile.create({
data: {
userId: user.id,
displayName: user.username,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
}, },
include: { include: {
user: { user: {
@@ -26,55 +53,14 @@ export default defineEventHandler(async (event) => {
} }
} }
}) })
if (!profile) {
const user = await prisma.user.findFirst({
where: {
OR: [{ id: userId }, { username: userId }]
}
})
if (user) {
profile = await prisma.profile.create({
data: {
userId: user.id,
displayName: user.username,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
},
include: {
user: {
include: {
_count: {
select: { canvases: true, followers: true, following: true }
}
}
}
}
})
}
}
} 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 { return {
profile: { profile: {
...profile, ...profile,
canvasCount: profile.user?._count?.canvases || 0, canvasCount: profile.user._count.canvases,
followerCount: profile.user?._count?.followers || 0, followerCount: profile.user._count.followers,
followingCount: profile.user?._count?.following || 0 followingCount: profile.user._count.following
} }
} }
}) })
+29 -30
View File
@@ -12,19 +12,19 @@ services:
dockerfile: Dockerfile.coolify dockerfile: Dockerfile.coolify
args: args:
SITE_URL: '${SITE_URL:-http://localhost:3000}' 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}' REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
JWT_SECRET: '${JWT_SECRET:-super-secret-jwt-key}' JWT_SECRET: '${JWT_SECRET:-super-secret-jwt-key}'
OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}' OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
NUXT_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:-}' NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}' DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}' FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}' FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}' FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}' FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}' FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}' FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
TZ: '${TZ:-Asia/Almaty}' TZ: '${TZ:-Asia/Almaty}'
depends_on: depends_on:
- db - db
@@ -49,9 +49,9 @@ services:
POSTGRES_USER: '${POSTGRES_USER:-xflow}' POSTGRES_USER: '${POSTGRES_USER:-xflow}'
POSTGRES_DB: '${POSTGRES_DB:-xflow}' POSTGRES_DB: '${POSTGRES_DB:-xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}' POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
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}'
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}' NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
REDIS_URL: '${REDIS_URL:-redis://redis:6379}' REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
NUXT_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:-}' NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}' DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}' FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}' NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}' NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}' FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}' NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}' NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}' FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}' NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}' NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}' FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}' NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}' NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}' FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}' NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}' NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}' FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}' NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}' NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
db: db:
image: postgres:16-alpine image: postgres:16-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: '${POSTGRES_USER:-xflow}' POSTGRES_USER: '${POSTGRES_USER:-xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}' POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:?}'
POSTGRES_DB: '${POSTGRES_DB:-xflow}' POSTGRES_DB: '${POSTGRES_DB:-xflow}'
POSTGRES_HOST_AUTH_METHOD: 'trust'
volumes: volumes:
- xflow-pg-data:/var/lib/postgresql/data - 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