Compare commits
1 Commits
v1.5
..
9f9a253ef4
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f9a253ef4 |
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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=
|
|
||||||
@@ -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)"
|
|
||||||
|
|
||||||
@@ -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 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(flutter *)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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**
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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)"
|
|
||||||
@@ -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 = 6
|
versionCode = 1
|
||||||
versionName = "1.5"
|
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 {
|
||||||
@@ -109,29 +104,3 @@ dependencies {
|
|||||||
// DataStore for settings
|
// DataStore for settings
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автоматическое копирование релизного APK на рабочий стол пользователя после сборки
|
|
||||||
|
|
||||||
val desktopDir = File(System.getProperty("user.home"), "Desktop")
|
|
||||||
|
|
||||||
|
|
||||||
tasks.register("copyReleaseApkToDesktop") {
|
|
||||||
notCompatibleWithConfigurationCache("Copies APK to user Desktop")
|
|
||||||
doLast {
|
|
||||||
val userHome = System.getProperty("user.home")
|
|
||||||
val desktopDir = File(userHome, "Desktop")
|
|
||||||
if (desktopDir.exists() && desktopDir.isDirectory) {
|
|
||||||
val apkFile = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
|
|
||||||
if (apkFile.exists()) {
|
|
||||||
val vName = android.defaultConfig.versionName ?: "release"
|
|
||||||
apkFile.copyTo(File(desktopDir, "Avtex-v${vName}.apk"), overwrite = true)
|
|
||||||
apkFile.copyTo(File(desktopDir, "Avtex-release.apk"), overwrite = true)
|
|
||||||
println(">>> [AUTO-DEPLOY] Релизный APK скопирован на Рабочий стол: Avtex-v${vName}.apk и Avtex-release.apk")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEvaluate {
|
|
||||||
tasks.findByName("assembleRelease")?.finalizedBy("copyReleaseApkToDesktop")
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
if (now - lastClassifyLogTimestamp >= MIN_CLASSIFY_LOG_INTERVAL_MS) {
|
|
||||||
lastClassifyLogTimestamp = now
|
|
||||||
AvtexApplication.instance.settingsRepository.addLog(
|
AvtexApplication.instance.settingsRepository.addLog(
|
||||||
tag = "SleepAPI",
|
tag = "SleepAPI",
|
||||||
message = "Классификация (раз в час): уверенность $confidence%, движение $motion/6, свет $light/6"
|
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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
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="#07091C"
|
android:fillColor="#0D0E1A"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 688 B |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Avtex</string>
|
||||||
|
</resources>
|
||||||
@@ -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);
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
*.iml
|
|
||||||
.gradle
|
|
||||||
/local.properties
|
|
||||||
local.properties
|
|
||||||
/.idea/
|
|
||||||
.idea/
|
|
||||||
.DS_Store
|
|
||||||
/build
|
|
||||||
build/
|
|
||||||
app/build/
|
|
||||||
/captures
|
|
||||||
.externalNativeBuild
|
|
||||||
.cxx
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 292 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>
|
|
||||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 7.2 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>
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||