1 Commits

281 changed files with 207 additions and 56905 deletions
-55
View File
@@ -1,55 +0,0 @@
# 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
# 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
-47
View File
@@ -1,47 +0,0 @@
# API Keys Configuration
# Copy this file to .env and fill in your actual keys
# DeepSeek API (recommended - cost effective)
# Get from: https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
# OpenAI API (alternative)
# Get from: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk_your_openai_api_key_here
# Anthropic API (alternative)
# Get from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Firebase Configuration (for Nuxt runtimeConfig)
# Get from: https://console.firebase.google.com -> Project Settings -> General
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
# Server-side Firebase API key (same as public, used for token verification)
NUXT_FIREBASE_API_KEY=your_firebase_api_key
# Google Calendar API
# Enable in Google Cloud Console
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
# Database (PostgreSQL)
# Production: postgresql://xflow:password@postgres:5432/xflow
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
# Gitea Integration
GITEA_URL=http://git.a-crm.controlsystem.kz
GITEA_TOKEN=your_gitea_token
GITEA_ORG=AVTEX
GITEA_MINDMAP_REPO=MMAVTEX
GITEA_PROJECT_REPO=Projects
# JWT Secret for session tokens
NUXT_JWT_SECRET=your_random_secret_here
-38
View File
@@ -1,38 +0,0 @@
# ========================================================
# XFlow — Coolify Environment Variables Template
# ========================================================
# В Coolify UI:
# 1. Set Compose file = docker-compose.coolify.yml
# 2. Set Dockerfile = Dockerfile.coolify
# 3. Скопируйте эти переменные в раздел Environment Variables проекта.
APP_NAME=xflow
NODE_ENV=production
SITE_URL=https://xflow.yourdomain.com
TZ=Asia/Almaty
# База данных PostgreSQL
POSTGRES_USER=xflow
POSTGRES_PASSWORD=generate-secure-password
POSTGRES_DB=xflow
DATABASE_URL=postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow
# Redis (кеширование и фоновые задачи)
REDIS_URL=redis://redis:6379
# Безопасность и JWT
JWT_SECRET=generate-super-secret-jwt-key-minimum-32-chars
# ИИ провайдеры (OpenRouter / Qwen / DeepSeek)
OPEN_ROUTER_API_KEY=
NUXT_OPEN_ROUTER_API_KEY=
NUXT_QWEN_API_KEY=
DEEPSEEK_API_KEY=
# Firebase Push Notifications (Web Push PWA)
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
-104
View File
@@ -1,104 +0,0 @@
name: Deploy XFlow (AVTEX/Projects)
on:
push:
branches:
- master
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
container:
image: node:22-alpine
defaults:
run:
shell: sh
steps:
- name: Checkout
env:
GIT_TOKEN: ${{ secrets.GIT_TOKEN }}
run: |
apk add --no-cache git
CLEAN_TOKEN=$(printf '%s' "${GIT_TOKEN}" | tr -d '\r\n')
if [ ! -d ".git" ]; then
git init
git remote add origin "http://oauth2:${CLEAN_TOKEN}@git.a-crm.controlsystem.kz/AVTEX/XFlow.git"
fi
git fetch origin master
git reset --hard origin/master
- name: Install dependencies
run: |
npm install --include=dev
ls -la node_modules/prisma || echo "Prisma not found in node_modules"
- name: Build Nuxt
run: |
npx prisma generate
npm run build
mkdir -p .output/server/node_modules/@prisma
cp -r node_modules/.prisma .output/server/node_modules/
cp -r node_modules/@prisma/* .output/server/node_modules/@prisma/
cp -r node_modules/pg .output/server/node_modules/
env:
DATABASE_URL: "postgresql://admin:dummy@dummy:5432/dummy" # Нужна заглушка для билда призмы
NUXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }}
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.FIREBASE_AUTH_DOMAIN }}
NUXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
NUXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.FIREBASE_APP_ID }}
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.FIREBASE_MESSAGING_SENDER_ID }}
- name: Deploy to server
run: |
if ! command -v docker >/dev/null 2>&1; then
if command -v apk >/dev/null 2>&1; then
apk add --no-cache docker-cli
elif command -v apt-get >/dev/null 2>&1; then
apt-get update && apt-get install -y docker.io
else
echo "Docker not found and no package manager detected"
exit 1
fi
fi
echo "🌉 Создаем мост к серверу через временный контейнер..."
docker stop bridge 2>/dev/null || true
docker rm bridge 2>/dev/null || true
docker run -d --name bridge -v /root/projects:/server alpine sleep 300
echo "🧹 Очистка старых файлов на сервере..."
docker exec bridge sh -c "rm -rf /server/xflow/.output/*"
docker exec bridge sh -c "rm -rf /server/xflow/prisma/*"
echo "🚀 Копирование билда и файлов на сервер..."
docker exec bridge mkdir -p /server/xflow/.output
docker exec bridge mkdir -p /server/xflow/prisma
docker exec bridge mkdir -p /server/xflow/node_modules
docker cp .output/. bridge:/server/xflow/.output/
docker cp prisma/. bridge:/server/xflow/prisma/
docker cp node_modules/. bridge:/server/xflow/node_modules/
docker cp package.json bridge:/server/xflow/package.json
docker cp prisma.config.ts bridge:/server/xflow/prisma.config.ts
docker rm -f bridge
echo "🔄 Перезапуск приложения через единый docker-compose.yml на хосте..."
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /root/projects:/root/projects \
-w /root/projects \
docker:cli \
sh -c "docker compose stop xflow_crm 2>/dev/null || true; docker compose rm -f xflow_crm 2>/dev/null || true; docker compose up -d --force-recreate --no-deps xflow_crm"
echo "⏳ Waiting for container to start..."
sleep 5
# Миграции с использованием новой БД xflow_crm_db
docker exec xflow_crm npx prisma migrate deploy
docker exec xflow_crm npx prisma db seed
echo "✅ XFlow CRM deployed at $(date)"
+11 -43
View File
@@ -1,45 +1,13 @@
# Environment variables
.env
.env.local
.env.*.local
!/.env.example
# Nuxt
.nuxt
.nitro
.cache
.output
dist
# IDE
.vscode/
.idea/
*.iml *.iml
*.ipr .gradle
*.iws /local.properties
# Dependencies
node_modules/
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Coverage
coverage/
# Temporary files
*.tmp
*.temp
server/api/node_modules/
obsidian-git/
/app/generated/prisma
*.apk
local.properties local.properties
.gradle/ /.idea/
.idea/
.DS_Store
/build
build/
app/build/
/captures
.externalNativeBuild
.cxx
-1
View File
@@ -1 +0,0 @@
{}
-1
View File
@@ -1 +0,0 @@
{}
-33
View File
@@ -1,33 +0,0 @@
{
"file-explorer": true,
"global-search": true,
"switcher": true,
"graph": true,
"backlink": true,
"canvas": true,
"outgoing-link": true,
"tag-pane": true,
"footnotes": false,
"properties": true,
"page-preview": true,
"daily-notes": true,
"templates": true,
"note-composer": true,
"command-palette": true,
"slash-command": false,
"editor-status": true,
"bookmarks": true,
"markdown-importer": false,
"zk-prefixer": false,
"random-note": false,
"outline": true,
"word-count": true,
"slides": false,
"audio-recorder": false,
"workspaces": false,
"file-recovery": true,
"publish": false,
"sync": true,
"bases": true,
"webviewer": false
}
-232
View File
@@ -1,232 +0,0 @@
{
"main": {
"id": "5578cc3a84253281",
"type": "split",
"children": [
{
"id": "08c5d1d78034a917",
"type": "tabs",
"children": [
{
"id": "3f38aefa8626b1f8",
"type": "leaf",
"state": {
"type": "canvas",
"state": {
"file": "session-2026-04-23.canvas",
"viewState": {
"x": -30,
"y": 220,
"zoom": -1.3211285963682102
}
},
"icon": "lucide-layout-dashboard",
"title": "session-2026-04-23"
}
}
]
}
],
"direction": "vertical"
},
"left": {
"id": "51c2518351e40de9",
"type": "split",
"children": [
{
"id": "e549873bfb898273",
"type": "tabs",
"children": [
{
"id": "593bb9c31fd772d7",
"type": "leaf",
"state": {
"type": "file-explorer",
"state": {
"sortOrder": "alphabetical",
"autoReveal": false
},
"icon": "lucide-folder-closed",
"title": "Файловый менеджер"
}
},
{
"id": "62546ac7efa0da90",
"type": "leaf",
"state": {
"type": "search",
"state": {
"query": "",
"matchingCase": false,
"explainSearch": false,
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical"
},
"icon": "lucide-search",
"title": "Поиск"
}
},
{
"id": "1d80fa437f26e9ac",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-bookmark",
"title": "Закладки"
}
}
]
}
],
"direction": "horizontal",
"width": 300
},
"right": {
"id": "852b2aecff829521",
"type": "split",
"children": [
{
"id": "e7d70e8889ccb870",
"type": "tabs",
"children": [
{
"id": "5746c4634649e54f",
"type": "leaf",
"state": {
"type": "backlink",
"state": {
"file": "xflow-project-ru.canvas",
"collapseAll": false,
"extraContext": false,
"sortOrder": "alphabetical",
"showSearch": false,
"searchQuery": "",
"backlinkCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-coming-in",
"title": "Обратные ссылки для xflow-project-ru"
}
},
{
"id": "75023e9b8d4fc317",
"type": "leaf",
"state": {
"type": "outgoing-link",
"state": {
"file": "xflow-project-ru.canvas",
"linksCollapsed": false,
"unlinkedCollapsed": true
},
"icon": "links-going-out",
"title": "Исходящие ссылки из xflow-project-ru"
}
},
{
"id": "c8b2ad057ffcc230",
"type": "leaf",
"state": {
"type": "tag",
"state": {
"sortOrder": "frequency",
"useHierarchy": true,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-tags",
"title": "Теги"
}
},
{
"id": "c551c9749ed1b9fd",
"type": "leaf",
"state": {
"type": "all-properties",
"state": {
"sortOrder": "frequency",
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-archive",
"title": "Все свойства"
}
},
{
"id": "b81e950116c8095d",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "xflow-project-ru.canvas",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Структура xflow-project-ru"
}
}
]
}
],
"direction": "horizontal",
"width": 300,
"collapsed": true
},
"left-ribbon": {
"hiddenItems": {
"switcher:Меню быстрого перехода": false,
"graph:Граф": false,
"canvas:Создать новый холст": false,
"daily-notes:Сегодняшняя заметка": false,
"templates:Вставить шаблон": false,
"command-palette:Открыть палитру команд": false,
"bases:Создать новую базу": false
}
},
"active": "3f38aefa8626b1f8",
"lastOpenFiles": [
"docker-compose.dev.yml",
"xflow-project-ru.canvas",
"session-2026-04-23.canvas",
"app/server/utils/prisma.ts",
"prisma.config.ts",
"prisma/schema.prisma",
"prisma",
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/promise/QueryableBase.d.ts",
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/promise/ExecutableBase.d.ts",
"node_modules/mysql2/typings/mysql/lib/protocol/packets/params/OkPacketParams.d.ts",
"node_modules/mysql2/typings/mysql/lib/protocol/packets/params/ErrorPacketParams.d.ts",
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/Sequence.d.ts",
"node_modules/mysql2/typings/mysql/lib/protocol/sequences/QueryableBase.d.ts",
"node_modules/mysql2/lib/auth_plugins/caching_sha2_password.md",
"node_modules/mysql2/README.md",
"node_modules/remeda/README.md",
"node_modules/valibot/README.md",
"node_modules/valibot/LICENSE.md",
"node_modules/zeptomatch/readme.md",
"node_modules/better-result/README.md",
"node_modules/env-paths/readme.md",
"node_modules/chart.js/README.md",
"node_modules/chart.js/LICENSE.md",
"node_modules/@standard-schema/spec/README.md",
"node_modules/fast-check/README.md",
"node_modules/fast-check/CHANGELOG.md",
"node_modules/aws-ssl-profiles/README.md",
"node_modules/lru.min/README.md",
"node_modules/@radix-ui/react-use-layout-effect/README.md",
"node_modules/@radix-ui/react-use-effect-event/README.md",
"node_modules/@radix-ui/react-use-controllable-state/README.md",
"node_modules/@radix-ui/react-toggle/README.md",
"node_modules/@radix-ui/react-slot/README.md",
"node_modules/@radix-ui/react-primitive/README.md",
"node_modules/@radix-ui/react-compose-refs/README.md",
"node_modules/@radix-ui/primitive/README.md",
"node_modules/svgo/node_modules/commander/Readme.md",
"node_modules/@prisma/streams-local/dist/touch/hash_vendor/NOTICE.md",
"node_modules/proper-lockfile/node_modules/retry/equation.gif",
"xflow-project.canvas"
]
}
-15
View File
@@ -1,15 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter *)",
"Bash(dart *)",
"Bash(taskkill *)",
"Bash(select-object *)",
"Bash(findstr *)",
"Bash(dir *)",
"Bash(select-string *)",
"Bash(timeout *)"
]
},
"$version": 3
}
-7
View File
@@ -1,7 +0,0 @@
{
"permissions": {
"allow": [
"Bash(flutter *)"
]
}
}
-156
View File
@@ -1,156 +0,0 @@
{
"nodes": [
{
"id": "center",
"type": "text",
"x": 100,
"y": 300,
"width": 350,
"height": 220,
"label": "📅 Отчет за сессию 25 мая 2026",
"text": "# 📅 Сессия: 25 мая 2026 г.\n\nСегодня мы провели масштабное обновление XFlow, интегрировав новые инструменты планирования, приоритезации, а также готовую интерактивную среду для вашего практического задания!\n\n**Ключевые блоки изменений:**\n1. 📋 Шаблон «Мои цели на год»\n2. ⚖️ Тест на истинность цели\n3. 📅 Диаграмма Ганта\n4. 📊 Матрица Эйзенхауэра\n5. 🧪 Сборка проекта Nuxt\n\n*Изучите подключенные узлы для деталей.*",
"style": {
"background": "#1e1b4b",
"border": "#fbbf24"
}
},
{
"id": "block-goals",
"type": "task",
"x": 550,
"y": 50,
"width": 320,
"height": 180,
"label": "📋 Шаблон целей на год",
"state": "completed",
"priority": "high",
"text": "## 📋 Шаблон «Мои цели на год»\n\n* **Тулбар:** Добавлена кнопка `📋 +Цели на Год` для автоматического развертывания карты из 5 целей.\n* **Структура:** Каждая цель пре-генерируется с шаблонами для ввода по методикам SMART и GROW (с полем для шагов и ресурсов).\n* **Связь с Гантом:** Все цели являются задачами и отображаются в таймлайне при заполнении дат.",
"style": {
"background": "#064e3b",
"border": "#10b981"
}
},
{
"id": "block-test",
"type": "task",
"x": 550,
"y": 250,
"width": 320,
"height": 180,
"label": "⚖️ Тест на истинность цели",
"state": "completed",
"priority": "high",
"text": "## ⚖️ Тест на истинность цели\n\n* **Сайдбар:** В свойства узла добавлен интерактивный опросник с 8 вопросами (шкала 1-10) для проверки мотивации по Заданию №2.\n* **Авторасчет:** Автоматически вычисляется сумма.\n* **Запись в узел:** Результат с подробными баллами записывается прямо в Markdown-текст узла по нажатию одной кнопки.",
"style": {
"background": "#0f172a",
"border": "#10b981"
}
},
{
"id": "block-gantt",
"type": "task",
"x": 550,
"y": 450,
"width": 320,
"height": 180,
"label": "📅 Диаграмма Ганта",
"state": "completed",
"priority": "high",
"text": "## 📅 Диаграмма Ганта\n\n* **Компонент:** Создан `GanttChartDrawer.vue` — выдвижной таймлайн снизу.\n* **Узлы задач:** Отслеживает все узлы типа `task` с заполненными датами.\n* **Фокус камеры:** Клик по задаче в списке Ганта плавно центрирует и приближает камеру Vue Flow на этот узел.",
"style": {
"background": "#1e293b",
"border": "#3b82f6"
}
},
{
"id": "block-eisen",
"type": "task",
"x": 550,
"y": 650,
"width": 320,
"height": 180,
"label": "📊 Матрица Эйзенхауэра",
"state": "completed",
"priority": "high",
"text": "## 📊 Матрица Эйзенхауэра\n\n* **2x2 Сетка:** Размещает четыре квадранта (Важно/Срочно, Важно/Несрочно, Неважно/Срочно, Неважно/Несрочно).\n* **ИИ коучинг:** Подключен бэкенд OpenRouter AI для автоматической сортировки списка задач пользователя по 4 квадрантам.",
"style": {
"background": "#451a03",
"border": "#f59e0b"
}
},
{
"id": "block-build",
"type": "task",
"x": 950,
"y": 350,
"width": 320,
"height": 180,
"label": "🧪 Сборка проекта",
"state": "completed",
"priority": "high",
"text": "## 🧪 Валидация сборки\n\n* **Команда:** `npm run build`\n* **Результат:** Успешно пройдено! Ошибок нет.\n* **Готовность:** 100% готовность к завтрашнему использованию на занятии.",
"style": {
"background": "#312e81",
"border": "#6366f1"
}
}
],
"edges": [
{
"id": "edge-goals",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-goals",
"toSide": "left"
},
{
"id": "edge-test",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-test",
"toSide": "left"
},
{
"id": "edge-gantt",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-gantt",
"toSide": "left"
},
{
"id": "edge-eisen",
"fromNode": "center",
"fromSide": "right",
"toNode": "block-eisen",
"toSide": "left"
},
{
"id": "edge-goals-build",
"fromNode": "block-goals",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
},
{
"id": "edge-test-build",
"fromNode": "block-test",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
},
{
"id": "edge-gantt-build",
"fromNode": "block-gantt",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
},
{
"id": "edge-eisen-build",
"fromNode": "block-eisen",
"fromSide": "right",
"toNode": "block-build",
"toSide": "left"
}
]
}
-112
View File
@@ -1,112 +0,0 @@
# Системный промпт для AI-ассистента проекта XFlow
## Текущая архитектура
Проект реализован на **Nuxt 3 PWA**.
### Стек
- **Frontend/Backend:** Nuxt 3 (Vue 3 + Nitro SSR)
- **Стили:** Tailwind CSS
- **PWA:** `@vite-pwa/nuxt` (Service Worker, манифест, оффлайн-кэш)
- **Аутентификация:** Firebase Auth (Google Sign-In), JWT на Nitro
- **Визуализация:** `@vue-flow/core` + `@vue-flow/background` + `@vue-flow/controls` + `@vue-flow/minimap`
- **Markdown:** `markdown-it` (рендеринг нод)
- **Push:** Web Push API + Notification API + вибро
- **База данных:** In-memory (заглушка на Nitro utils/db.ts), миграция на Postgres в планах
### Файловая структура
```
app/
app.vue # Корневой компонент
assets/css/main.css # Tailwind + кастомные стили для Vue Flow
components/
CanvasViewer.vue # Основной канвас (Vue Flow)
MarkdownNode.vue # Рендеринг markdown в нодах
AchievementOverlay.vue # Всплывающие ачивки
composables/
useAuth.ts # Firebase Google Sign-In + JWT decode
useCanvas.ts # Загрузка .canvas JSON, парсинг frontmatter
usePush.ts # Подписка/отправка Web Push
useAchievements.ts # Управление ачивками
layouts/
default.vue # Навигация + AchievementOverlay
middleware/
auth.ts # Проверка JWT (cookie xflow_token)
admin.ts # Проверка role === 'admin'
pages/
index.vue # Дашборд (статы задач и ачивок)
login.vue # Только Google Sign-In (Firebase)
canvas.vue # CanvasViewer страница
admin.vue # Панель админа (пуши, скрипты)
settings.vue # Настройки пушей и ачивок
plugins/
firebase.client.ts # Инициализация Firebase SDK (только клиент)
push-notifications.client.ts # Обработка пуш-событий
server/
api/auth/google.post.ts # Верификация Firebase ID token + выдача JWT
api/auth/validate.get.ts # Проверка JWT
api/canvas/[id].get.ts # Мок .canvas JSON
api/nodes/[id].put.ts # Обновление статуса ноды + автовыдача ачивок
api/nodes/index.get.ts # Список нод с RBAC (фильтр по assigned_to)
api/achievements/index.get.ts # Список ачивок пользователя
api/push/subscribe.post.ts # Сохранение подписки
api/push/send.post.ts # Отправка пуша (admin only)
utils/auth.ts # Генерация/верификация JWT (jose)
utils/db.ts # In-memory store (users, nodes, achievements, pushSubs)
public/
favicon.svg
pwa-*.svg # Иконки PWA (SVG заглушки)
```
### Ключевые бизнес-правила
1. **Авторизация:** Только Firebase Google Sign-In. Email/password удален. Cookie `xflow_token`.
2. **RBAC:** `role: 'admin'` дает доступ к `/admin`. Обычные пользователи видят только ноды с `assigned_to == свой email`.
3. **Canvas ноды:** Double-click переключает статус `pending -> in_progress -> completed`. При `completed` сервер автоматически выдает ачивку.
4. **Frontmatter:** Ноды могут содержать YAML frontmatter с полями `alarm`, `script_id`, `assigned_to`.
5. **Push:** Админ отправляет пуши через `/api/push/send`. Клиентский плагин слушает `PUSH_RECEIVED` и вибрирует.
6. **Gamification:** Ачивки выдаются автоматически при завершении нод. Отображаются через AchievementOverlay.
### Конфигурация (.env)
Обязательные переменные для Firebase:
```
NUXT_PUBLIC_FIREBASE_API_KEY=
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NUXT_PUBLIC_FIREBASE_PROJECT_ID=
NUXT_PUBLIC_FIREBASE_APP_ID=
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NUXT_FIREBASE_API_KEY= # Серверная верификация (тот же ключ)
NUXT_JWT_SECRET= # Секрет для подписи JWT
```
## Критически важное правило: документация в .md
**При ЛЮБЫХ изменениях архитектуры, API, бизнес-логики или структуры данных — обновляй соответствующие .md файлы.**
### Структура документации в проекте
- `docs/architecture/` — архитектурные решения, ADR
- `docs/api/` — контракты API, форматы данных
- `docs/features/` — описание функционала
- `README.md` — общее описание, setup, структура
### Правила обновления .md
1. **Если добавляешь новый composable/component/API route** — создай или обнови `.md`:
- Назначение
- Зависимости
- Публичный API (методы, параметры, типы)
- Пример использования
2. **Если меняешь формат данных** (.canvas JSON, frontmatter схема):
- Обнови документацию формата
- Укажи версию схемы
- Опиши миграцию если нужна
3. **Если добавляешь фичу**:
- Создай `docs/features/feature-name.md`
- Опиши user flow, edge cases
## Инструкция для сессии
1. Перед изменениями — прочитай соответствующий `.md` файл и код
2. После изменений — обнови `.md` или создай новый
3. Не ломай существующие форматы данных без явного запроса
4. При добавлении зависимостей — проверяй совместимость с Nuxt 3 / Vue 3 / TypeScript
5. Все Dart/Flutter файлы удалены — не возвращай их
6. Cookie называется `xflow_token`
7. Проект называется **XFlow**
-115
View File
@@ -1,115 +0,0 @@
# ==========================================
# Stage 1: builder
# ==========================================
FROM node:22-slim AS builder
ENV HUSKY=0
ENV NODE_ENV=development
ENV NODE_OPTIONS="--max-old-space-size=4096"
WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends openssl git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
ARG REDIS_URL="redis://redis:6379"
ARG JWT_SECRET="super-secret-jwt-key"
ARG OPEN_ROUTER_API_KEY=""
ARG NUXT_OPEN_ROUTER_API_KEY=""
ARG NUXT_QWEN_API_KEY=""
ARG DEEPSEEK_API_KEY=""
ARG FIREBASE_API_KEY="AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck"
ARG FIREBASE_AUTH_DOMAIN="xflow-d8dd5.firebaseapp.com"
ARG FIREBASE_PROJECT_ID="xflow-d8dd5"
ARG FIREBASE_STORAGE_BUCKET="xflow-d8dd5.firebasestorage.app"
ARG FIREBASE_MESSAGING_SENDER_ID="944408960366"
ARG FIREBASE_APP_ID="1:944408960366:web:4d2cd393e85a0ca9b4357f"
ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \
DATABASE_URL=${DATABASE_URL} \
NUXT_DATABASE_URL=${DATABASE_URL} \
REDIS_URL=${REDIS_URL} \
NUXT_REDIS_URL=${REDIS_URL} \
JWT_SECRET=${JWT_SECRET} \
NUXT_JWT_SECRET=${JWT_SECRET} \
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
NUXT_OPEN_ROUTER_API_KEY=${NUXT_OPEN_ROUTER_API_KEY} \
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} \
TZ=${TZ}
# Копируем package.json, prisma схемы и конфиг
COPY apps/web/package*.json ./
COPY apps/web/prisma ./prisma
COPY apps/web/prisma.config.ts ./
RUN npm ci --include=optional && npx prisma generate
# Копируем исходный код apps/web
COPY apps/web ./
RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=development npm run build
# ==========================================
# Stage 2: runner
# ==========================================
FROM node:22-slim AS runner
ENV HUSKY=0
ENV NODE_ENV=production
WORKDIR /src
RUN apt-get update && apt-get install -y --no-install-recommends openssl tzdata ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ARG SITE_URL=""
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
ARG REDIS_URL="redis://redis:6379"
ARG JWT_SECRET="super-secret-jwt-key"
ARG TZ="Asia/Almaty"
ENV SITE_URL=${SITE_URL} \
DATABASE_URL=${DATABASE_URL} \
NUXT_DATABASE_URL=${DATABASE_URL} \
REDIS_URL=${REDIS_URL} \
NUXT_REDIS_URL=${REDIS_URL} \
JWT_SECRET=${JWT_SECRET} \
NUXT_JWT_SECRET=${JWT_SECRET} \
TZ=${TZ}
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
ENV HOST=0.0.0.0
ENV PORT=3000
ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000
ENV NITRO_HOST=0.0.0.0
ENV NITRO_PORT=3000
EXPOSE 3000
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && 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)"
+86 -74
View File
@@ -1,79 +1,91 @@
# 🚀 XFlow — Nuxt 3 PWA # Avtex — Smart Sleep & Motion Engine for Home Assistant
Визуальный менеджер задач на базе Obsidian Canvas. Работает офлайн, поддерживает уведомления и геймификацию. <p align="center">
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
</p>
## 📱 Возможности <p align="center">
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
- ✅ **Obsidian Canvas** — визуальное управление задачами через граф <i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
- ✅ **Markdown ноды** — рендеринг содержимого с подсветкой синтаксиса </p>
- ✅ **PWA + Офлайн** — Service Worker, кэширование, установка на homescreen
- ✅ **JWT Auth** — регистрация, вход, ролевая модель (RBAC)
- ✅ **Web Push** — пуш-уведомления даже при закрытом приложении
- ✅ **Будильники** — локальные вибро-уведомления
- ✅ **Ачивки** — геймификация выполнения задач
- ✅ **Админка** — управление скриптами и рассылкой пушей
## 🛠 Технологический стек
| Компонент | Технология |
|-----------|------------|
| Framework | Nuxt 3 (Vue 3) |
| Styling | Tailwind CSS |
| Canvas | Vue Flow |
| Markdown | markdown-it |
| PWA | @vite-pwa/nuxt |
| Backend API | Nitro (встроен в Nuxt) |
| Auth | JWT |
## 🚀 Быстрый старт
### 1. Установка зависимостей
```bash
npm install
```
### 2. Настройка окружения
```bash
cp .env.example .env
# Отредактируй .env (JWT_SECRET, Postgres и т.д.)
```
### 3. Запуск dev-сервера
```bash
npm run dev
```
### 4. Сборка для production
```bash
npm run build
npm run preview
```
## 📁 Структура проекта
```
app/
├── components/ # Vue компоненты (CanvasViewer, MarkdownNode, ...)
├── composables/ # Логика (useAuth, useCanvas, usePush, ...)
├── layouts/ # Layout'ы страниц
├── middleware/ # Route guards (auth, admin)
├── pages/ # Страницы (index, login, canvas, admin)
├── plugins/ # Client plugins (push notifications)
├── public/ # Статические ассеты (иконки PWA)
└── server/ # Nitro API routes
server/ # Legacy Express API (сохранён)
```
## � Доступ
- **Админ** — `admin@xflow.local` / `admin123`
- **Регистрация** — доступна на `/login`
## 📄 Лицензия
Internal use only
--- ---
**XFlow Team** 🚀 ## 🌟 Ключевые возможности
* **Мгновенная фиксация пробуждения (0% расхода батареи)**:
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом.
* **Детекция сна при ненормированном графике**:
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00).
* **Полная совместимость с Home Assistant (HAC)**:
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`.
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token.
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне.
* **Устойчивость к убийцам процессов OnePlus**:
* Реализован как Foreground Service с типом `specialUse`.
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
* Jetpack Compose & Material 3.
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
* Интерактивная лента аудита событий сенсоров в реальном времени.
---
## 🚀 Быстрый старт
### 1. Сборка APK
Для сборки отладочной версии:
```bash
./gradlew assembleDebug
```
Для сборки оптимизированного подписанного Release APK:
```bash
./gradlew assembleRelease
```
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
### 2. Установка через ADB
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
---
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации)
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
```yaml
alias: "Avtex: Автоматизация сна и пробуждения"
trigger:
- platform: webhook
webhook_id: avtex_sleep
action:
- choose:
- conditions: "{{ trigger.json.state == 'asleep' }}"
sequence:
- service: light.turn_off
target:
entity_id: all
- service: climate.set_preset_mode
data:
preset_mode: sleep
target:
entity_id: climate.bedroom
- conditions: "{{ trigger.json.state == 'awake' }}"
sequence:
- service: scene.turn_on
target:
entity_id: scene.morning_lights
```
---
## 🛠 Стек технологий
* **Язык**: Kotlin 2.3+
* **UI**: Jetpack Compose, Material 3, Navigation
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
* **Сеть**: OkHttp 4.12
* **Хранилище настроек**: Jetpack DataStore Preferences
@@ -11,8 +11,8 @@ android {
applicationId = "com.example.avtex" applicationId = "com.example.avtex"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 5 versionCode = 1
versionName = "1.4" 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 {
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".AvtexApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Avtex">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".engine.SleepTrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Detects sleep and wake states using motion sensors and Sleep API for Home Assistant smart home automations." />
</service>
<receiver
android:name=".engine.SleepReceiver"
android:exported="true"
android:permission="com.google.android.gms.permission.ACTIVITY_RECOGNITION">
<intent-filter>
<action android:name="com.example.avtex.ACTION_SLEEP_API_EVENT" />
</intent-filter>
</receiver>
<receiver
android:name=".engine.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -6,26 +6,16 @@ import android.app.NotificationManager
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import com.example.avtex.data.SettingsRepository import com.example.avtex.data.SettingsRepository
import com.example.avtex.notes.NotesRepository
import com.example.avtex.notes.XFlowSyncClient
class AvtexApplication : Application() { class AvtexApplication : Application() {
lateinit var settingsRepository: SettingsRepository lateinit var settingsRepository: SettingsRepository
private set private set
lateinit var notesRepository: NotesRepository
private set
lateinit var xflowSyncClient: XFlowSyncClient
private set
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
settingsRepository = SettingsRepository(this) settingsRepository = SettingsRepository(this)
notesRepository = NotesRepository(this)
xflowSyncClient = XFlowSyncClient(this)
createNotificationChannels() createNotificationChannels()
} }
@@ -33,10 +23,10 @@ class AvtexApplication : Application() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID_TRACKING, CHANNEL_ID_TRACKING,
"Мониторинг XFlow", "Мониторинг сна Avtex",
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_LOW
).apply { ).apply {
description = "Отображает текущий статус мониторинга и синхронизации" description = "Отображает текущий статус отслеживания сна и активности"
setShowBadge(false) setShowBadge(false)
} }
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
@@ -45,7 +35,7 @@ class AvtexApplication : Application() {
} }
companion object { companion object {
const val CHANNEL_ID_TRACKING = "xflow_tracking_channel" const val CHANNEL_ID_TRACKING = "avtex_sleep_tracking_channel"
lateinit var instance: AvtexApplication lateinit var instance: AvtexApplication
private set private set
} }
@@ -0,0 +1,22 @@
package com.example.avtex
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.avtex.theme.AvtexTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AvtexTheme { Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { MainNavigation() } }
}
}
}
@@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding 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.filled.Info
import androidx.compose.material.icons.filled.Hub
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
@@ -29,9 +28,9 @@ import com.example.avtex.theme.NeonViolet
import com.example.avtex.theme.ObsidianBg import com.example.avtex.theme.ObsidianBg
import com.example.avtex.theme.ObsidianSurfaceElevated import com.example.avtex.theme.ObsidianSurfaceElevated
import com.example.avtex.theme.TextMuted import com.example.avtex.theme.TextMuted
import com.example.avtex.theme.TextPrimary
import com.example.avtex.ui.DashboardScreen 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 import com.example.avtex.ui.SettingsScreen
@Composable @Composable
@@ -58,10 +57,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
) )
@@ -71,10 +70,10 @@ fun MainNavigation() {
NavigationBarItem( NavigationBarItem(
selected = selectedTab == 1, selected = selectedTab == 1,
onClick = { selectedTab = 1 }, onClick = { selectedTab = 1 },
icon = { Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = "Списки") }, icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
label = { label = {
Text( Text(
"Списки", "Настройки",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal
) )
@@ -84,37 +83,23 @@ 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.Info, contentDescription = "OnePlus") },
label = { label = {
Text( Text(
"Сон", "OnePlus",
fontSize = 11.sp, fontSize = 11.sp,
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
) )
}, },
colors = navColors colors = navColors
) )
NavigationBarItem(
selected = selectedTab == 3,
onClick = { selectedTab = 3 },
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
label = {
Text(
"Настройки",
fontSize = 11.sp,
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
)
},
colors = navColors
)
} }
} }
) { 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 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding)) 2 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
3 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
} }
} }
} }
@@ -18,14 +18,10 @@ class SleepReceiver : BroadcastReceiver() {
val motion = event.motion val motion = event.motion
val light = event.light val light = event.light
Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light") Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light")
val now = System.currentTimeMillis() AvtexApplication.instance.settingsRepository.addLog(
if (now - lastClassifyLogTimestamp >= MIN_CLASSIFY_LOG_INTERVAL_MS) { tag = "SleepAPI",
lastClassifyLogTimestamp = now message = "Классификация: уверенность $confidence%, движение $motion/6, свет $light/6"
AvtexApplication.instance.settingsRepository.addLog( )
tag = "SleepAPI",
message = "Классификация (раз в час): уверенность $confidence%, движение $motion/6, свет $light/6"
)
}
SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light) SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light)
} }
} }
@@ -47,8 +43,5 @@ class SleepReceiver : BroadcastReceiver() {
companion object { companion object {
private const val TAG = "SleepReceiver" private const val TAG = "SleepReceiver"
const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT" const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT"
private const val MIN_CLASSIFY_LOG_INTERVAL_MS = 60 * 60 * 1000L // Минимум 1 раз в час
@Volatile
private var lastClassifyLogTimestamp: Long = 0L
} }
} }
@@ -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.

After

Width:  |  Height:  |  Size: 266 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.

After

Width:  |  Height:  |  Size: 3.9 KiB

Before

Width:  |  Height:  |  Size: 688 B

After

Width:  |  Height:  |  Size: 688 B

Before

Width:  |  Height:  |  Size: 866 B

After

Width:  |  Height:  |  Size: 866 B

Before

Width:  |  Height:  |  Size: 434 B

After

Width:  |  Height:  |  Size: 434 B

Before

Width:  |  Height:  |  Size: 570 B

After

Width:  |  Height:  |  Size: 570 B

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Avtex</string>
</resources>
-158
View File
@@ -1,158 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
function loadConfig() {
if (fs.existsSync(CONFIG_FILE)) {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
} catch {
// fallback
}
}
return {
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
userId: process.env.XFLOW_USER_ID || 'default-user'
};
}
function saveConfig(config) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
}
async function addNote(title, listTitle = 'Фильмы на просмотр', category = 'movies') {
const config = loadConfig();
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title,
listTitle,
category,
ownerId: config.userId
})
});
const data = await res.json();
if (res.ok && data.success) {
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
} else {
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
}
} catch (err) {
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
}
}
async function listNotes(category) {
const config = loadConfig();
const query = category ? `?category=${encodeURIComponent(category)}` : '';
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
try {
const res = await fetch(url);
const data = await res.json();
if (res.ok && data.success) {
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
for (const list of data.lists) {
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
for (const item of list.items || []) {
const check = item.isCompleted ? '☑' : '☐';
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
}
}
console.log('');
} else {
console.error(`Ошибка:`, data.message);
}
} catch (err) {
console.error(`Ошибка связи с сервером: ${err.message}`);
}
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
console.log(`
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
Использование:
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
xflow list [категория] Показать списки и элементы
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
Примеры:
xflow movie "Интерстеллар"
xflow add "Купить молоко" --list "Покупки"
xflow list
`);
return;
}
if (command === 'movie') {
const title = args.slice(1).join(' ');
if (!title) {
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
return;
}
await addNote(title, 'Фильмы на просмотр', 'movies');
return;
}
if (command === 'add') {
const title = args[1];
let listTitle = 'Общий список';
let category = 'general';
for (let i = 2; i < args.length; i++) {
if (args[i] === '--list' && args[i + 1]) {
listTitle = args[i + 1];
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
}
}
if (!title) {
console.error('Укажите текст заметки: xflow add "Заметка"');
return;
}
await addNote(title, listTitle, category);
return;
}
if (command === 'list') {
const category = args[1];
await listNotes(category);
return;
}
if (command === 'config') {
const config = loadConfig();
for (let i = 1; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
}
saveConfig(config);
return;
}
console.error(`Неизвестная команда: ${command}. Используйте --help`);
}
main().catch(console.error);
-22
View File
@@ -1,22 +0,0 @@
{
"name": "@xflow/desktop-cli",
"version": "1.0.0",
"description": "XFlow Windows/Linux Native CLI and Agent Bridge",
"type": "module",
"bin": {
"xflow": "./dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"dependencies": {
"commander": "^12.0.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
interface XFlowConfig {
serverUrl: string;
userId: string;
apiKey?: string;
}
function loadConfig(): XFlowConfig {
if (fs.existsSync(CONFIG_FILE)) {
try {
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
} catch {
// fallback
}
}
return {
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
userId: process.env.XFLOW_USER_ID || 'default-user'
};
}
function saveConfig(config: XFlowConfig) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
}
async function addNote(title: string, listTitle: string = 'Фильмы', category: string = 'movies') {
const config = loadConfig();
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
title,
listTitle,
category,
ownerId: config.userId
})
});
const data = await res.json() as any;
if (res.ok && data.success) {
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
} else {
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
}
} catch (err: any) {
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
}
}
async function listNotes(category?: string) {
const config = loadConfig();
const query = category ? `?category=${encodeURIComponent(category)}` : '';
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
try {
const res = await fetch(url);
const data = await res.json() as any;
if (res.ok && data.success) {
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
for (const list of data.lists) {
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
for (const item of list.items || []) {
const check = item.isCompleted ? '☑' : '☐';
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
}
}
console.log('');
} else {
console.error(`Ошибка:`, data.message);
}
} catch (err: any) {
console.error(`Ошибка связи с сервером: ${err.message}`);
}
}
async function main() {
const args = process.argv.slice(2);
const command = args[0];
if (!command || command === '--help' || command === '-h') {
console.log(`
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
Использование:
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
xflow list [категория] Показать списки и элементы
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
Примеры:
xflow movie "Интерстеллар"
xflow add "Купить молоко" --list "Покупки"
xflow list
`);
return;
}
if (command === 'movie') {
const title = args.slice(1).join(' ');
if (!title) {
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
return;
}
await addNote(title, 'Фильмы на просмотр', 'movies');
return;
}
if (command === 'add') {
const title = args[1];
let listTitle = 'Общий список';
let category = 'general';
for (let i = 2; i < args.length; i++) {
if (args[i] === '--list' && args[i + 1]) {
listTitle = args[i + 1];
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
}
}
if (!title) {
console.error('Укажите текст заметки: xflow add "Заметка"');
return;
}
await addNote(title, listTitle, category);
return;
}
if (command === 'list') {
const category = args[1];
await listNotes(category);
return;
}
if (command === 'config') {
const config = loadConfig();
for (let i = 1; i < args.length; i++) {
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
}
saveConfig(config);
return;
}
console.error(`Неизвестная команда: ${command}. Используйте --help`);
}
main().catch(console.error);
-13
View File
@@ -1,13 +0,0 @@
*.iml
.gradle
/local.properties
local.properties
/.idea/
.idea/
.DS_Store
/build
build/
app/build/
/captures
.externalNativeBuild
.cxx
-91
View File
@@ -1,91 +0,0 @@
# Avtex — Smart Sleep & Motion Engine for Home Assistant
<p align="center">
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
</p>
<p align="center">
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
<i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
</p>
---
## 🌟 Ключевые возможности
* **Мгновенная фиксация пробуждения (0% расхода батареи)**:
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом.
* **Детекция сна при ненормированном графике**:
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00).
* **Полная совместимость с Home Assistant (HAC)**:
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`.
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token.
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне.
* **Устойчивость к убийцам процессов OnePlus**:
* Реализован как Foreground Service с типом `specialUse`.
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
* Jetpack Compose & Material 3.
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
* Интерактивная лента аудита событий сенсоров в реальном времени.
---
## 🚀 Быстрый старт
### 1. Сборка APK
Для сборки отладочной версии:
```bash
./gradlew assembleDebug
```
Для сборки оптимизированного подписанного Release APK:
```bash
./gradlew assembleRelease
```
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
### 2. Установка через ADB
```bash
adb install -r app/build/outputs/apk/release/app-release.apk
```
---
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации)
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
```yaml
alias: "Avtex: Автоматизация сна и пробуждения"
trigger:
- platform: webhook
webhook_id: avtex_sleep
action:
- choose:
- conditions: "{{ trigger.json.state == 'asleep' }}"
sequence:
- service: light.turn_off
target:
entity_id: all
- service: climate.set_preset_mode
data:
preset_mode: sleep
target:
entity_id: climate.bedroom
- conditions: "{{ trigger.json.state == 'awake' }}"
sequence:
- service: scene.turn_on
target:
entity_id: scene.morning_lights
```
---
## 🛠 Стек технологий
* **Язык**: Kotlin 2.3+
* **UI**: Jetpack Compose, Material 3, Navigation
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
* **Сеть**: OkHttp 4.12
* **Хранилище настроек**: Jetpack DataStore Preferences
@@ -1,112 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:name=".AvtexApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher_avtex"
android:label="@string/app_name"
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: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 & 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" />
</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>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<service
android:name=".engine.SleepTrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Detects sleep and wake states using motion sensors and Sleep API for Home Assistant smart home automations." />
</service>
<receiver
android:name=".engine.SleepReceiver"
android:exported="true"
android:permission="com.google.android.gms.permission.ACTIVITY_RECOGNITION">
<intent-filter>
<action android:name="com.example.avtex.ACTION_SLEEP_API_EVENT" />
</intent-filter>
</receiver>
<receiver
android:name=".engine.BootReceiver"
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>
</receiver>
</application>
</manifest>
@@ -1,290 +0,0 @@
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)
enableEdgeToEdge()
setContent {
AvtexTheme {
CompositionLocalProvider(
LocalVoiceInputLauncher provides { launchVoiceInput() },
LocalRoleNotesRequester provides { requestDefaultNotesRole() }
) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
MainNavigation()
}
}
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIncomingIntent(intent)
}
fun launchVoiceInput() {
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ru-RU")
putExtra(RecognizerIntent.EXTRA_PROMPT, "Скажите заметку, задачу или фильм...")
}
try {
speechRecognizerLauncher.launch(intent)
} catch (e: Exception) {
Log.e(TAG, "Speech recognizer not available", e)
Toast.makeText(this, "Голосовой ввод недоступен на этом устройстве", Toast.LENGTH_SHORT).show()
}
}
fun requestDefaultNotesRole() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
try {
val roleManager = getSystemService(RoleManager::class.java)
if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_NOTES)) {
val roleIntent = roleManager.createRequestRoleIntent(RoleManager.ROLE_NOTES)
roleRequestLauncher.launch(roleIntent)
return
}
} catch (e: Exception) {
Log.e(TAG, "Failed to request ROLE_NOTES", e)
}
}
try {
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
startActivity(settingsIntent)
} catch (e: Exception) {
Toast.makeText(this, "Откройте: Настройки -> Приложения -> Приложения по умолчанию", Toast.LENGTH_LONG).show()
}
}
private fun handleIncomingIntent(intent: Intent?) {
if (intent == null) return
val action = intent.action
Log.d(TAG, "Incoming intent action: $action, data: ${intent.data}")
// 1. Google Assistant / Android System Note Action (android.intent.action.CREATE_NOTE)
if (action == Intent.ACTION_CREATE_NOTE) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
?: intent.getStringExtra("android.intent.extra.TEXT")
?: intent.getStringExtra(Intent.EXTRA_TITLE)
val title = intent.getStringExtra(Intent.EXTRA_TITLE)
if (!text.isNullOrBlank()) {
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
routeAndSaveNote(fullText, preferredList = null)
return
} else {
launchVoiceInput()
return
}
}
// 2. Google Voice Actions (com.google.android.gms.actions.CREATE_NOTE)
if (action == "com.google.android.gms.actions.CREATE_NOTE") {
val text = intent.getStringExtra("com.google.android.gms.actions.extra.TEXT")
?: intent.getStringExtra(Intent.EXTRA_TEXT)
val name = intent.getStringExtra("com.google.android.gms.actions.extra.NAME")
if (!text.isNullOrBlank()) {
val fullText = if (!name.isNullOrBlank() && name != text) "$name: $text" else text
routeAndSaveNote(fullText, preferredList = null)
return
} else {
launchVoiceInput()
return
}
}
// 3. Android Text Selection (ACTION_PROCESS_TEXT)
if (action == Intent.ACTION_PROCESS_TEXT) {
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
if (!text.isNullOrBlank()) {
routeAndSaveNote(text, preferredList = null)
return
}
}
// 4. Android Share Sheet Action (android.intent.action.SEND with text/plain)
if (action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
if (!sharedText.isNullOrBlank()) {
val fullText = if (!subject.isNullOrBlank()) "$subject — $sharedText" else sharedText
routeAndSaveNote(fullText, preferredList = null)
return
}
}
// 5. Deep Link Action (xflow://...)
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()) {
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 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
repo.addNoteItem(
title = cleanText,
listTitle = targetList,
category = category
)
Toast.makeText(
this@MainActivity,
"✅ 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"
}
}
@@ -1,24 +0,0 @@
package com.example.avtex.notes
data class NoteItem(
val id: String,
val listId: String,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val status: String = "todo", // "want_to_watch", "watched", "todo", "completed"
val priority: String = "normal",
val metadata: String = "{}",
val createdAt: Long = System.currentTimeMillis()
)
data class NoteList(
val id: String,
val title: String,
val category: String = "general", // "movies", "shopping", "tasks", "general"
val description: String = "",
val color: String? = null,
val icon: String? = null,
val items: List<NoteItem> = emptyList(),
val createdAt: Long = System.currentTimeMillis()
)
@@ -1,211 +0,0 @@
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
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())
val listsFlow: StateFlow<List<NoteList>> = _listsFlow.asStateFlow()
init {
reload()
}
fun reload() {
scope.launch {
val lists = loadListsFromDb()
_listsFlow.value = lists
}
}
private suspend fun loadListsFromDb(): List<NoteList> = withContext(Dispatchers.IO) {
val db = dbHelper.readableDatabase
val lists = mutableListOf<NoteList>()
val cursorLists = db.query(
XFlowDatabaseHelper.TABLE_LISTS,
null, null, null, null, null, "created_at DESC"
)
cursorLists.use { cLists ->
while (cLists.moveToNext()) {
val listId = cLists.getString(cLists.getColumnIndexOrThrow("id"))
val title = cLists.getString(cLists.getColumnIndexOrThrow("title"))
val category = cLists.getString(cLists.getColumnIndexOrThrow("category"))
val description = cLists.getString(cLists.getColumnIndexOrThrow("description")) ?: ""
val color = cLists.getString(cLists.getColumnIndexOrThrow("color"))
val icon = cLists.getString(cLists.getColumnIndexOrThrow("icon"))
val createdAt = cLists.getLong(cLists.getColumnIndexOrThrow("created_at"))
// Загружаем элементы списка
val items = mutableListOf<NoteItem>()
val cursorItems = db.query(
XFlowDatabaseHelper.TABLE_ITEMS,
null, "list_id = ?", arrayOf(listId), null, null, "created_at DESC"
)
cursorItems.use { cItems ->
while (cItems.moveToNext()) {
val itemId = cItems.getString(cItems.getColumnIndexOrThrow("id"))
val itemTitle = cItems.getString(cItems.getColumnIndexOrThrow("title"))
val itemDesc = cItems.getString(cItems.getColumnIndexOrThrow("description")) ?: ""
val isCompleted = cItems.getInt(cItems.getColumnIndexOrThrow("is_completed")) == 1
val status = cItems.getString(cItems.getColumnIndexOrThrow("status"))
val priority = cItems.getString(cItems.getColumnIndexOrThrow("priority"))
val metadata = cItems.getString(cItems.getColumnIndexOrThrow("metadata")) ?: "{}"
val itemCreatedAt = cItems.getLong(cItems.getColumnIndexOrThrow("created_at"))
items.add(
NoteItem(
id = itemId,
listId = listId,
title = itemTitle,
description = itemDesc,
isCompleted = isCompleted,
status = status,
priority = priority,
metadata = metadata,
createdAt = itemCreatedAt
)
)
}
}
lists.add(
NoteList(
id = listId,
title = title,
category = category,
description = description,
color = color,
icon = icon,
items = items,
createdAt = createdAt
)
)
}
}
lists
}
suspend fun addNoteItem(
title: String,
listTitle: String = "Фильмы на просмотр",
category: String = "movies",
description: String = ""
): NoteItem = withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
// Ищем список по названию
var targetListId: String? = null
val c = db.query(
XFlowDatabaseHelper.TABLE_LISTS,
arrayOf("id"), "LOWER(title) = LOWER(?)", arrayOf(listTitle.trim()),
null, null, null
)
c.use {
if (it.moveToFirst()) {
targetListId = it.getString(0)
}
}
// Если список не найден, создаем его
if (targetListId == null) {
val newListId = "list-" + UUID.randomUUID().toString()
val cvList = ContentValues().apply {
put("id", newListId)
put("title", listTitle.trim())
put("category", category)
put("description", "Список $listTitle")
put("created_at", System.currentTimeMillis())
}
db.insert(XFlowDatabaseHelper.TABLE_LISTS, null, cvList)
targetListId = newListId
}
val newItemId = "item-" + UUID.randomUUID().toString()
val now = System.currentTimeMillis()
val cvItem = ContentValues().apply {
put("id", newItemId)
put("list_id", targetListId)
put("title", title.trim())
put("description", description)
put("is_completed", 0)
put("status", if (category == "movies") "want_to_watch" else "todo")
put("priority", "normal")
put("metadata", "{}")
put("created_at", now)
}
db.insert(XFlowDatabaseHelper.TABLE_ITEMS, null, cvItem)
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,
title = title.trim(),
description = description,
isCompleted = false,
status = if (category == "movies") "want_to_watch" else "todo",
createdAt = now
)
}
suspend fun toggleItem(item: NoteItem) = withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
val newCompleted = !item.isCompleted
val newStatus = if (newCompleted) {
if (item.status == "want_to_watch") "watched" else "completed"
} else {
if (item.status == "watched") "want_to_watch" else "todo"
}
val cv = ContentValues().apply {
put("is_completed", if (newCompleted) 1 else 0)
put("status", newStatus)
}
db.update(XFlowDatabaseHelper.TABLE_ITEMS, cv, "id = ?", arrayOf(item.id))
reload()
}
suspend fun deleteItem(itemId: String) = withContext(Dispatchers.IO) {
val db = dbHelper.writableDatabase
db.delete(XFlowDatabaseHelper.TABLE_ITEMS, "id = ?", arrayOf(itemId))
reload()
}
}
@@ -1,72 +0,0 @@
package com.example.avtex.notes
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
class XFlowDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("""
CREATE TABLE $TABLE_LISTS (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
category TEXT NOT NULL,
description TEXT,
color TEXT,
icon TEXT,
created_at INTEGER NOT NULL
)
""".trimIndent())
db.execSQL("""
CREATE TABLE $TABLE_ITEMS (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
is_completed INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
priority TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY(list_id) REFERENCES $TABLE_LISTS(id) ON DELETE CASCADE
)
""".trimIndent())
// Создаем дефолтные списки для удобства пользователя
val now = System.currentTimeMillis()
val cvMovies = ContentValues().apply {
put("id", "list-movies-default")
put("title", "Фильмы на просмотр")
put("category", "movies")
put("description", "Список фильмов и сериалов")
put("created_at", now)
}
db.insert(TABLE_LISTS, null, cvMovies)
val cvShopping = ContentValues().apply {
put("id", "list-shopping-default")
put("title", "Покупки")
put("category", "shopping")
put("description", "Список покупок")
put("created_at", now)
}
db.insert(TABLE_LISTS, null, cvShopping)
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_ITEMS")
db.execSQL("DROP TABLE IF EXISTS $TABLE_LISTS")
onCreate(db)
}
companion object {
private const val DATABASE_NAME = "xflow_notes.db"
private const val DATABASE_VERSION = 1
const val TABLE_LISTS = "lists"
const val TABLE_ITEMS = "items"
}
}
@@ -1,65 +0,0 @@
package com.example.avtex.notes
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class XFlowSyncClient(private val context: Context) {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
suspend fun syncItemToServer(
serverUrl: String,
title: String,
listTitle: String,
category: String,
ownerId: String = "default-user"
): Result<String> = withContext(Dispatchers.IO) {
if (serverUrl.isBlank()) {
return@withContext Result.failure(IllegalArgumentException("URL сервера XFlow не указан"))
}
try {
val endpoint = "${serverUrl.trimEnd('/')}/api/v1/lists/items"
val json = JSONObject().apply {
put("title", title)
put("listTitle", listTitle)
put("category", category)
put("ownerId", ownerId)
}
val request = Request.Builder()
.url(endpoint)
.post(json.toString().toRequestBody(jsonMediaType))
.build()
val response = httpClient.newCall(request).execute()
response.use {
if (it.isSuccessful) {
Result.success("Синхронизировано с XFlow")
} else {
Result.failure(Exception("HTTP ${it.code}: ${it.message}"))
}
}
} catch (e: Exception) {
Log.e(TAG, "Sync to XFlow server failed", e)
Result.failure(e)
}
}
companion object {
private const val TAG = "XFlowSyncClient"
}
}
@@ -1,780 +0,0 @@
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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Info
import com.example.avtex.LocalVoiceInputLauncher
import com.example.avtex.LocalRoleNotesRequester
import androidx.compose.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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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.EmeraldMint
import com.example.avtex.theme.GlassBorder
import com.example.avtex.theme.NeonViolet
import com.example.avtex.theme.ObsidianBg
import com.example.avtex.theme.ObsidianSurface
import com.example.avtex.theme.ObsidianSurfaceElevated
import com.example.avtex.theme.ObsidianSurfaceHigh
import com.example.avtex.theme.SolarAmber
import com.example.avtex.theme.TextMuted
import com.example.avtex.theme.TextPrimary
import com.example.avtex.theme.TextSecondary
import kotlinx.coroutines.launch
@Composable
fun ListsScreen(modifier: Modifier = Modifier) {
val context = LocalContext.current
val repository = remember { AvtexApplication.instance.notesRepository }
val lists by repository.listsFlow.collectAsState()
val coroutineScope = rememberCoroutineScope()
var selectedCategory by remember { mutableStateOf("all") }
var newItemText by remember { mutableStateOf("") }
var showAssistantTester by remember { mutableStateOf(false) }
val voiceLauncher = LocalVoiceInputLauncher.current
val filteredLists = remember(lists, selectedCategory) {
if (selectedCategory == "all") lists
else lists.filter { it.category == selectedCategory }
}
LazyColumn(
modifier = modifier
.fillMaxSize()
.background(ObsidianBg),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
// 1. Header
item {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "Списки и Заметки",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = TextPrimary
)
Text(
text = "XFlow синхронизация • Голосовой ввод Ассистента",
fontSize = 12.sp,
color = TextMuted
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
// Direct Voice Button
Button(
onClick = { voiceLauncher?.invoke() },
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Голос",
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
color = Color.White
)
}
// 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
)
}
}
}
}
}
// 2. Interactive Assistant Simulator Card
item {
AnimatedVisibility(visible = showAssistantTester) {
AssistantTesterCard(context = context)
}
}
// 3. Category Filter Chips
item {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CategoryChip(
label = "Все",
selected = selectedCategory == "all",
onClick = { selectedCategory = "all" }
)
CategoryChip(
label = "Фильмы 🎬",
selected = selectedCategory == "movies",
onClick = { selectedCategory = "movies" }
)
CategoryChip(
label = "Покупки 🛒",
selected = selectedCategory == "shopping",
onClick = { selectedCategory = "shopping" }
)
CategoryChip(
label = "Задачи 📋",
selected = selectedCategory == "tasks",
onClick = { selectedCategory = "tasks" }
)
}
}
// 4. Manual Quick Add Box
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = newItemText,
onValueChange = { newItemText = it },
placeholder = {
Text(
if (selectedCategory == "movies") "Название фильма на просмотр..."
else if (selectedCategory == "shopping") "Купить..."
else "Новая запись или заметка...",
fontSize = 13.sp,
color = TextMuted
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NeonViolet,
unfocusedBorderColor = GlassBorder,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
cursorColor = NeonViolet
),
singleLine = true,
modifier = Modifier.weight(1f),
shape = RoundedCornerShape(12.dp)
)
Button(
onClick = {
if (newItemText.isNotBlank()) {
coroutineScope.launch {
val targetCategory = if (selectedCategory == "all") "movies" else selectedCategory
val listTitle = when (targetCategory) {
"movies" -> "Фильмы на просмотр"
"shopping" -> "Покупки"
"tasks" -> "Задачи"
else -> "Быстрые заметки"
}
repository.addNoteItem(
title = newItemText.trim(),
listTitle = listTitle,
category = targetCategory
)
newItemText = ""
}
}
},
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
shape = RoundedCornerShape(12.dp),
modifier = Modifier.height(52.dp)
) {
Icon(Icons.Default.Add, contentDescription = "Добавить", tint = Color.White)
}
}
}
}
// 5. Lists and Items
if (filteredLists.isEmpty()) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(ObsidianSurface)
.padding(vertical = 40.dp, horizontal = 20.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Нет записей в выбранной категории",
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = TextSecondary
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
fontSize = 12.sp,
color = TextMuted
)
}
}
}
} else {
items(filteredLists, key = { it.id }) { list ->
ListCard(
list = list,
onToggleItem = { item ->
coroutineScope.launch { repository.toggleItem(item) }
},
onDeleteItem = { item ->
coroutineScope.launch { repository.deleteItem(item.id) }
}
)
}
}
}
}
@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,
onToggleItem: (NoteItem) -> Unit,
onDeleteItem: (NoteItem) -> Unit
) {
val accentColor = when (list.category) {
"movies" -> CyanAccent
"shopping" -> EmeraldMint
"tasks" -> SolarAmber
else -> NeonViolet
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(18.dp))
.background(ObsidianSurfaceElevated)
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp))
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
// List Title Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(accentColor)
)
Text(
text = list.title,
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,
fontWeight = FontWeight.Bold,
color = accentColor
)
}
}
// Items list
if (list.items.isEmpty()) {
Text(
text = "Список пуст",
fontSize = 12.sp,
color = TextMuted,
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
)
} else {
list.items.forEach { item ->
ItemRow(
item = item,
accentColor = accentColor,
onToggle = { onToggleItem(item) },
onDelete = { onDeleteItem(item) }
)
}
}
}
}
@Composable
private fun ItemRow(
item: NoteItem,
accentColor: Color,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(ObsidianSurface)
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
.clickable { onToggle() }
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isCompleted,
onCheckedChange = { onToggle() },
colors = CheckboxDefaults.colors(
checkedColor = accentColor,
uncheckedColor = TextMuted,
checkmarkColor = ObsidianBg
),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.title,
fontSize = 14.sp,
color = if (item.isCompleted) TextMuted else TextPrimary,
textDecoration = if (item.isCompleted) TextDecoration.LineThrough else TextDecoration.None
)
if (item.status == "want_to_watch") {
Text(
text = "🎬 Буду смотреть",
fontSize = 10.sp,
color = CyanAccent,
fontWeight = FontWeight.Medium
)
} else if (item.status == "watched") {
Text(
text = "✔ Просмотрено",
fontSize = 10.sp,
color = EmeraldMint,
fontWeight = FontWeight.Medium
)
}
}
IconButton(
onClick = onDelete,
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Удалить",
tint = TextMuted,
modifier = Modifier.size(15.dp)
)
}
}
}
@@ -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
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 322 KiB

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

Before

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,5 +0,0 @@
<resources>
<string name="app_name">Avtex</string>
<string name="shortcut_voice_note">Голосовая заметка</string>
<string name="shortcut_voice_note_desc">Быстрая запись заметки голосом в Avtex</string>
</resources>
@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Шорткат по долгому нажатию на иконку: Голосовая заметка -->
<shortcut
android:shortcutId="voice_note"
android:enabled="true"
android:icon="@mipmap/ic_launcher_avtex"
android:shortcutShortLabel="@string/shortcut_voice_note"
android:shortcutLongLabel="@string/shortcut_voice_note_desc">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://voice/record" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<!-- Создание заметки через Gemini / Google Assistant ("Сделай заметку...") -->
<capability android:name="actions.intent.CREATE_NOTE">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://notes/create">
<parameter
android:name="note.text"
android:key="note_text" />
<parameter
android:name="note.name"
android:key="note_title" />
</intent>
</capability>
<!-- Добавление элемента в список через Gemini / Google Assistant ("Добавь фильм в Avtex...") -->
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://lists/add">
<parameter
android:name="itemList.name"
android:key="list_name" />
<parameter
android:name="itemListElement.name"
android:key="item_name" />
</intent>
</capability>
<!-- Создание списка через Gemini / Google Assistant -->
<capability android:name="actions.intent.CREATE_ITEM_LIST">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://lists/create">
<parameter
android:name="itemList.name"
android:key="list_name" />
</intent>
</capability>
<!-- Просмотр списка -->
<capability android:name="actions.intent.GET_ITEM_LIST">
<intent
android:action="android.intent.action.VIEW"
android:targetPackage="com.example.avtex"
android:targetClass="com.example.avtex.MainActivity"
android:data="xflow://lists/view">
<parameter
android:name="itemList.name"
android:key="list_name" />
</intent>
</capability>
</shortcuts>
-47
View File
@@ -1,47 +0,0 @@
# API Keys Configuration
# Copy this file to .env and fill in your actual keys
# DeepSeek API (recommended - cost effective)
# Get from: https://platform.deepseek.com/api_keys
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
# OpenAI API (alternative)
# Get from: https://platform.openai.com/api-keys
OPENAI_API_KEY=sk_your_openai_api_key_here
# Anthropic API (alternative)
# Get from: https://console.anthropic.com/
ANTHROPIC_API_KEY=your_anthropic_api_key_here
# Firebase Configuration (for Nuxt runtimeConfig)
# Get from: https://console.firebase.google.com -> Project Settings -> General
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
# Server-side Firebase API key (same as public, used for token verification)
NUXT_FIREBASE_API_KEY=your_firebase_api_key
# Google Calendar API
# Enable in Google Cloud Console
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
# Database (PostgreSQL)
# Production: postgresql://xflow:password@postgres:5432/xflow
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
# Gitea Integration
GITEA_URL=http://git.a-crm.controlsystem.kz
GITEA_TOKEN=your_gitea_token
GITEA_ORG=AVTEX
GITEA_MINDMAP_REPO=MMAVTEX
GITEA_PROJECT_REPO=Projects
# JWT Secret for session tokens
NUXT_JWT_SECRET=your_random_secret_here
-22
View File
@@ -1,22 +0,0 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup>
const { initAuth } = useAuth()
initAuth()
useHead({
title: 'XFlow',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
{ name: 'theme-color', content: '#0f172a' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
})
</script>
-37
View File
@@ -1,37 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-950 text-slate-100;
font-family: 'Inter', system-ui, sans-serif;
}
}
@layer components {
.vue-flow {
@apply bg-slate-900;
}
.vue-flow__node {
@apply bg-slate-800 border border-slate-700 rounded-lg shadow-lg;
}
.vue-flow__node.selected {
@apply border-blue-500 ring-2 ring-blue-500/50;
}
.vue-flow__edge-path {
stroke: #475569;
stroke-width: 2;
}
.vue-flow__controls {
@apply bg-slate-800 border-slate-700;
}
.vue-flow__controls-button {
@apply bg-slate-700 text-slate-200 hover:bg-slate-600;
}
}
@@ -1,64 +0,0 @@
<template>
<Teleport to="body">
<TransitionGroup
name="achievement"
tag="div"
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
>
<div
v-for="ach in visibleAchievements"
:key="ach.id"
class="bg-slate-800 border border-yellow-500/50 rounded-xl p-4 shadow-2xl max-w-sm flex items-start gap-3"
>
<div class="text-3xl">{{ ach.icon || '🏆' }}</div>
<div>
<h4 class="font-bold text-yellow-400 text-sm">{{ ach.title }}</h4>
<p class="text-xs text-slate-400 mt-0.5">{{ ach.description }}</p>
</div>
<button
@click="dismiss(ach.id)"
class="text-slate-500 hover:text-slate-300 ml-2"
>
✕
</button>
</div>
</TransitionGroup>
</Teleport>
</template>
<script setup>
const props = defineProps({
achievements: { type: Array, default: () => [] }
})
const visibleAchievements = ref([])
watch(() => props.achievements, (newAchievements) => {
const incoming = newAchievements.filter(a => !visibleAchievements.value.find(v => v.id === a.id))
incoming.forEach(ach => {
visibleAchievements.value.push(ach)
setTimeout(() => dismiss(ach.id), 5000)
})
}, { deep: true, immediate: true })
const dismiss = (id) => {
visibleAchievements.value = visibleAchievements.value.filter(a => a.id !== id)
}
</script>
<style scoped>
.achievement-enter-active,
.achievement-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.achievement-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
.achievement-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
</style>
-46
View File
@@ -1,46 +0,0 @@
<template>
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/80 backdrop-blur-xl border-t border-slate-800 pb-safe z-40">
<div class="flex justify-around items-center h-16">
<NuxtLink to="/" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">🏠</span>
<span class="text-[10px] font-medium">Главная</span>
</NuxtLink>
<NuxtLink to="/canvases" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">🗺️</span>
<span class="text-[10px] font-medium">Канвасы</span>
</NuxtLink>
<NuxtLink to="/tasks" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">✅</span>
<span class="text-[10px] font-medium">Задачи</span>
</NuxtLink>
<NuxtLink v-if="isAdmin" to="/admin" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-red-500">
<span class="text-xl">🛡️</span>
<span class="text-[10px] font-medium">Админ</span>
</NuxtLink>
<NuxtLink to="/settings" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">⚙️</span>
<span class="text-[10px] font-medium">Опции</span>
</NuxtLink>
<NuxtLink to="/profile" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
<span class="text-xl">👤</span>
<span class="text-[10px] font-medium">Профиль</span>
</NuxtLink>
</div>
</nav>
</template>
<script setup>
const auth = useAuth()
const isAdmin = computed(() => auth.user.value?.role === 'admin')
</script>
<style scoped>
.pb-safe {
padding-bottom: env(safe-area-inset-bottom);
}
</style>
-108
View File
@@ -1,108 +0,0 @@
<template>
<div class="h-screen w-full relative">
<VueFlow
v-model="elements"
:default-zoom="0.8"
:min-zoom="0.2"
:max-zoom="2"
fit-view-on-init
@node-double-click="onNodeDoubleClick"
>
<Background pattern-color="#334155" :gap="20" />
<Controls />
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
<template #node-text="{ data, id }">
<MarkdownNode
:id="id"
:data="data"
@update-state="updateNodeState"
/>
</template>
<template #node-file="{ data, id }">
<MarkdownNode
:id="id"
:data="data"
:is-file="true"
@update-state="updateNodeState"
/>
</template>
</VueFlow>
<div class="absolute top-4 left-4 z-10 bg-slate-800/90 backdrop-blur border border-slate-700 rounded-lg p-3">
<h2 class="text-sm font-semibold text-slate-200">{{ canvasName }}</h2>
<p class="text-xs text-slate-400 mt-1">Double click to edit state</p>
</div>
</div>
</template>
<script setup>
import { VueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { MiniMap } from '@vue-flow/minimap'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const props = defineProps({
canvasId: { type: String, default: 'demo' }
})
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
const canvasName = computed(() => props.canvasId)
const elements = ref([])
const transformToFlowElements = (canvasData) => {
const nodes = (canvasData.nodes || []).map(node => ({
id: node.id,
type: node.type === 'file' ? 'file' : 'text',
position: { x: node.x, y: node.y },
data: {
...node,
width: node.width,
height: node.height
},
style: {
width: `${node.width}px`,
height: `${node.height}px`,
background: node.style?.background || '#1e293b',
border: `1px solid ${node.style?.border || '#334155'}`,
borderRadius: '8px'
}
}))
const edges = (canvasData.edges || []).map(edge => ({
id: edge.id,
source: edge.fromNode,
target: edge.toNode,
sourceHandle: edge.fromSide,
targetHandle: edge.toSide,
animated: true,
style: { stroke: '#475569', strokeWidth: 2 }
}))
return [...nodes, ...edges]
}
const loadData = async () => {
const data = await loadCanvas(props.canvasId)
elements.value = transformToFlowElements(data)
}
const updateNodeState = async ({ nodeId, state }) => {
await apiUpdateNodeState(nodeId, state)
await loadData()
}
const onNodeDoubleClick = (event) => {
const node = event.node
const currentState = node.data?.state || 'pending'
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
updateNodeState({ nodeId: node.id, state: nextState })
}
onMounted(() => {
loadData()
})
</script>
File diff suppressed because it is too large Load Diff
@@ -1,303 +0,0 @@
<template>
<div
ref="rootEl"
:class="[
'bg-slate-800 border rounded-lg shadow-lg p-4 text-sm font-sans flex flex-col h-full w-full',
selected ? 'ring-2 ring-blue-500' : '',
nodeStateClass
]"
:style="nodeStyle"
>
<!-- Header with icon and label -->
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-slate-600">
<span class="text-lg">{{ nodeIcon }}</span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-white truncate">{{ data.label || getNodeTitle() }}</div>
<div class="text-xs text-slate-400 uppercase tracking-wider">{{ data.type || type }}</div>
</div>
<div v-if="type === 'task' && data.state" :class="stateBadgeClass">
{{ stateLabel }}
</div>
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
{{ priorityLabel }}
</div>
<!-- Кнопка запуска для спец-узлов -->
<button
v-if="['ai', 'webhook'].includes(type)"
@click.stop="handleExecute"
:disabled="isExecuting"
class="ml-auto p-1.5 hover:bg-slate-700 rounded-md transition-colors group relative"
title="Запустить выполнение"
>
<span v-if="isExecuting" class="block animate-spin text-blue-400">🌀</span>
<span v-else class="block group-hover:scale-110 transition-transform">▶️</span>
</button>
</div>
<!-- Content preview for measurement -->
<div class="text-slate-100 space-y-2 flex-1">
<div v-if="data.text" class="text-slate-300 text-xs leading-relaxed whitespace-pre-wrap mb-3" v-html="formattedText"></div>
<div v-if="data.file" class="flex items-center gap-2">
<span class="text-sm">📄</span>
<span class="font-mono text-sm text-blue-400">{{ data.file }}</span>
</div>
<div v-if="data.prompt" class="text-sm text-purple-300">
<div class="font-medium mb-1">AI Prompt:</div>
<div>{{ data.prompt }}</div>
</div>
<div v-if="data.url" class="flex items-center gap-2">
<span class="text-sm">🔗</span>
<a :href="data.url" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm break-all">{{ data.url }}</a>
</div>
<div v-if="data.script_id" class="flex items-center gap-2">
<span class="text-sm">🎭</span>
<span class="font-mono text-sm text-green-400">{{ data.script_id }}</span>
</div>
<div v-if="data.assigned_to" class="flex items-center gap-2">
<span class="text-sm">👤</span>
<span class="text-sm text-slate-300">{{ data.assigned_to }}</span>
</div>
<div v-if="data.alarm" class="flex items-center gap-2">
<span class="text-sm">⏰</span>
<span class="text-sm text-orange-400">{{ formatAlarm(data.alarm) }}</span>
</div>
</div>
<!-- Connection handles -->
<Handle type="target" :position="Position.Left" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
<Handle type="source" :position="Position.Right" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
</div>
</template>
<script setup>
import { Handle, Position, useVueFlow } from '@vue-flow/core'
import { computed, ref, onMounted, nextTick } from 'vue'
const props = defineProps({
id: { type: String, required: true },
data: { type: Object, required: true },
type: { type: String, default: 'text' },
selected: { type: Boolean, default: false }
})
const emit = defineEmits(['update', 'execute'])
const isExecuting = ref(false)
const handleExecute = async () => {
if (isExecuting.value) return
isExecuting.value = true
try {
emit('execute', { id: props.id, type: props.type })
} finally {
// Состояние загрузки сбрасывается внешним событием или по таймауту для безопасности
setTimeout(() => { isExecuting.value = false }, 10000)
}
}
// Root element ref
const rootEl = ref(null)
// Access Vue Flow API to update node dimensions
const { updateNode } = useVueFlow()
// Measure and apply real height to Vue Flow node wrapper
const applyNodeSize = () => {
if (!rootEl.value) return
const h = rootEl.value.scrollHeight
if (h > 0) {
updateNode(props.id, {
style: { height: h + 'px' }
})
}
}
onMounted(() => {
nextTick(() => setTimeout(applyNodeSize, 50))
setTimeout(applyNodeSize, 200)
})
// Node style: fixed width from data, height auto to fit content
const nodeStyle = computed(() => {
const baseStyle = {
backgroundColor: props.data.style?.background || '#1e293b',
borderColor: props.data.style?.border || '#475569',
width: `${props.data.width || 320}px`
}
if (props.data.state === 'completed') {
baseStyle.borderColor = '#10b981' // Green
} else if (props.data.state === 'in_progress') {
baseStyle.borderColor = '#3b82f6' // Blue
} else if (props.data.state === 'review') {
baseStyle.borderColor = '#a855f7' // Purple
} else if (props.data.state === 'blocked') {
baseStyle.borderColor = '#ef4444' // Red
} else if (props.data.state === 'todo') {
baseStyle.borderColor = '#64748b' // Slate
}
return baseStyle
})
// Node type icon
const nodeIcon = computed(() => {
const icons = {
text: '📝',
file: '📄',
task: '✅',
ai: '🤖',
webhook: '🔗',
git: '🔄'
}
return icons[props.type] || '📝'
})
// State handling
const nodeStateClass = computed(() => {
const state = props.data.state
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20'
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
return ''
})
const stateBadgeClass = computed(() => {
const state = props.data.state
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
})
const stateLabel = computed(() => {
const state = props.data.state
if (state === 'completed') return '✅ Done'
if (state === 'in_progress') return '⏳ In Progress'
if (state === 'review') return '👀 Review'
if (state === 'blocked') return '⛔ Blocked'
return '📋 To Do'
})
// Priority handling
const priorityBadgeClass = computed(() => {
const prio = props.data.priority
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
})
const priorityLabel = computed(() => {
const prio = props.data.priority
if (prio === 'high') return '🔥 High'
if (prio === 'low') return '🧊 Low'
return '⚡ Med'
})
// Get node title from text content
const getNodeTitle = () => {
if (props.data.text) {
const lines = props.data.text.split('\n')
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('# ')) {
return trimmed.replace('# ', '')
}
}
// If no h1 found, use first non-empty line
const firstLine = lines.find(l => l.trim())
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
}
if (props.data.file) return props.data.file
return 'Node'
}
// Format text with markdown support
const formattedText = computed(() => {
if (!props.data.text) return ''
let text = props.data.text
// Convert markdown headers
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
// Convert bold text
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
// Convert italic text
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
// Convert code blocks
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
// Convert inline code
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
// Convert lists
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
// Convert line breaks
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
text = text.replace(/\n/g, '<br>')
// Wrap in paragraphs
if (!text.includes('<p>')) {
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
}
return text
})
// Format alarm time
const formatAlarm = (alarm) => {
try {
const date = new Date(alarm)
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
} catch {
return alarm
}
}
</script>
<style scoped>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: #475569 #1e293b;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #1e293b;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
</style>
-51
View File
@@ -1,51 +0,0 @@
<template>
<div v-if="deferredPrompt && !isStandalone" class="fixed bottom-6 right-6 z-50">
<button
@click="installApp"
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-2xl font-bold shadow-2xl flex items-center gap-3 transition-all transform hover:scale-105 active:scale-95 group"
>
<span class="text-xl group-hover:rotate-12 transition-transform">📲</span>
<span>Установить XFlow</span>
</button>
</div>
</template>
<script setup>
const deferredPrompt = ref(null)
const isStandalone = ref(false)
onMounted(() => {
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault()
deferredPrompt.value = e
})
window.addEventListener('appinstalled', () => {
deferredPrompt.value = null
})
})
const installApp = async () => {
if (!deferredPrompt.value) {
alert('Браузер еще не подготовил установку. Пожалуйста, подождите 10-20 секунд или проверьте вкладку Application в консоли.')
return
}
deferredPrompt.value.prompt()
const { outcome } = await deferredPrompt.value.userChoice
console.log(`PWA: user choice outcome: ${outcome}`)
deferredPrompt.value = null
}
</script>
<style scoped>
@keyframes bounce-subtle {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.animate-bounce-subtle {
animation: bounce-subtle 3s ease-in-out infinite;
}
</style>
-89
View File
@@ -1,89 +0,0 @@
<template>
<div
class="w-full h-full overflow-auto p-3 rounded-lg cursor-pointer"
:class="stateClass"
@click="$emit('update-state', { nodeId: id, state: nextState })"
>
<div v-if="data.frontmatter?.alarm" class="text-xs text-yellow-400 mb-1 flex items-center gap-1">
<span>⏰</span>
<span>{{ formatAlarm(data.frontmatter.alarm) }}</span>
</div>
<div v-if="data.frontmatter?.assigned_to" class="text-xs text-slate-400 mb-1">
👤 {{ data.frontmatter.assigned_to }}
</div>
<div v-if="data.text" class="prose prose-invert prose-sm max-w-none" v-html="renderedHtml" />
<div v-else-if="data.file" class="text-sm text-blue-400">
📄 {{ data.file }}
</div>
<div class="mt-2 flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full"
:class="badgeClass"
>
{{ stateLabel }}
</span>
<span v-if="data.frontmatter?.script_id" class="text-xs text-purple-400">
⚡ {{ data.frontmatter.script_id }}
</span>
</div>
</div>
</template>
<script setup>
const props = defineProps({
id: { type: String, required: true },
data: { type: Object, required: true },
isFile: { type: Boolean, default: false }
})
const emit = defineEmits(['update-state'])
const { renderMarkdown } = useCanvas()
const renderedHtml = computed(() => {
return renderMarkdown(props.data.text || '')
})
const currentState = computed(() => props.data.state || 'pending')
const nextState = computed(() => {
const s = currentState.value
return s === 'pending' ? 'in_progress' : s === 'in_progress' ? 'completed' : 'pending'
})
const stateClass = computed(() => {
switch (currentState.value) {
case 'completed': return 'bg-emerald-900/40 border-emerald-700'
case 'in_progress': return 'bg-blue-900/40 border-blue-700'
default: return 'bg-slate-800 border-slate-700'
}
})
const badgeClass = computed(() => {
switch (currentState.value) {
case 'completed': return 'bg-emerald-700 text-emerald-100'
case 'in_progress': return 'bg-blue-700 text-blue-100'
default: return 'bg-slate-700 text-slate-300'
}
})
const stateLabel = computed(() => {
switch (currentState.value) {
case 'completed': return '✅ Выполнено'
case 'in_progress': return '🔧 В работе'
default: return '📋 Ожидает'
}
})
const formatAlarm = (alarm) => {
try {
return new Date(alarm).toLocaleString('ru-RU')
} catch {
return alarm
}
}
</script>
@@ -1,266 +0,0 @@
<template>
<Teleport to="body">
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-sm p-4 font-sans">
<div class="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
<!-- Modal Header -->
<div class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
<h2 class="text-base font-bold text-slate-200 flex items-center gap-2">
<span>🧙‍♂️</span> Коуч-Ассистент: Конструктор SMART & GROW
</h2>
<button @click="$emit('close')" class="text-slate-400 hover:text-white transition">✕</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 bg-slate-950/40">
<button
@click="wizardType = 'smart'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'smart' ? 'text-indigo-400 border-indigo-500 bg-indigo-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
🎯 SMART Цель
</button>
<button
@click="wizardType = 'grow'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'grow' ? 'text-teal-400 border-teal-500 bg-teal-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
🌱 GROW Сессия
</button>
<button
@click="wizardType = 'dilts'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'dilts' ? 'text-violet-400 border-violet-500 bg-violet-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
📐 Пирамида Дилтса
</button>
<button
@click="wizardType = 'eisenhower'"
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'eisenhower' ? 'text-amber-400 border-amber-500 bg-amber-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
>
📊 Матрица Эйзенхауэра
</button>
</div>
<!-- Modal Body (Forms) -->
<div class="p-6 overflow-y-auto flex-1 space-y-4">
<!-- S.M.A.R.T Form -->
<div v-if="wizardType === 'smart'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">S (Specific) — Какая конкретная задача?</label>
<textarea v-model="smartForm.specific" rows="2" placeholder="Что именно вы хотите сделать? (например: Разработать модуль экспорта canvas)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">M (Measurable) — Как вы измерите успех?</label>
<textarea v-model="smartForm.measurable" rows="2" placeholder="Укажите критерии готовности (каждую метрику с новой строки)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">A (Achievable) — За счет чего цель достижима?</label>
<textarea v-model="smartForm.achievable" rows="2" placeholder="Какими ресурсами и навыками вы располагаете?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">R (Relevant) — Почему эта цель важна?</label>
<textarea v-model="smartForm.relevant" rows="2" placeholder="Какую пользу принесет выполнение этой цели общему проекту?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">T (Time-bound) — Каков дедлайн?</label>
<input v-model="smartForm.deadline" type="datetime-local" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none" />
</div>
</div>
<!-- G.R.O.W Form -->
<div v-if="wizardType === 'grow'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">G (Goal) — Цель</label>
<textarea v-model="growForm.goal" rows="3" placeholder="Какой именно цели я хочу достичь?&#10;Какой результат я хочу получить?&#10;Почему достижение этой цели важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">O (Options) — Варианты</label>
<textarea v-model="growForm.options" rows="4" placeholder="Что именно я могу изменить?&#10;Какие ресурсы мне нужны?&#10;Кто мне может помочь?&#10;А что еще?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">R (Reality) — Реальность</label>
<textarea v-model="growForm.reality" rows="4" placeholder="Какова текущая ситуация?&#10;Какие препятствия я вижу?&#10;Что мне мешает начать?&#10;Какие у меня уже есть ресурсы?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">W (Way Forward) — План</label>
<textarea v-model="growForm.wayForward" rows="3" placeholder="Какие самые важные действия необходимо предпринять?&#10;Что я сделаю в первую очередь? Какие сроки?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
</div>
</div>
<!-- Dilts Form -->
<div v-if="wizardType === 'dilts'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🌟 Миссия — Ради кого? Кому еще это важно? (БЫТЬ)</label>
<textarea v-model="diltsForm.mission" rows="2" placeholder="Ради кого? Кому еще это важно? (БЫТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">👤 Я — Кто я?</label>
<textarea v-model="diltsForm.identity" rows="2" placeholder="Кто я?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">💎 Ценности — Почему это важно?</label>
<textarea v-model="diltsForm.values" rows="2" placeholder="Почему это важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🧠 Навыки — Как? Каким образом?</label>
<textarea v-model="diltsForm.skills" rows="2" placeholder="Как? Каким образом?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🎬 Действия — Что делать? (ДЕЛАТЬ)</label>
<textarea v-model="diltsForm.actions" rows="2" placeholder="Что делать? (ДЕЛАТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🌍 Окружение — Где? Когда? С кем? (ИМЕТЬ)</label>
<textarea v-model="diltsForm.environment" rows="2" placeholder="Где? Когда? С кем? (ИМЕТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
</div>
</div>
<!-- Eisenhower Form -->
<div v-if="wizardType === 'eisenhower'" class="space-y-4">
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🔥 Квадрант I: Важно и Срочно (Сделать сейчас)</label>
<textarea v-model="eisenhowerForm.q1" rows="2" placeholder="Кризисы, горящие дедлайны, важные неотложные задачи" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">⚡ Квадрант II: Важно, но Несрочно (Запланировать)</label>
<textarea v-model="eisenhowerForm.q2" rows="2" placeholder="Долгосрочное планирование, здоровье, обучение, стратегические цели" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">🧊 Квадрант III: Неважно, но Срочно (Делегировать)</label>
<textarea v-model="eisenhowerForm.q3" rows="2" placeholder="Входящие рутинные звонки/письма, мелкие поручения, задачи, которые можно перепоручить" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
<div>
<label class="text-xs font-bold text-slate-400 block mb-1">💤 Квадрант IV: Неважно и Несрочно (Отложить/Удалить)</label>
<textarea v-model="eisenhowerForm.q4" rows="2" placeholder="Пожиратели времени, мелкие развлечения, неважная рутина" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
<button @click="$emit('close')" class="px-4 py-2 border border-slate-700 hover:bg-slate-800 text-slate-300 rounded-lg text-xs transition">
Отмена
</button>
<button
@click="submitManual"
:disabled="isSubmitting"
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-lg text-xs transition"
>
Создать вручную
</button>
<button
@click="submitAI"
:disabled="isSubmitting"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition flex items-center gap-1.5"
>
<span v-if="isSubmitting" class="animate-spin text-xs">🌀</span>
<span v-else>✨</span>
Оптимизировать через AI
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
isSubmitting: { type: Boolean, default: false }
})
const emit = defineEmits([
'close',
'submit-smart-manual',
'submit-smart-ai',
'submit-grow-manual',
'submit-grow-ai',
'submit-dilts-manual',
'submit-dilts-ai',
'submit-eisenhower-manual',
'submit-eisenhower-ai'
])
const wizardType = ref('smart')
const smartForm = ref({
specific: '',
measurable: '',
achievable: '',
relevant: '',
deadline: ''
})
const growForm = ref({
goal: '',
reality: '',
options: '',
wayForward: ''
})
const diltsForm = ref({
mission: '',
identity: '',
values: '',
skills: '',
actions: '',
environment: ''
})
const eisenhowerForm = ref({
q1: '',
q2: '',
q3: '',
q4: ''
})
const resetForms = () => {
smartForm.value = { specific: '', measurable: '', achievable: '', relevant: '', deadline: '' }
growForm.value = { goal: '', reality: '', options: '', wayForward: '' }
diltsForm.value = { mission: '', identity: '', values: '', skills: '', actions: '', environment: '' }
eisenhowerForm.value = { q1: '', q2: '', q3: '', q4: '' }
}
const submitManual = () => {
if (wizardType.value === 'smart') {
emit('submit-smart-manual', { ...smartForm.value })
} else if (wizardType.value === 'grow') {
emit('submit-grow-manual', { ...growForm.value })
} else if (wizardType.value === 'dilts') {
emit('submit-dilts-manual', { ...diltsForm.value })
} else if (wizardType.value === 'eisenhower') {
emit('submit-eisenhower-manual', { ...eisenhowerForm.value })
}
resetForms()
}
const submitAI = () => {
if (wizardType.value === 'smart') {
if (!smartForm.value.specific.trim()) {
alert('Пожалуйста, введите хотя бы описание цели!')
return
}
emit('submit-smart-ai', { ...smartForm.value })
} else if (wizardType.value === 'grow') {
if (!growForm.value.goal.trim()) {
alert('Пожалуйста, введите хотя бы цель (Goal)!')
return
}
emit('submit-grow-ai', { ...growForm.value })
} else if (wizardType.value === 'dilts') {
if (!diltsForm.value.mission.trim()) {
alert('Пожалуйста, введите хотя бы описание миссии!')
return
}
emit('submit-dilts-ai', { ...diltsForm.value })
} else if (wizardType.value === 'eisenhower') {
if (!eisenhowerForm.value.q1.trim() && !eisenhowerForm.value.q2.trim() && !eisenhowerForm.value.q3.trim() && !eisenhowerForm.value.q4.trim()) {
alert('Пожалуйста, введите задачи хотя бы в один из квадрантов!')
return
}
emit('submit-eisenhower-ai', { ...eisenhowerForm.value })
}
}
// Expose reset in case parent wants to reset from outside
defineExpose({ resetForms })
</script>
@@ -1,318 +0,0 @@
<template>
<div
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
:class="[isOpen ? 'h-80' : 'h-10']"
>
<!-- Handle / Toggle Bar -->
<div
@click="isOpen = !isOpen"
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
>
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
<span>📅</span>
<span>Диаграмма Ганта</span>
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
Задач: {{ tasks.length }}
</span>
</div>
<div class="text-slate-400 text-xs">
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
</div>
</div>
<!-- Timeline Body -->
<div
v-if="isOpen"
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
>
<!-- Task Titles Sidebar (Left) -->
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
Задача
</div>
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
<div
v-for="task in tasks"
:key="task.id"
@click="$emit('select-node', task.id)"
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
title="Кликните для фокусировки на холсте"
>
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
</div>
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
Нет задач на холсте
</div>
</div>
</div>
<!-- Timeline Chart (Right) -->
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
<!-- Date Columns Header -->
<div
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
ref="timelineHeader"
@scroll="syncScrollLeft"
>
<div
v-for="(day, idx) in timelineDays"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
:style="{ width: `${dayWidth}px` }"
>
<span>{{ day.dayOfWeek }}</span>
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
</div>
</div>
<!-- Task Bars Body -->
<div
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
ref="timelineBody"
@scroll="syncScrollRight"
>
<div
v-for="task in tasks"
:key="task.id"
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
>
<!-- Timeline Grid Background Lines -->
<div class="absolute inset-0 flex pointer-events-none">
<div
v-for="idx in timelineDays.length"
:key="idx"
class="h-full border-r border-slate-900/40 shrink-0"
:style="{ width: `${dayWidth}px` }"
></div>
</div>
<!-- Task Gantt Bar -->
<div
v-if="hasValidDates(task)"
@click="$emit('select-node', task.id)"
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
:class="getBarClasses(task)"
:style="getBarStyles(task)"
>
<span class="truncate">{{ task.data.label }}</span>
<!-- Tooltip on hover -->
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
С: {{ formatDate(task.data.start_date) }}<br/>
По: {{ formatDate(task.data.alarm) }}
</div>
</div>
</div>
</div>
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
Создайте задачи и задайте им даты начала/дедлайны
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
elements: { type: Array, default: () => [] }
})
defineEmits(['select-node'])
const isOpen = ref(false)
const dayWidth = 60 // Width of one day column in pixels
const timelineHeader = ref(null)
const timelineBody = ref(null)
// Scroll synchronization between header and body
const syncScrollLeft = (e) => {
if (timelineBody.value) {
timelineBody.value.scrollLeft = e.target.scrollLeft
}
}
const syncScrollRight = (e) => {
if (timelineHeader.value) {
timelineHeader.value.scrollLeft = e.target.scrollLeft
}
}
// Filter tasks
const tasks = computed(() => {
return props.elements.filter(el => el.type === 'task')
})
// Calculate overall min/max dates
const dateRange = computed(() => {
let minDate = new Date()
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
const taskDates = []
tasks.value.forEach(t => {
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
})
if (taskDates.length > 0) {
minDate = new Date(Math.min(...taskDates))
maxDate = new Date(Math.max(...taskDates))
}
// Add 2 days padding to sides
minDate.setDate(minDate.getDate() - 2)
maxDate.setDate(maxDate.getDate() + 5)
// Cap timeline length at 30 days to prevent rendering overflow
const diffTime = Math.abs(maxDate - minDate)
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
if (diffDays > 30) {
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
}
// Reset to midnight
minDate.setHours(0, 0, 0, 0)
maxDate.setHours(23, 59, 59, 999)
return { minDate, maxDate }
})
// Generate list of days for timeline header
const timelineDays = computed(() => {
const days = []
const current = new Date(dateRange.value.minDate)
const end = dateRange.value.maxDate
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
while (current <= end) {
days.push({
date: new Date(current),
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
dayOfWeek: ruDays[current.getDay()]
})
current.setDate(current.getDate() + 1)
}
return days
})
// Check if task has valid start and end date
const hasValidDates = (task) => {
return !!(task.data.start_date || task.data.alarm)
}
// Format single date for tooltips
const formatDate = (dateStr) => {
if (!dateStr) return 'Не указано'
const date = new Date(dateStr)
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
}
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
const getBarStyles = (task) => {
const minTime = dateRange.value.minDate.getTime()
const maxTime = dateRange.value.maxDate.getTime()
const totalDuration = maxTime - minTime
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
if (start > end) {
// Correct order if user reversed dates
const tmp = start
start = end
end = tmp
}
// Boundary checks
start = Math.max(start, minTime)
end = Math.min(end, maxTime)
const leftPercent = ((start - minTime) / totalDuration) * 100
const widthPercent = ((end - start) / totalDuration) * 100
// Calculate actual pixel offsets based on timeline width
const totalDays = timelineDays.value.length
const totalPixels = totalDays * dayWidth
const leftPx = (leftPercent / 100) * totalPixels
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
return {
left: `${leftPx}px`,
width: `${widthPx}px`
}
}
// Style colors based on task status
const getBarClasses = (task) => {
const state = task.data.state || 'todo'
switch (state) {
case 'completed':
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
case 'in_progress':
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
case 'review':
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
case 'blocked':
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
default:
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
}
}
const getStatusIcon = (state) => {
switch (state) {
case 'completed': return '✅'
case 'in_progress': return '⏳'
case 'review': return '👀'
case 'blocked': return '⛔'
default: return '📋'
}
}
const getStatusLabel = (state) => {
switch (state) {
case 'completed': return 'Завершено'
case 'in_progress': return 'В работе'
case 'review': return 'На проверке'
case 'blocked': return 'Заблокировано'
default: return 'К выполнению'
}
}
const getPriorityLabel = (priority) => {
switch (priority) {
case 'high': return 'Высокий'
case 'low': return 'Низкий'
default: return 'Средний'
}
}
</script>
<style scoped>
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #475569;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
</style>

Some files were not shown because too many files have changed in this diff Show More