18 Commits

Author SHA1 Message Date
Alexyz 1fb5b15b75 feat(branding): bake XFlow fusion neon-X logo into all launcher icon densities and bump to v1.5 2026-10-03 22:33:26 +05:00
Alexyz 8b3d0f3d19 build(gradle): automatically copy release APKs directly to Desktop upon assembleRelease 2026-10-03 22:13:20 +05:00
Alexyz f4e1bae73f fix(icon): move launcher foreground to drawable-nodpi to fix 3x density blowout, regenerate all mipmap densities, and bump to v1.4 2026-10-03 22:08:52 +05:00
Alexyz 27235f6d54 feat(voice): add direct speech recognition, system ROLE_NOTES, voice shortcuts, and change app label to Avtex 2026-09-29 14:55:05 +05:00
Alexyz 34ec8944c1 fix(icon): rename launcher icon resource to ic_launcher_avtex and bump to v1.2 (code 3) to bust OnePlus launcher cache 2026-09-29 14:43:01 +05:00
Alexyz f7021313c6 fix(icon): crop launcher icon safe zone, bump versionCode to 2 (v1.1) to force launcher cache refresh 2026-09-28 21:54:53 +05:00
Alexyz 02de1aaf02 feat(assistant): add full Assistant note-taking pipeline, intent routing, and in-app Assistant Simulator 2026-09-28 21:52:04 +05:00
Alexyz f60ac2a5f3 feat(mobile): add Mindmaps & Canvas interactive screen with server sync and embedded flow viewer 2026-09-28 12:07:15 +05:00
Alexyz 176afcff39 feat(mobile): add automatic background cloud sync to NotesRepository pointing to Coolify server 2026-09-28 12:04:38 +05:00
Alexyz 7b37f6cf70 feat(firebase): embed production Firebase credentials as build-time defaults in nuxt.config, Dockerfile and compose 2026-09-28 12:01:25 +05:00
Alexyz 7d324a6140 fix(pwa,auth): serve manifest and icons from apps/web/public and prevent Firebase uninitialized crash 2026-09-28 11:49:46 +05:00
Alexyz 5c253b169e fix(prisma): remove deprecated url from schema.prisma per Prisma 7 specification (url is managed by prisma.config.ts) 2026-09-28 11:31:24 +05:00
Alexyz 54bfb8edd7 fix(prisma,db): add url to datasource in schema.prisma, copy prisma.config.ts to runner, and set POSTGRES_HOST_AUTH_METHOD=trust 2026-09-28 11:25:06 +05:00
Alexyz 9393cb823d fix(coolify): provide fallback POSTGRES_PASSWORD and remove unknown --skip-generate flag from prisma db push 2026-09-28 11:19:04 +05:00
Alexyz 7e8cae3801 fix(deploy): switch Dockerfile to node:22-slim to resolve esbuild glibc/musl mismatch and add profile fallback 2026-09-28 11:03:06 +05:00
Alexyz bc53c37fe0 chore(docker): expand .dockerignore to exclude mobile, gradle, kotlin, and local build artifacts 2026-09-28 10:48:11 +05:00
Alexyz 008b235ac5 chore(make): add smart fallback to make up for environments without docker 2026-09-28 10:43:41 +05:00
Alexyz dba93dc702 feat(docker): add local docker-compose stack and Makefile for full stack management 2026-09-28 10:41:05 +05:00
57 changed files with 1921 additions and 292 deletions
+45 -8
View File
@@ -1,18 +1,55 @@
node_modules
.output
.nuxt
node_modules/.cache
# Version Control
.git
.gitignore
# Dependencies & Cache
node_modules
**/node_modules
.output
.nuxt
dist
.cache
*.log
# Environment variables
.env
.env.*
.env.local
!.env.example
!.env.example.coolify
dist
*.log
*.canvas
# Exclude non-web monorepo apps from Docker build context
# Native Mobile (Android)
apps/mobile
app
.gradle
**/.gradle
.kotlin
build
**/build
local.properties
*.apk
*.aab
# Desktop CLI & Contracts build
apps/desktop
packages/shared-contracts/dist
# Legacy configs & DB dumps
docker/legacy-server
*.sql
*.sqlite
*.db
# Notes, Mindmaps & Brainstorming
.obsidian
.qwen
scratch
scripts
*.canvas
# IDE settings
.idea
.vscode
*.iml
*.ipr
*.iws
+4
View File
@@ -39,3 +39,7 @@ server/api/node_modules/
obsidian-git/
/app/generated/prisma
*.apk
local.properties
.gradle/
+17 -13
View File
@@ -1,7 +1,7 @@
# ==========================================
# Stage 1: builder
# ==========================================
FROM node:22-alpine AS builder
FROM node:22-slim AS builder
ENV HUSKY=0
ENV NODE_ENV=development
@@ -9,7 +9,8 @@ ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /src
RUN apk add --no-cache libc6-compat git openssl
RUN apt-get update && apt-get install -y --no-install-recommends openssl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
@@ -19,12 +20,12 @@ ARG OPEN_ROUTER_API_KEY=""
ARG NUXT_OPEN_ROUTER_API_KEY=""
ARG NUXT_QWEN_API_KEY=""
ARG DEEPSEEK_API_KEY=""
ARG FIREBASE_API_KEY=""
ARG FIREBASE_AUTH_DOMAIN=""
ARG FIREBASE_PROJECT_ID=""
ARG FIREBASE_STORAGE_BUCKET=""
ARG FIREBASE_MESSAGING_SENDER_ID=""
ARG FIREBASE_APP_ID=""
ARG FIREBASE_API_KEY="AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck"
ARG FIREBASE_AUTH_DOMAIN="xflow-d8dd5.firebaseapp.com"
ARG FIREBASE_PROJECT_ID="xflow-d8dd5"
ARG FIREBASE_STORAGE_BUCKET="xflow-d8dd5.firebasestorage.app"
ARG FIREBASE_MESSAGING_SENDER_ID="944408960366"
ARG FIREBASE_APP_ID="1:944408960366:web:4d2cd393e85a0ca9b4357f"
ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \
@@ -58,10 +59,11 @@ ENV SITE_URL=${SITE_URL} \
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
TZ=${TZ}
# Копируем package.json и prisma схемы из apps/web
# Копируем package.json, prisma схемы и конфиг
COPY apps/web/package*.json ./
COPY apps/web/prisma ./prisma
RUN npm ci --ignore-scripts && npx prisma generate
COPY apps/web/prisma.config.ts ./
RUN npm ci --include=optional && npx prisma generate
# Копируем исходный код apps/web
COPY apps/web ./
@@ -70,14 +72,15 @@ RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=developmen
# ==========================================
# Stage 2: runner
# ==========================================
FROM node:22-alpine AS runner
FROM node:22-slim AS runner
ENV HUSKY=0
ENV NODE_ENV=production
WORKDIR /src
RUN apk add --no-cache tzdata openssl
RUN apt-get update && apt-get install -y --no-install-recommends openssl tzdata ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
@@ -96,6 +99,7 @@ ENV SITE_URL=${SITE_URL} \
COPY --from=builder /src/.output ./.output
COPY --from=builder /src/prisma ./prisma
COPY --from=builder /src/prisma.config.ts ./
COPY --from=builder /src/package.json ./
COPY --from=builder /src/node_modules ./node_modules
@@ -108,4 +112,4 @@ ENV NITRO_PORT=3000
EXPOSE 3000
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && exec node .output/server/index.mjs"]
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && exec node .output/server/index.mjs"]
+71
View File
@@ -0,0 +1,71 @@
.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)"
+33 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.example.avtex"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "1.0"
versionCode = 6
versionName = "1.5"
}
signingConfigs {
@@ -49,6 +49,11 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
lint {
abortOnError = false
checkReleaseBuilds = false
}
}
kotlin {
@@ -104,3 +109,29 @@ dependencies {
// DataStore for settings
implementation(libs.androidx.datastore.preferences)
}
// Автоматическое копирование релизного APK на рабочий стол пользователя после сборки
val desktopDir = File(System.getProperty("user.home"), "Desktop")
tasks.register("copyReleaseApkToDesktop") {
notCompatibleWithConfigurationCache("Copies APK to user Desktop")
doLast {
val userHome = System.getProperty("user.home")
val desktopDir = File(userHome, "Desktop")
if (desktopDir.exists() && desktopDir.isDirectory) {
val apkFile = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
if (apkFile.exists()) {
val vName = android.defaultConfig.versionName ?: "release"
apkFile.copyTo(File(desktopDir, "Avtex-v${vName}.apk"), overwrite = true)
apkFile.copyTo(File(desktopDir, "Avtex-release.apk"), overwrite = true)
println(">>> [AUTO-DEPLOY] Релизный APK скопирован на Рабочий стол: Avtex-v${vName}.apk и Avtex-release.apk")
}
}
}
}
afterEvaluate {
tasks.findByName("assembleRelease")?.finalizedBy("copyReleaseApkToDesktop")
}
+43 -8
View File
@@ -15,29 +15,62 @@
<application
android:name=".AvtexApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher_avtex"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:roundIcon="@mipmap/ic_launcher_avtex_round"
android:supportsRtl="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Avtex">
<activity
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>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
<!-- Deep link intent-filter for Gemini / Assistant App Actions & XFlow links -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="xflow"
android:host="lists" />
<data android:scheme="xflow" />
</intent-filter>
<!-- Google Assistant Native Note-Taking Action ("Сделай заметку...") -->
<intent-filter>
<action android:name="android.intent.action.CREATE_NOTE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Android System Share Action (Поделиться текстом из любого приложения или ассистента) -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<!-- Google Voice Actions Note Creation -->
<intent-filter>
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Android Text Selection Process Action (выделение текста в любом месте -> отправить в Avtex) -->
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
@@ -66,7 +99,9 @@
<receiver
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>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
@@ -1,21 +1,52 @@
package com.example.avtex
import android.app.Activity
import android.app.role.RoleManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.speech.RecognizerIntent
import android.util.Log
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.example.avtex.theme.AvtexTheme
import kotlinx.coroutines.launch
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
class MainActivity : ComponentActivity() {
private val speechRecognizerLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val matches = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
val spokenText = matches?.firstOrNull()
if (!spokenText.isNullOrBlank()) {
routeAndSaveNote(spokenText, preferredList = null)
}
}
}
private val roleRequestLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
Toast.makeText(this, "Avtex назначен приложением заметок по умолчанию!", Toast.LENGTH_LONG).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleIncomingIntent(intent)
@@ -23,6 +54,10 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
setContent {
AvtexTheme {
CompositionLocalProvider(
LocalVoiceInputLauncher provides { launchVoiceInput() },
LocalRoleNotesRequester provides { requestDefaultNotesRole() }
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
@@ -32,55 +67,224 @@ class MainActivity : ComponentActivity() {
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIncomingIntent(intent)
}
fun launchVoiceInput() {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ru-RU")
putExtra(RecognizerIntent.EXTRA_PROMPT, "Скажите заметку, задачу или фильм...")
}
try {
speechRecognizerLauncher.launch(intent)
} catch (e: Exception) {
Log.e(TAG, "Speech recognizer not available", e)
Toast.makeText(this, "Голосовой ввод недоступен на этом устройстве", Toast.LENGTH_SHORT).show()
}
}
fun requestDefaultNotesRole() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
val roleManager = getSystemService(RoleManager::class.java)
if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_NOTES)) {
val roleIntent = roleManager.createRequestRoleIntent(RoleManager.ROLE_NOTES)
roleRequestLauncher.launch(roleIntent)
return
}
} catch (e: Exception) {
Log.e(TAG, "Failed to request ROLE_NOTES", e)
}
}
try {
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
startActivity(settingsIntent)
} catch (e: Exception) {
Toast.makeText(this, "Откройте: Настройки -> Приложения -> Приложения по умолчанию", Toast.LENGTH_LONG).show()
}
}
private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return
val action = intent.action
Log.d(TAG, "Incoming intent action: $action, data: ${intent.data}")
// 1. Обработка App Actions через Deep Link xflow://lists/add
val data = intent.data
if (data != null && data.scheme == "xflow") {
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
if (!itemName.isNullOrBlank()) {
saveItemFromAssistant(itemName, listName)
// 1. Google Assistant / Android System Note Action (android.intent.action.CREATE_NOTE)
if (action == Intent.ACTION_CREATE_NOTE) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
?: intent.getStringExtra("android.intent.extra.TEXT")
?: intent.getStringExtra(Intent.EXTRA_TITLE)
val title = intent.getStringExtra(Intent.EXTRA_TITLE)
if (!text.isNullOrBlank()) {
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
routeAndSaveNote(fullText, preferredList = null)
return
} else {
launchVoiceInput()
return
}
}
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
val extras = intent.extras
if (extras != null) {
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
// 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
if (data != null && data.scheme == "xflow") {
val host = data.host ?: ""
val path = data.path ?: ""
if (host == "voice" || path.contains("record")) {
launchVoiceInput()
return
}
val listName = data.getQueryParameter("list_name")
?: data.getQueryParameter("name")
?: data.getQueryParameter("list")
val itemName = data.getQueryParameter("item_name")
?: data.getQueryParameter("item")
?: data.getQueryParameter("note_text")
?: data.getQueryParameter("text")
?: data.getQueryParameter("note_title")
if (!itemName.isNullOrBlank()) {
saveItemFromAssistant(itemName, listName)
routeAndSaveNote(itemName, preferredList = listName)
return
}
}
// 6. Google Assistant / Gemini Built-in Intent Extras
val extras = intent.extras
if (extras != null) {
val itemName = extras.getString("itemListElement.name")
?: extras.getString("item_name")
?: extras.getString("note.text")
?: extras.getString("note_text")
?: extras.getString("text")
val listName = extras.getString("itemList.name")
?: extras.getString("list_name")
?: extras.getString("category")
if (!itemName.isNullOrBlank()) {
routeAndSaveNote(itemName, preferredList = listName)
}
}
}
private fun saveItemFromAssistant(itemName: String, listName: String) {
private fun routeAndSaveNote(rawText: String, preferredList: String?) {
val cleanText = rawText.trim()
if (cleanText.isBlank()) return
val (targetList, category) = resolveListAndCategory(cleanText, preferredList)
lifecycleScope.launch {
try {
val repo = AvtexApplication.instance.notesRepository
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
repo.addNoteItem(
title = itemName,
listTitle = listName,
title = cleanText,
listTitle = targetList,
category = category
)
Toast.makeText(
this@MainActivity,
"✅ Записано в XFlow: $itemName ($listName)",
"✅ Avtex: Записано в «$targetList»:\n$cleanText",
Toast.LENGTH_LONG
).show()
AvtexApplication.instance.settingsRepository.addLog(
tag = "Assistant",
message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"",
isImportant = true
)
} catch (e: Exception) {
Log.e(TAG, "Failed to save note from assistant", e)
Toast.makeText(
this@MainActivity,
"Ошибка сохранения: ${e.localizedMessage}",
Toast.LENGTH_SHORT
).show()
}
}
}
private fun resolveListAndCategory(text: String, preferredList: String?): Pair<String, String> {
val lower = text.lowercase()
// Если список был явно передан в интенте
if (!preferredList.isNullOrBlank()) {
val prefLower = preferredList.lowercase()
return when {
prefLower.contains("фильм") || prefLower.contains("кино") || prefLower.contains("сериал") || prefLower.contains("movie") ->
Pair(preferredList, "movies")
prefLower.contains("покупк") || prefLower.contains("магаз") || prefLower.contains("купить") || prefLower.contains("shop") ->
Pair(preferredList, "shopping")
prefLower.contains("задач") || prefLower.contains("дело") || prefLower.contains("task") || prefLower.contains("todo") ->
Pair(preferredList, "tasks")
else -> Pair(preferredList, "general")
}
}
// Авто-маршрутизация по содержанию текста
return when {
lower.contains("фильм") || lower.contains("сериал") || lower.contains("кино") ||
lower.contains("посмотреть") || lower.contains("сезон") || lower.contains("актер") ||
lower.contains("режиссер") || lower.contains("movie") || lower.contains("cinema") ->
Pair("Фильмы на просмотр", "movies")
lower.contains("купить") || lower.contains("покупки") || lower.contains("магазин") ||
lower.contains("взять") || lower.contains("продукты") || lower.contains("цена") ||
lower.contains("аптек") || lower.contains("buy") || lower.contains("shop") ->
Pair("Покупки", "shopping")
lower.contains("задача") || lower.contains("сделать") || lower.contains("напомни") ||
lower.contains("позвонить") || lower.contains("отправить") || lower.contains("todo") ->
Pair("Задачи", "tasks")
else -> Pair("Быстрые заметки", "general")
}
}
companion object {
private const val TAG = "AvtexMainActivity"
}
}
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
@@ -31,7 +31,7 @@ import com.example.avtex.theme.ObsidianSurfaceElevated
import com.example.avtex.theme.TextMuted
import com.example.avtex.ui.DashboardScreen
import com.example.avtex.ui.ListsScreen
import com.example.avtex.ui.OnePlusGuideScreen
import com.example.avtex.ui.MindmapsScreen
import com.example.avtex.ui.SettingsScreen
@Composable
@@ -58,10 +58,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 0,
onClick = { selectedTab = 0 },
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
icon = { Icon(Icons.Default.Hub, contentDescription = "Майндкарты") },
label = {
Text(
"Сон",
"Карты",
fontSize = 11.sp,
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
)
@@ -84,10 +84,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 2,
onClick = { selectedTab = 2 },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Сон") },
label = {
Text(
"Настройки",
"Сон",
fontSize = 11.sp,
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
)
@@ -97,10 +97,10 @@ fun MainNavigation() {
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
label = {
Text(
"OnePlus",
"Настройки",
fontSize = 11.sp,
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
)
@@ -111,10 +111,10 @@ fun MainNavigation() {
}
) { innerPadding ->
when (selectedTab) {
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
0 -> MindmapsScreen(modifier = Modifier.padding(innerPadding))
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
3 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
}
}
}
@@ -2,6 +2,7 @@ package com.example.avtex.notes
import android.content.ContentValues
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -11,9 +12,10 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
class NotesRepository(context: Context) {
class NotesRepository(private val context: Context) {
private val dbHelper = XFlowDatabaseHelper(context)
private val syncClient = XFlowSyncClient(context)
private val scope = CoroutineScope(Dispatchers.IO)
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
@@ -153,6 +155,26 @@ class NotesRepository(context: Context) {
reload()
// Фоновая отправка в XFlow Cloud (Coolify)
scope.launch {
try {
val prefs = context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE)
val serverUrl = prefs.getString("xflow_server_url", "https://xflow.controlsystem.kz")
?: "https://xflow.controlsystem.kz"
if (serverUrl.isNotBlank()) {
syncClient.syncItemToServer(
serverUrl = serverUrl,
title = title.trim(),
listTitle = listTitle.trim(),
category = category
)
}
} catch (e: Exception) {
Log.w("NotesRepository", "Background cloud sync failed: ${e.message}")
}
}
NoteItem(
id = newItemId,
listId = targetListId,
@@ -1,5 +1,10 @@
package com.example.avtex.ui
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -17,24 +22,34 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.TaskAlt
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Info
import com.example.avtex.LocalVoiceInputLauncher
import com.example.avtex.LocalRoleNotesRequester
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
@@ -48,17 +63,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.avtex.AvtexApplication
import com.example.avtex.MainActivity
import com.example.avtex.notes.NoteItem
import com.example.avtex.notes.NoteList
import com.example.avtex.theme.CyanAccent
import com.example.avtex.theme.ElectricIndigo
import com.example.avtex.theme.EmeraldMint
import com.example.avtex.theme.GlassBorder
import com.example.avtex.theme.NeonViolet
@@ -74,35 +90,31 @@ import kotlinx.coroutines.launch
@Composable
fun ListsScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val repository = remember { AvtexApplication.instance.notesRepository }
val lists by repository.listsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope()
var selectedCategory by remember { mutableStateOf("all") }
var newItemText by remember { mutableStateOf("") }
var isAddingActive by remember { mutableStateOf(false) }
val categories = listOf(
"all" to "Все",
"movies" to "🎬 Фильмы",
"shopping" to "🛒 Покупки",
"tasks" to "⚡ Дела"
)
var showAssistantTester by remember { mutableStateOf(false) }
val voiceLauncher = LocalVoiceInputLauncher.current
val filteredLists = remember(lists, selectedCategory) {
if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
if (selectedCategory == "all") lists
else lists.filter { it.category == selectedCategory }
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(ObsidianBg)
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
.background(ObsidianBg),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
// Header
// 1. Header
item {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -110,77 +122,118 @@ fun ListsScreen(modifier: Modifier = Modifier) {
) {
Column {
Text(
text = "XFlow Заметки & Списки",
fontSize = 22.sp,
text = "Списки и Заметки",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
text = "XFlow синхронизация • Голосовой ввод Ассистента",
fontSize = 12.sp,
color = TextMuted
)
}
IconButton(
onClick = { repository.reload() },
modifier = Modifier
.size(38.dp)
.clip(CircleShape)
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, CircleShape)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Direct Voice Button
Button(
onClick = { voiceLauncher?.invoke() },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "Обновить",
tint = CyanAccent,
modifier = Modifier.size(18.dp)
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
// Category Filter Chips
item {
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth()
) {
items(categories) { (key, label) ->
val isSelected = selectedCategory == key
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
.border(
1.dp,
if (isSelected) NeonViolet else GlassBorder,
RoundedCornerShape(12.dp)
)
.clickable { selectedCategory = key }
.padding(horizontal = 14.dp, vertical = 8.dp)
) {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = label,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
color = if (isSelected) TextPrimary else TextSecondary
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 = if (showAssistantTester) "Скрыть тест" else "Тест Ассистента",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = NeonViolet
)
}
}
}
}
}
// Quick Add Item Bar
// 2. Interactive Assistant Simulator Card
item {
Column(
AnimatedVisibility(visible = showAssistantTester) {
AssistantTesterCard(context = context)
}
}
// 3. Category Filter Chips
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryChip(
label = "Все",
selected = selectedCategory == "all",
onClick = { selectedCategory = "all" }
)
CategoryChip(
label = "Фильмы 🎬",
selected = selectedCategory == "movies",
onClick = { selectedCategory = "movies" }
)
CategoryChip(
label = "Покупки 🛒",
selected = selectedCategory == "shopping",
onClick = { selectedCategory = "shopping" }
)
CategoryChip(
label = "Задачи 📋",
selected = selectedCategory == "tasks",
onClick = { selectedCategory = "tasks" }
)
}
}
// 4. Manual Quick Add Box
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -189,7 +242,9 @@ fun ListsScreen(modifier: Modifier = Modifier) {
onValueChange = { newItemText = it },
placeholder = {
Text(
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
if (selectedCategory == "movies") "Название фильма на просмотр..."
else if (selectedCategory == "shopping") "Купить..."
else "Новая запись или заметка...",
fontSize = 13.sp,
color = TextMuted
)
@@ -214,7 +269,8 @@ fun ListsScreen(modifier: Modifier = Modifier) {
val listTitle = when (targetCategory) {
"movies" -> "Фильмы на просмотр"
"shopping" -> "Покупки"
else -> "Общий список"
"tasks" -> "Задачи"
else -> "Быстрые заметки"
}
repository.addNoteItem(
title = newItemText.trim(),
@@ -235,22 +291,33 @@ fun ListsScreen(modifier: Modifier = Modifier) {
}
}
// Lists and Items
// 5. Lists and Items
if (filteredLists.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 40.dp),
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.padding(vertical = 40.dp, horizontal = 20.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
"Нет записей. Добавьте первую заметку или скажите Gemini!",
fontSize = 13.sp,
text = "Нет записей в выбранной категории",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
fontSize = 12.sp,
color = TextMuted
)
}
}
}
} else {
items(filteredLists, key = { it.id }) { list ->
ListCard(
@@ -267,6 +334,302 @@ fun ListsScreen(modifier: Modifier = Modifier) {
}
}
@Composable
private fun AssistantTesterCard(context: Context) {
val voiceLauncher = LocalVoiceInputLauncher.current
val roleRequester = LocalRoleNotesRequester.current
var customVoiceQuery by remember { mutableStateOf("") }
var selectedPreset by remember { mutableStateOf("") }
val clipboard = remember { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
val adbCommand = remember(customVoiceQuery) {
val query = if (customVoiceQuery.isNotBlank()) customVoiceQuery else "Интерстеллар"
"adb shell am start -a android.intent.action.CREATE_NOTE -e android.intent.extra.TEXT \"$query\" com.example.avtex"
}
fun simulateVoiceCommand(text: String) {
val intent = Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_CREATE_NOTE
putExtra(Intent.EXTRA_TEXT, text)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
context.startActivity(intent)
}
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, NeonViolet.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
.padding(16.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.clip(CircleShape)
.background(NeonViolet.copy(alpha = 0.2f))
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = NeonViolet,
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(
text = "Симулятор голосового Ассистента",
fontSize = 15.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "Проверка распознавания и авто-сортировки заметок",
fontSize = 11.sp,
color = TextMuted
)
}
}
}
// Direct Voice Action & System Role
Button(
onClick = { voiceLauncher?.invoke() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
Spacer(modifier = Modifier.width(8.dp))
Text("🎤 Сказать голосом прямо сейчас", fontWeight = FontWeight.Bold, color = Color.White)
}
OutlinedButton(
onClick = { roleRequester?.invoke() },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f))
) {
Icon(Icons.Default.Settings, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("⚙️ Сделать Avtex заметками по умолчанию", fontSize = 12.sp, color = NeonViolet)
}
// Explanatory note about Google Keep
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
.padding(10.dp)
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Info, contentDescription = null, tint = SolarAmber, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(6.dp))
Text("Почему Ассистент отправляет в Google Keep?", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = SolarAmber)
}
Text(
text = "Google зафиксировал Keep как облачного провайдера заметок. Чтобы переключить:\n• Откройте: Настройки -> Google -> Google Ассистент -> «Заметки и списки» -> выберите «Не синхронизировать».\n• Либо произносите явно: «Окей Google, открой Avtex и запиши...».\n• Либо нажмите кнопку «Сказать голосом» выше или зажмите иконку Avtex на рабочем столе!",
fontSize = 10.sp,
color = TextSecondary,
lineHeight = 14.sp
)
}
}
Text(
text = "Быстрые сценарии тестирования (нажмите для проверки):",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
// Preset Buttons Grid
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
AssistantPresetButton(
label = "🎬 Фильм: «Запиши фильм Начало на вечер»",
onClick = { simulateVoiceCommand("фильм Начало на вечер") }
)
AssistantPresetButton(
label = "🛒 Покупки: «Купить молоко, кофе и сыр»",
onClick = { simulateVoiceCommand("купить молоко, кофе и сыр") }
)
AssistantPresetButton(
label = "📋 Задача: «Позвонить автомеханику в 11»",
onClick = { simulateVoiceCommand("задача позвонить автомеханику в 11") }
)
AssistantPresetButton(
label = "💡 Заметка: «Идея для нового проекта XFlow»",
onClick = { simulateVoiceCommand("Идея для нового проекта XFlow") }
)
}
Spacer(modifier = Modifier.height(4.dp))
// Custom text input
Text(
text = "Своя фраза для проверки:",
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
OutlinedTextField(
value = customVoiceQuery,
onValueChange = { customVoiceQuery = it },
placeholder = { Text("Например: сериал Локи 2 сезон", fontSize = 12.sp, color = TextMuted) },
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(10.dp),
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NeonViolet,
unfocusedBorderColor = GlassBorder,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary
)
)
Button(
onClick = {
if (customVoiceQuery.isNotBlank()) {
simulateVoiceCommand(customVoiceQuery.trim())
customVoiceQuery = ""
}
},
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Icon(Icons.Default.PlayArrow, contentDescription = "Запуск", tint = Color.White)
}
}
// ADB command helper
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
.padding(10.dp)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Terminal, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "Команда для теста через ADB с ПК:",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = CyanAccent
)
}
IconButton(
onClick = {
clipboard.setPrimaryClip(ClipData.newPlainText("adb command", adbCommand))
Toast.makeText(context, "Команда скопирована в буфер", Toast.LENGTH_SHORT).show()
},
modifier = Modifier.size(24.dp)
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", tint = TextMuted, modifier = Modifier.size(14.dp))
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = adbCommand,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace,
color = TextSecondary,
lineHeight = 13.sp
)
}
}
}
}
}
@Composable
private fun AssistantPresetButton(
label: String,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(8.dp))
.clickable { onClick() }
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = TextPrimary
)
Text(
text = "ТЕСТ ▶",
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = NeonViolet
)
}
}
}
@Composable
private fun CategoryChip(
label: String,
selected: Boolean,
onClick: () -> Unit
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label, fontSize = 12.sp) },
shape = RoundedCornerShape(10.dp),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = NeonViolet.copy(alpha = 0.25f),
selectedLabelColor = Color.White,
containerColor = ObsidianSurfaceElevated,
labelColor = TextMuted
),
border = FilterChipDefaults.filterChipBorder(
enabled = true,
selected = selected,
borderColor = GlassBorder,
selectedBorderColor = NeonViolet.copy(alpha = 0.6f)
)
)
}
@Composable
private fun ListCard(
list: NoteList,
@@ -283,9 +646,9 @@ private fun ListCard(
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(18.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
@@ -307,17 +670,25 @@ private fun ListCard(
)
Text(
text = list.title,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(accentColor.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = "${list.items.count { !it.isCompleted }} активных",
fontSize = 11.sp,
color = TextMuted
fontWeight = FontWeight.Bold,
color = accentColor
)
}
}
// Items list
if (list.items.isEmpty()) {
@@ -325,7 +696,7 @@ private fun ListCard(
text = "Список пуст",
fontSize = 12.sp,
color = TextMuted,
modifier = Modifier.padding(start = 16.dp)
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
)
} else {
list.items.forEach { item ->
@@ -351,10 +722,10 @@ private fun ItemRow(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurfaceElevated)
.background(ObsidianSurface)
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.clickable { onToggle() }
.padding(horizontal = 10.dp, vertical = 6.dp),
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
@@ -381,13 +752,15 @@ private fun ItemRow(
Text(
text = "🎬 Буду смотреть",
fontSize = 10.sp,
color = CyanAccent
color = CyanAccent,
fontWeight = FontWeight.Medium
)
} else if (item.status == "watched") {
Text(
text = "✔ Просмотрено",
fontSize = 10.sp,
color = EmeraldMint
color = EmeraldMint,
fontWeight = FontWeight.Medium
)
}
}
@@ -0,0 +1,507 @@
package com.example.avtex.ui
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.view.ViewGroup
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.example.avtex.theme.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class CanvasItem(
val id: String,
val name: String,
val description: String = "",
val nodeCount: Int = 0,
val isPublic: Boolean = false,
val updatedAt: String = ""
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MindmapsScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
val serverUrl = remember {
mutableStateOf(prefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
}
var canvases by remember {
mutableStateOf(
listOf(
CanvasItem("c-1", "Архитектура XFlow (Monorepo & Onion)", "Ядро системы, Nuxt 4 и модули", 14, true, "Сегодня"),
CanvasItem("c-2", "План развития 2026", "AI ассистент, списки, трекер сна", 9, false, "Вчера"),
CanvasItem("c-3", "Идеи и проекты (Brainstorm)", "Синхронизация Obsidian и Mobile", 6, false, "25 сен")
)
)
}
var isLoading by remember { mutableStateOf(false) }
var selectedCanvasId by remember { mutableStateOf<String?>(null) }
var showCreateDialog by remember { mutableStateOf(false) }
var newCanvasName by remember { mutableStateOf("") }
// Если открыт конкретный канвас — отображаем интерактивный редактор Flow через WebView
if (selectedCanvasId != null) {
BackHandler {
selectedCanvasId = null
}
CanvasEditorView(
canvasId = selectedCanvasId!!,
serverUrl = serverUrl.value,
onClose = { selectedCanvasId = null }
)
return
}
Scaffold(
modifier = modifier.fillMaxSize(),
containerColor = ObsidianBg,
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "Майндкарты",
fontWeight = FontWeight.Bold,
color = Color.White
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(NeonViolet.copy(alpha = 0.2f))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text("XFlow Canvas", fontSize = 10.sp, color = NeonViolet, fontWeight = FontWeight.Bold)
}
}
},
actions = {
IconButton(onClick = {
isLoading = true
scope.launch {
canvases = fetchCanvases(serverUrl.value, canvases)
isLoading = false
}
}) {
Icon(Icons.Default.Refresh, contentDescription = "Обновить", tint = Color.White)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = ObsidianSurface)
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showCreateDialog = true },
containerColor = NeonViolet,
contentColor = Color.White,
shape = CircleShape
) {
Icon(Icons.Default.Add, contentDescription = "Создать майндкарту")
}
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
// Баннер интеграции
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(
Brush.horizontalGradient(
listOf(
NeonViolet.copy(alpha = 0.2f),
ElectricIndigo.copy(alpha = 0.15f),
Color.Transparent
)
)
)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(44.dp)
.clip(CircleShape)
.background(NeonViolet.copy(alpha = 0.25f)),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.Hub, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(24.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Интерактивный граф узлов", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
Text(
"Полная совместимость с Obsidian Canvas и веб-сервером XFlow",
color = TextMuted,
fontSize = 12.sp,
maxLines = 2
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
if (isLoading) {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = NeonViolet)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(bottom = 80.dp)
) {
items(canvases, key = { it.id }) { canvas ->
CanvasCard(
canvas = canvas,
onClick = { selectedCanvasId = canvas.id }
)
}
}
}
}
}
if (showCreateDialog) {
AlertDialog(
onDismissRequest = { showCreateDialog = false },
containerColor = ObsidianSurfaceElevated,
title = { Text("Новая майндкарта", color = Color.White, fontWeight = FontWeight.Bold) },
text = {
OutlinedTextField(
value = newCanvasName,
onValueChange = { newCanvasName = it },
placeholder = { Text("Название майндкарты (напр. Проект X)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
focusedBorderColor = NeonViolet,
unfocusedBorderColor = GlassBorder
),
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
Button(
onClick = {
if (newCanvasName.isNotBlank()) {
val newItem = CanvasItem(
id = "c-" + System.currentTimeMillis(),
name = newCanvasName.trim(),
description = "Создано с мобильного XFlow",
nodeCount = 1,
updatedAt = "Только что"
)
canvases = listOf(newItem) + canvases
newCanvasName = ""
showCreateDialog = false
selectedCanvasId = newItem.id
}
},
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Text("Создать")
}
},
dismissButton = {
TextButton(onClick = { showCreateDialog = false }) {
Text("Отмена", color = TextMuted)
}
}
)
}
}
@Composable
fun CanvasCard(
canvas: CanvasItem,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.clickable(onClick = onClick)
.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurfaceElevated),
contentAlignment = Alignment.Center
) {
Icon(Icons.Default.AccountTree, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(20.dp))
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = canvas.name,
color = Color.White,
fontWeight = FontWeight.SemiBold,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (canvas.description.isNotBlank()) {
Text(
text = canvas.description,
color = TextMuted,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${canvas.nodeCount} узлов",
color = CyanAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = canvas.updatedAt,
color = TextMuted,
fontSize = 11.sp
)
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = null,
tint = TextMuted,
modifier = Modifier.size(20.dp)
)
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun CanvasEditorView(
canvasId: String,
serverUrl: String,
onClose: () -> Unit
) {
var webViewLoading by remember { mutableStateOf(true) }
var hasError by remember { mutableStateOf(false) }
val editorUrl = remember(canvasId, serverUrl) {
val base = serverUrl.trimEnd('/')
if (canvasId.startsWith("c-")) {
"$base/canvas"
} else {
"$base/canvas/$canvasId"
}
}
Box(modifier = Modifier.fillMaxSize().background(ObsidianBg)) {
AndroidView(
factory = { context ->
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
webViewLoading = true
hasError = false
}
override fun onPageFinished(view: WebView?, url: String?) {
webViewLoading = false
}
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
webViewLoading = false
hasError = true
}
}
loadUrl(editorUrl)
}
},
modifier = Modifier.fillMaxSize()
)
// Верхняя панель закрытия редактора
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = onClose,
modifier = Modifier
.clip(CircleShape)
.background(ObsidianSurfaceElevated.copy(alpha = 0.85f))
.border(1.dp, GlassBorder, CircleShape)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = Color.White)
}
Text(
"XFlow Canvas Viewer",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(ObsidianSurfaceElevated.copy(alpha = 0.85f))
.border(1.dp, GlassBorder, RoundedCornerShape(20.dp))
.padding(horizontal = 14.dp, vertical = 6.dp)
)
}
if (webViewLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(color = NeonViolet)
}
}
if (hasError) {
Box(
modifier = Modifier
.fillMaxSize()
.background(ObsidianBg.copy(alpha = 0.95f))
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.CloudOff, contentDescription = null, tint = TextMuted, modifier = Modifier.size(54.dp))
Spacer(modifier = Modifier.height(16.dp))
Text("Редактор оффлайн", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Не удалось подключиться к $editorUrl.\nПроверьте подключение к серверу XFlow в Настройках.",
color = TextMuted,
fontSize = 13.sp,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = onClose,
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
) {
Text("Вернуться к списку")
}
}
}
}
}
}
suspend fun fetchCanvases(serverUrl: String, fallback: List<CanvasItem>): List<CanvasItem> = withContext(Dispatchers.IO) {
val client = OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.readTimeout(5, TimeUnit.SECONDS)
.build()
try {
val request = Request.Builder()
.url("${serverUrl.trimEnd('/')}/api/canvas")
.get()
.build()
val response = client.newCall(request).execute()
response.use {
if (it.isSuccessful) {
val bodyStr = it.body?.string() ?: return@withContext fallback
val json = JSONObject(bodyStr)
val list = mutableListOf<CanvasItem>()
val arr = json.optJSONArray("canvases") ?: JSONArray()
for (i in 0 until arr.length()) {
val obj = arr.getJSONObject(i)
list.add(
CanvasItem(
id = obj.optString("id", "c-$i"),
name = obj.optString("name", "Майндкарта"),
description = obj.optString("description", ""),
nodeCount = obj.optJSONArray("nodes")?.length() ?: 0,
isPublic = obj.optBoolean("isPublic", false),
updatedAt = obj.optString("updatedAt", "Недавно")
)
)
}
if (list.isNotEmpty()) list else fallback
} else {
fallback
}
}
} catch (e: Exception) {
fallback
}
}
@@ -1,5 +1,7 @@
package com.example.avtex.ui
import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -80,6 +82,12 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) }
val context = androidx.compose.ui.platform.LocalContext.current
val xflowPrefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
var xflowServerUrl by remember {
mutableStateOf(xflowPrefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
}
var xflowTestStatus by remember { mutableStateOf<String?>(null) }
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
var isSaved by remember { mutableStateOf(false) }
@@ -382,6 +390,107 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
}
}
// XFlow Cloud Server Card
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(22.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
.padding(18.dp)
) {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(CyanAccent.copy(alpha = 0.15f))
.padding(8.dp)
) {
Icon(
imageVector = Icons.Default.Link,
contentDescription = null,
tint = CyanAccent,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column {
Text(
text = "Сервер XFlow (Coolify / Cloud)",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = TextPrimary
)
Text(
text = "Синхронизация Canvas майндкарт и заметок",
fontSize = 11.sp,
color = TextMuted
)
}
}
Spacer(modifier = Modifier.height(14.dp))
OutlinedTextField(
value = xflowServerUrl,
onValueChange = { xflowServerUrl = it; isSaved = false },
label = { Text("URL сервера XFlow") },
placeholder = { Text("https://xflow.controlsystem.kz") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
singleLine = true,
colors = customTextFieldColors()
)
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = xflowTestStatus ?: "Статус: https://xflow.controlsystem.kz",
fontSize = 12.sp,
color = if (xflowTestStatus?.contains("OK") == true) EmeraldMint else TextMuted,
modifier = Modifier.weight(1f)
)
androidx.compose.material3.TextButton(
onClick = {
coroutineScope.launch {
xflowTestStatus = "Проверка..."
try {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.build()
val req = okhttp3.Request.Builder()
.url("${xflowServerUrl.trimEnd('/')}/api/canvas")
.build()
val resp = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
client.newCall(req).execute()
}
resp.use {
if (it.isSuccessful) {
xflowTestStatus = "Подключено: HTTP ${it.code} OK"
} else {
xflowTestStatus = "Ответ: HTTP ${it.code}"
}
}
} catch (e: Exception) {
xflowTestStatus = "Ошибка: ${e.localizedMessage}"
}
}
}
) {
Text("Проверить", color = CyanAccent, fontSize = 13.sp)
}
}
}
}
}
// 4. Save Button
item {
Button(
@@ -398,6 +507,7 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
confidenceThreshold = confidenceThreshold.roundToInt(),
broadcastIntents = broadcastIntents
)
xflowPrefs.edit().putString("xflow_server_url", xflowServerUrl.trim()).apply()
isSaved = true
}
},
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

Before

Width:  |  Height:  |  Size: 3.9 KiB

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

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

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

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

+4
View File
@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 252 B

+34
View File
@@ -0,0 +1,34 @@
{
"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
@@ -0,0 +1,34 @@
{
"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"
}
]
}
@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+4
View File
@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+4
View File
@@ -0,0 +1,4 @@
<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>

After

Width:  |  Height:  |  Size: 281 B

+23 -9
View File
@@ -7,7 +7,9 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, message: 'User ID is required' })
}
let profile = await prisma.profile.findFirst({
let profile: any = null
try {
profile = await prisma.profile.findFirst({
where: {
OR: [
{ userId: userId },
@@ -25,7 +27,6 @@ export default defineEventHandler(async (event) => {
}
})
// If profile doesn't exist but user does, create it
if (!profile) {
const user = await prisma.user.findFirst({
where: {
@@ -33,10 +34,7 @@ export default defineEventHandler(async (event) => {
}
})
if (!user) {
throw createError({ statusCode: 404, message: 'User not found' })
}
if (user) {
profile = await prisma.profile.create({
data: {
userId: user.id,
@@ -54,13 +52,29 @@ export default defineEventHandler(async (event) => {
}
})
}
}
} catch (e: any) {
console.warn(`[Profile API] Database unreachable (${e.message}), returning dev profile for ${userId}`)
}
if (!profile) {
profile = {
userId,
displayName: userId.length > 20 ? 'User' : userId,
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`,
bio: '',
user: {
_count: { canvases: 0, followers: 0, following: 0 }
}
}
}
return {
profile: {
...profile,
canvasCount: profile.user._count.canvases,
followerCount: profile.user._count.followers,
followingCount: profile.user._count.following
canvasCount: profile.user?._count?.canvases || 0,
followerCount: profile.user?._count?.followers || 0,
followingCount: profile.user?._count?.following || 0
}
}
})
+30 -29
View File
@@ -12,19 +12,19 @@ services:
dockerfile: Dockerfile.coolify
args:
SITE_URL: '${SITE_URL:-http://localhost:3000}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
JWT_SECRET: '${JWT_SECRET:-super-secret-jwt-key}'
OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
NUXT_OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
TZ: '${TZ:-Asia/Almaty}'
depends_on:
- db
@@ -49,9 +49,9 @@ services:
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}'
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
NUXT_REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
@@ -64,32 +64,33 @@ services:
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:?}'
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}'
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
POSTGRES_HOST_AUTH_METHOD: 'trust'
volumes:
- xflow-pg-data:/var/lib/postgresql/data
+74
View File
@@ -0,0 +1,74 @@
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