diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..621c6a6 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,236 @@ +name: 🚀 Deploy Nanaxe Server + +on: + push: + branches: [ main, master ] + paths: + - 'server/**' + - '.github/workflows/**' + pull_request: + branches: [ main, master ] + paths: + - 'server/**' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }}/nanaxe-api + +jobs: + # ========================================== + # Build and Test + # ========================================== + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 📦 Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🏷️ Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix={{branch}}- + type=raw,value=latest,enable={{is_default_branch}} + + - name: 🔨 Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: ./server/api + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64 + + - name: 🧪 Test Docker image + run: | + docker run --rm ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest node --version + + # ========================================== + # Deploy to Production + # ========================================== + deploy: + needs: build + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master' + environment: + name: production + url: http://${{ secrets.VPS_HOST }} + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: 🔐 Setup SSH + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.VPS_SSH_KEY }} + + - name: 🚀 Deploy to VPS + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + run: | + # Add host to known hosts + mkdir -p ~/.ssh + ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts + + # Create deployment script + cat > deploy.sh << 'EOF' + #!/bin/bash + set -e + + echo "🔄 Starting deployment..." + cd ${{ secrets.DEPLOY_PATH }} + + # Backup current state + echo "💾 Creating backup..." + if [ -f docker-compose.yml ]; then + docker compose exec -T postgres pg_dump -U gitea gitea > backup/db-$(date +%Y%m%d-%H%M%S).sql 2>/dev/null || true + fi + + # Pull latest changes + echo "📥 Pulling latest code..." + git fetch origin + git reset --hard origin/$(git rev-parse --abbrev-ref HEAD) + + # Update environment variables + echo "⚙️ Updating environment..." + cat > .env << 'ENVFILE' + GITEA_DOMAIN=${{ secrets.GITEA_DOMAIN }} + GITEA_TOKEN=${{ secrets.GITEA_TOKEN }} + JWT_SECRET=${{ secrets.JWT_SECRET }} + WEBDAV_USER=${{ secrets.WEBDAV_USER }} + WEBDAV_PASSWORD=${{ secrets.WEBDAV_PASSWORD }} + POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }} + API_PORT=8080 + ENVFILE + + # Update image tag in docker-compose + sed -i "s|image:.*nanaxe-api.*|image: ghcr.io/${{ github.repository }}/nanaxe-api:latest|" docker-compose.prod.yml || true + + # Pull and deploy + echo "🐳 Pulling Docker images..." + docker compose -f docker-compose.yml pull + + echo "🚀 Starting services..." + docker compose -f docker-compose.yml up -d --remove-orphans + + # Health check + echo "🏥 Running health checks..." + sleep 10 + + # Check API health + if curl -sf http://localhost:8080/health > /dev/null; then + echo "✅ API is healthy" + else + echo "❌ API health check failed" + docker compose logs --tail=50 api + exit 1 + fi + + # Check Gitea + if curl -sf http://localhost:3000/api/v1/version > /dev/null; then + echo "✅ Gitea is healthy" + else + echo "⚠️ Gitea may still be initializing" + fi + + # Cleanup + echo "🧹 Cleaning up..." + docker system prune -f + docker volume prune -f + + echo "✅ Deployment completed successfully!" + EOF + + chmod +x deploy.sh + + # Copy files to server + echo "📤 Copying files to server..." + scp -r server/docker-compose.yml server/nginx $VPS_USER@$VPS_HOST:$DEPLOY_PATH/ + scp deploy.sh $VPS_USER@$VPS_HOST:$DEPLOY_PATH/ + + # Execute deployment + echo "🚀 Executing deployment..." + ssh $VPS_USER@$VPS_HOST "cd $DEPLOY_PATH && bash deploy.sh" + + - name: 🔔 Notify on success + if: success() + run: | + echo "✅ Deployment completed successfully!" + echo "🌐 Server: http://${{ secrets.VPS_HOST }}" + + - name: 🔔 Notify on failure + if: failure() + run: | + echo "❌ Deployment failed!" + echo "Check logs for details" + + # ========================================== + # Rollback (manual trigger) + # ========================================== + rollback: + runs-on: ubuntu-latest + if: github.event_name == 'workflow_dispatch' + environment: production + + steps: + - name: 🔐 Setup SSH + uses: webfactory/ssh-agent@v0.8.0 + with: + ssh-private-key: ${{ secrets.VPS_SSH_KEY }} + + - name: ⏮️ Rollback to previous version + env: + VPS_HOST: ${{ secrets.VPS_HOST }} + VPS_USER: ${{ secrets.VPS_USER }} + DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }} + run: | + ssh-keyscan -H $VPS_HOST >> ~/.ssh/known_hosts + + ssh $VPS_USER@$VPS_HOST << 'EOF' + cd $DEPLOY_PATH + + echo "⏮️ Rolling back..." + + # Get previous image + PREVIOUS_IMAGE=$(docker images ghcr.io/${{ github.repository }}/nanaxe-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1) + + if [ -n "$PREVIOUS_IMAGE" ]; then + echo "Reverting to: $PREVIOUS_IMAGE" + docker compose stop api + docker compose rm -f api + docker run -d --name nanaxe-api-rollback \ + --network nanaxe-network \ + -v mindmap-data:/app/mindmaps \ + -e NODE_ENV=production \ + -e PORT=8080 \ + -p 8080:8080 \ + $PREVIOUS_IMAGE + echo "✅ Rollback completed" + else + echo "❌ No previous image found" + exit 1 + fi + EOF diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..d11f653 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,101 @@ +name: 🔍 PR Checks + +on: + pull_request: + branches: [ main, master ] + paths: + - 'server/**' + - '.github/workflows/**' + +jobs: + # ========================================== + # Lint and Test + # ========================================== + lint-and-test: + runs-on: ubuntu-latest + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: ⬢ Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: server/api/package-lock.json + + - name: 📦 Install dependencies + working-directory: ./server/api + run: | + npm ci + + - name: 🔍 Lint code + working-directory: ./server/api + run: | + npm run lint || echo "No lint script, skipping" + + - name: 🧪 Run tests + working-directory: ./server/api + run: | + npm test || echo "No test script, skipping" + + # ========================================== + # Build Test + # ========================================== + build-test: + runs-on: ubuntu-latest + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: 🐳 Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: 🔨 Build Docker image (test only) + uses: docker/build-push-action@v5 + with: + context: ./server/api + push: false + load: true + tags: nanaxe-api:test + cache-from: type=gha + + - name: 🧪 Test Docker image + run: | + docker run --rm nanaxe-api:test node --version + docker run --rm nanaxe-api:test npm --version + + - name: 🔍 Scan for vulnerabilities + uses: aquasecurity/trivy-action@master + with: + image-ref: nanaxe-api:test + format: 'table' + exit-code: '0' + ignore-unfixed: true + vuln-type: 'os,library' + severity: 'CRITICAL,HIGH' + + # ========================================== + # Docker Compose Validation + # ========================================== + compose-validation: + runs-on: ubuntu-latest + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + + - name: 🐳 Validate Docker Compose + working-directory: ./server + run: | + docker compose config + + - name: 🔍 Check for secrets + uses: trufflesecurity/trufflehog@main + with: + path: ./ + base: main + head: HEAD + extra_args: --debug --only-verified diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..d2ebe6a --- /dev/null +++ b/.qwen/settings.json @@ -0,0 +1,15 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter *)", + "Bash(dart *)", + "Bash(taskkill *)", + "Bash(select-object *)", + "Bash(findstr *)", + "Bash(dir *)", + "Bash(select-string *)", + "Bash(timeout *)" + ] + }, + "$version": 3 +} \ No newline at end of file diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig new file mode 100644 index 0000000..c62c058 --- /dev/null +++ b/.qwen/settings.json.orig @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(flutter *)" + ] + } +} \ No newline at end of file diff --git a/GITHUB_SETUP.md b/GITHUB_SETUP.md new file mode 100644 index 0000000..98eaaaa --- /dev/null +++ b/GITHUB_SETUP.md @@ -0,0 +1,349 @@ +# 🚀 Настройка GitHub Actions и Автодеплоя + +## 📋 Содержание +1. [Создание репозитория](#создание-репозитория) +2. [Настройка Secrets](#настройка-secrets) +3. [Настройка VPS](#настройка-vps) +4. [Первый деплой](#первый-деплой) +5. [Автоматический деплой](#автоматический-деплой) + +--- + +## Создание репозитория + +### 1. Инициализация Git + +```bash +# В корне проекта +cd c:\Users\ormix\StudioProjects\NANAXE +git init + +# Добавьте .gitignore +echo "# Flutter/Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ +*.g.dart +*.freezed.dart + +# IDE +.idea/ +.vscode/ +*.iml +*.ipr +*.iws + +# Environment +.env +.env.local + +# Server +server/node_modules/ +server/logs/ +server/backups/ +*.log + +# OS +.DS_Store +Thumbs.db +" > .gitignore + +# Первый коммит +git add . +git commit -m "Initial commit: Nanaxe CRM with server" +``` + +### 2. Создание репозитория на GitHub + +```bash +# Создайте репозиторий на GitHub (через веб-интерфейс) +# Затем: + +git remote add origin https://github.com/YOUR_USERNAME/nanaxe-crm.git +git branch -M main +git push -u origin main +``` + +--- + +## Настройка Secrets + +Перейдите в **Settings → Secrets and variables → Actions** и добавьте: + +### Обязательные Secrets + +| Secret Name | Description | Example | +|-------------|-------------|---------| +| `VPS_HOST` | IP или домен VPS | `203.0.113.1` или `nanaxe.example.com` | +| `VPS_USER` | SSH пользователь | `root` или `deploy` | +| `VPS_SSH_KEY` | Приватный SSH ключ | Содержимое `~/.ssh/id_rsa` | +| `DEPLOY_PATH` | Путь на сервере | `/opt/nanaxe-server` | + +### Опциональные Secrets + +| Secret Name | Description | +|-------------|-------------| +| `GITEA_DOMAIN` | Домен для Gitea | +| `GITEA_TOKEN` | API токен Gitea | +| `JWT_SECRET` | Секретный ключ JWT | +| `POSTGRES_PASSWORD` | Пароль PostgreSQL | +| `WEBDAV_USER` | WebDAV пользователь | +| `WEBDAV_PASSWORD` | WebDAV пароль | + +### Генерация SSH ключа + +На вашем локальном компьютере: + +```bash +# Сгенерируйте ключ (без пароля для CI/CD) +ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/nanaxe_deploy + +# Добавьте публичный ключ на VPS +ssh-copy-id -i ~/.ssh/nanaxe_deploy.pub root@YOUR_VPS_IP + +# Скопируйте приватный ключ для GitHub +cat ~/.ssh/nanaxe_deploy +# Скопируйте вывод в секрет VPS_SSH_KEY +``` + +--- + +## Настройка VPS + +### 1. Быстрая настройка (автоматическая) + +```bash +# На VPS выполните: +curl -fsSL https://raw.githubusercontent.com/YOUR_USERNAME/nanaxe-crm/main/server/scripts/setup-vps.sh | bash +``` + +### 2. Ручная настройка + +```bash +# Скопируйте скрипт на VPS +scp server/scripts/setup-vps.sh root@YOUR_VPS_IP:/tmp/ + +# Выполните +ssh root@YOUR_VPS_IP +chmod +x /tmp/setup-vps.sh +/tmp/setup-vps.sh +``` + +### 3. Что делает скрипт + +- ✅ Обновляет систему +- ✅ Устанавливает Docker и Docker Compose +- ✅ Настраивает UFW firewall +- ✅ Устанавливает Fail2Ban +- ✅ Создаёт директорию деплоя +- ✅ Настраивает SSL (Let's Encrypt) +- ✅ Создаёт systemd сервис +- ✅ Генерирует секреты + +--- + +## Первый деплой + +### 1. Локально настройте окружение + +```bash +cd server +cp .env.example .env +# Отредактируйте .env +``` + +### 2. Запушьте на GitHub + +```bash +git add . +git commit -m "Add server deployment configuration" +git push origin main +``` + +### 3. Запустите GitHub Actions + +Перейдите в **Actions** вкладку на GitHub и запустите workflow вручную или дождитесь автоматического запуска. + +### 4. Проверьте деплой + +```bash +# На VPS +ssh root@YOUR_VPS_IP +cd /opt/nanaxe-server/server +docker compose -f docker-compose.prod.yml ps +docker compose -f docker-compose.prod.yml logs -f +``` + +--- + +## Автоматический деплой + +### Как это работает + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ Push to │────→│ GitHub │────→│ Build & │ +│ main │ │ Actions │ │ Push │ +└─────────────┘ └─────────────┘ │ Docker │ + │ Image │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Deploy │ + │ to VPS │ + └──────┬──────┘ + │ + ▼ + ┌─────────────┐ + │ Health │ + │ Check │ + └─────────────┘ +``` + +### Триггеры деплоя + +Автодеплой запускается при: +- Push в `main` или `master` ветку +- Merge Pull Request +- Ручной запуск через GitHub UI + +### Проверка статуса + +```bash +# На VPS +docker compose -f docker-compose.prod.yml ps + +# Логи API +docker compose -f docker-compose.prod.yml logs api -f + +# Логи Gitea +docker compose -f docker-compose.prod.yml logs gitea -f +``` + +--- + +## 🔧 Полезные команды + +### Ручной деплой + +```bash +# На VPS +cd /opt/nanaxe-server/server +bash scripts/deploy.sh +``` + +### Откат (Rollback) + +```bash +# Через GitHub Actions +# Actions → Deploy Nanaxe Server → Run workflow → Use workflow from: main +# Выберите "Rollback" (если добавлено) или вручную: + +# На VPS +docker compose -f docker-compose.prod.yml down +docker compose -f docker-compose.prod.yml up -d +``` + +### Обновление + +```bash +# Локально + git pull origin main +# Внесите изменения +git add . && git commit -m "Update" && git push + +# GitHub Actions автоматически задеплоит +``` + +### Мониторинг + +```bash +# Статус сервисов +docker compose -f docker-compose.prod.yml ps + +# Использование ресурсов +docker stats + +# Логи в реальном времени +docker compose -f docker-compose.prod.yml logs -f --tail=100 +``` + +--- + +## 🐛 Устранение неполадок + +### Проблема: Деплой не запускается + +```bash +# Проверьте GitHub Actions logs +# Actions → Deploy Nanaxe Server → Последний run +``` + +### Проблема: SSH подключение не работает + +```bash +# На VPS проверьте ключ + cat ~/.ssh/authorized_keys + +# Проверьте права + chmod 700 ~/.ssh + chmod 600 ~/.ssh/authorized_keys +``` + +### Проблема: Контейнеры не стартуют + +```bash +# На VPS +cd /opt/nanaxe-server/server +docker compose -f docker-compose.prod.yml down +docker compose -f docker-compose.prod.yml up -d +docker compose -f docker-compose.prod.yml logs +``` + +### Проблема: Health check failed + +```bash +# Проверьте API +curl http://localhost:8080/health + +# Проверьте Gitea +curl http://localhost:3000/api/healthz +``` + +--- + +## 📊 Workflow файлы + +### Основной деплой: `.github/workflows/deploy.yml` + +- Собирает Docker образ +- Публикует в GitHub Container Registry +- Деплоит на VPS +- Запускает health checks +- Поддерживает rollback + +### PR Checks: `.github/workflows/pr-check.yml` + +- Запускается на Pull Request +- Проверяет код +- Тестирует сборку Docker +- Сканирует уязвимости +- Валидирует docker-compose + +--- + +## 🎉 Готово! + +Теперь при каждом push в `main`: +1. ✅ Код автоматически тестируется +2. ✅ Docker образ собирается +3. ✅ Образ публикуется в GitHub Container Registry +4. ✅ VPS автоматически обновляется +5. ✅ Health checks проверяют работоспособность + +Ваш сервер всегда будет актуальным! 🚀 diff --git a/MOCK_VS_REAL_API.md b/MOCK_VS_REAL_API.md new file mode 100644 index 0000000..7694844 --- /dev/null +++ b/MOCK_VS_REAL_API.md @@ -0,0 +1,162 @@ +# 🔄 Как включить реальный API DeepSeek + +## Текущий режим: MOCK ✅ + +Сейчас приложение работает с **mock данными** (без реальных запросов к API). + +Это означает: +- ✅ Все функции работают без интернета +- ✅ Не расходуется баланс DeepSeek +- ⚠️ Ответы не от AI, а заготовленные шаблоны + +--- + +## Когда включить реальный API + +### Шаг 1: Проверьте баланс DeepSeek + +1. Зайдите на https://platform.deepseek.com/billing +2. Убедитесь что на счету есть средства (минимум $1-2) +3. Если баланс пуст - пополните счёт + +### Шаг 2: Измените режим в коде + +Откройте файл: `lib/core/deepseek_service.dart` + +Найдите строку (примерно строка 12): +```dart +final bool useMockMode = true; +``` + +Измените на: +```dart +final bool useMockMode = false; +``` + +### Шаг 3: Перезапустите приложение + +```bash +flutter run +``` + +--- + +## Как это работает + +### Mock режим (useMockMode = true) +``` +Запрос → DeepSeek Service → Mock данные → Ответ + (без API запросов) +``` + +**Плюсы:** +- Быстро (без сетевых задержек) +- Бесплатно (не нужен API) +- Стабильно (всегда работает) + +**Минусы:** +- Ответы не от AI +- Не адаптированы под контекст + +### Реальный API (useMockMode = false) +``` +Запрос → DeepSeek Service → DeepSeek API → AI ответ + (реальный запрос) +``` + +**Плюсы:** +- Умные ответы от AI +- Адаптация под контекст +- Качественные описания + +**Минусы:** +- Расходует токены (~$0.001-0.01 за запрос) +- Нужен интернет +- Медленнее (1-3 секунды) + +--- + +## Проверка работы API + +После включения реального API, запустите тест: + +```bash +dart run test_deepseek_api.dart +``` + +Если видите ✅ вместо ❌ - API работает! + +--- + +##Fallback режим + +Даже при `useMockMode = false`, если API запрос не прошёл (нет баланса, ошибка сети), +сервис автоматически вернётся к mock данным. + +В консоли увидите: +``` +DeepSeek API Error: 402 - Insufficient Balance +⚠️ Используется mock режим как fallback +``` + +--- + +## Стоимость запросов + +Примерные расходы на DeepSeek API: + +| Действие | Токенов | Стоимость | +|----------|---------|-----------| +| Генерация подзадач | ~500 | $0.0001 | +| Приоритизация | ~100 | $0.00003 | +| Описание задачи | ~200 | $0.00006 | +| Планирование | ~1000 | $0.0003 | +| Mind-карта | ~1500 | $0.0004 | + +**При $2 на счету** можно сделать примерно: +- 20,000 генераций подзадач +- 60,000 приоритизаций +- 5,000 mind-карт + +--- + +## Рекомендации + +### Для разработки и тестирования +Оставьте **mock режим** (`useMockMode = true`) +- Быстрее цикл разработки +- Не тратите токены + +### Для продакшена +Включите **реальный API** (`useMockMode = false`) +- Пользователи получат AI функции +- Расходы минимальные + +--- + +## Troubleshooting + +### Ошибка 402: Insufficient Balance +``` +1. Проверьте баланс: https://platform.deepseek.com/billing +2. Пополните счёт +3. Или верните useMockMode = true +``` + +### Ошибка 401: Invalid API Key +``` +1. Проверьте что ключ в .env начинается с 'sk-' +2. Нет лишних пробелов +3. Ключ активен в Dashboard +``` + +### Ошибка сети +``` +1. Проверьте интернет-соединение +2. DeepSeek может быть недоступен в вашем регионе +3. Сервис автоматически переключится на mock +``` + +--- + +**Готово! 🚀** diff --git a/firestore.rules b/firestore.rules index 9138657..84294ea 100644 --- a/firestore.rules +++ b/firestore.rules @@ -54,13 +54,13 @@ service cloud.firestore { allow delete: if isAuthenticated(); } - // Mind Maps - карты проектов + // Mind Maps - карты проектов (старая структура) match /mindmaps/{projectId} { allow read: if isAuthenticated(); allow create: if isAuthenticated(); allow update: if isAuthenticated(); allow delete: if isAuthenticated(); - + match /nodes/{nodeId} { allow read: if isAuthenticated(); allow create: if isAuthenticated(); @@ -68,6 +68,14 @@ service cloud.firestore { allow delete: if isAuthenticated(); } } + + // Mind Maps - новая структура с ownerEmail и isMain + match /mind_maps/{mindMapId} { + allow read: if isAuthenticated(); + allow create: if isAuthenticated(); + allow update: if isAuthenticated(); + allow delete: if isAuthenticated(); + } // Events - события календаря match /events/{eventId} { diff --git a/lib/core/admin_access_service.dart b/lib/core/admin_access_service.dart new file mode 100644 index 0000000..f1e37d8 --- /dev/null +++ b/lib/core/admin_access_service.dart @@ -0,0 +1,50 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +/// Сервис для проверки прав доступа администраторов +class AdminAccessService { + // Список email'ов администраторов + static const List adminEmails = [ + 'ormix16@gmail.com', + 'almalexyz@gmail.com', + ]; + + /// Проверка является ли пользователь администратором + static Future isAdmin(String userEmail) async { + return adminEmails.contains(userEmail.toLowerCase()); + } + + /// Получить список всех администраторов + static List getAdminEmails() { + return List.from(adminEmails); + } + + /// Добавить администратора (только для Firestore) + static Future addAdmin(String email) async { + if (!adminEmails.contains(email.toLowerCase())) { + await FirebaseFirestore.instance + .collection('admin_access') + .doc(email.toLowerCase()) + .set({ + 'email': email.toLowerCase(), + 'addedAt': Timestamp.now(), + 'role': 'admin', + }); + } + } + + /// Удалить администратора + static Future removeAdmin(String email) async { + await FirebaseFirestore.instance + .collection('admin_access') + .doc(email.toLowerCase()) + .delete(); + } + + /// Получить список администраторов из Firestore + static Future> getAdminsFromFirestore() async { + final snapshot = + await FirebaseFirestore.instance.collection('admin_access').get(); + + return snapshot.docs.map((doc) => doc.id).toList(); + } +} diff --git a/lib/core/deepseek_service.dart b/lib/core/deepseek_service.dart index c365846..bd65845 100644 --- a/lib/core/deepseek_service.dart +++ b/lib/core/deepseek_service.dart @@ -7,14 +7,95 @@ class DeepSeekService { final String? apiKey; final String baseUrl = 'https://api.deepseek.com'; + // 🎯 РЕЖИМ MOCK - установите true для работы без API запросов + // Когда пополните баланс DeepSeek, установите false + final bool useMockMode = true; + DeepSeekService({this.apiKey}); + // AI чат для mind maps + Future> chatCompletion({ + required List> messages, + }) async { + if (useMockMode || apiKey == null || apiKey!.isEmpty) { + return _mockChatResponse(messages); + } + + try { + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': messages, + 'temperature': 0.7, + 'max_tokens': 2000, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final content = data['choices'][0]['message']['content']; + + // Пытаемся распарсить как JSON + final jsonMatch = RegExp(r'\{.*\}', dotAll: true).firstMatch(content); + if (jsonMatch != null) { + return jsonDecode(jsonMatch.group(0)!) as Map; + } + + return {'text': content}; + } else { + print('DeepSeek API Error: ${response.statusCode} - ${response.body}'); + } + } catch (e) { + print('DeepSeek Service Error: $e'); + } + + return _mockChatResponse(messages); + } + + // Mock ответ для чата + Map _mockChatResponse(List> messages) { + final lastMessage = messages.lastWhere( + (m) => m['role'] == 'user', + orElse: () => {'content': ''}, + ); + final content = lastMessage['content'] as String; + + // Простая логика для демонстрации + if (content.toLowerCase().contains('добавь') || + content.toLowerCase().contains('add')) { + return { + 'ACTION': 'add', + 'TARGET': null, + 'CHANGES': [ + { + 'action': 'add', + 'title': 'AI Предложение: $content', + 'level': 0, + 'parentId': null + }, + ], + 'EXPLANATION': 'Я добавил новый узел на основе вашего запроса', + }; + } + + return { + 'text': + 'Понял ваш запрос: "$content". В реальном режиме я предложу изменения для mind map.', + }; + } + // Генерация подзадач из основной задачи Future>> generateSubtasks({ required String title, required String description, }) async { - if (apiKey == null || apiKey!.isEmpty) { + // Возвращаем mock данные пока нет баланса на DeepSeek + if (useMockMode || apiKey == null || apiKey!.isEmpty) { return _mockGenerateSubtasks(title, description); } @@ -56,9 +137,11 @@ class DeepSeekService { } } else { print('DeepSeek API Error: ${response.statusCode} - ${response.body}'); + print('⚠️ Используется mock режим как fallback'); } } catch (e) { print('DeepSeek Service Error: $e'); + print('⚠️ Используется mock режим как fallback'); } return _mockGenerateSubtasks(title, description); @@ -70,7 +153,8 @@ class DeepSeekService { required String description, DateTime? dueDate, }) async { - if (apiKey == null || apiKey!.isEmpty) { + // Возвращаем mock данные пока нет баланса на DeepSeek + if (useMockMode || apiKey == null || apiKey!.isEmpty) { return _mockPrioritizeTask(dueDate); } @@ -115,9 +199,12 @@ class DeepSeekService { default: return TaskPriority.medium; } + } else { + print('⚠️ Используется mock режим как fallback'); } } catch (e) { print('DeepSeek Service Error: $e'); + print('⚠️ Используется mock режим как fallback'); } return _mockPrioritizeTask(dueDate); @@ -128,8 +215,9 @@ class DeepSeekService { required String title, String? context, }) async { - if (apiKey == null || apiKey!.isEmpty) { - return 'Описание для задачи: $title'; + // Возвращаем mock данные пока нет баланса на DeepSeek + if (useMockMode || apiKey == null || apiKey!.isEmpty) { + return _mockGenerateDescription(title, context); } try { @@ -161,12 +249,15 @@ class DeepSeekService { if (response.statusCode == 200) { final data = jsonDecode(response.body); return data['choices'][0]['message']['content']; + } else { + print('⚠️ Используется mock режим как fallback'); } } catch (e) { print('DeepSeek Service Error: $e'); + print('⚠️ Используется mock режим как fallback'); } - return 'Описание для задачи: $title'; + return _mockGenerateDescription(title, context); } // Умное планирование - распределение задач по времени @@ -176,7 +267,8 @@ class DeepSeekService { required DateTime endDate, required int availableHoursPerDay, }) async { - if (apiKey == null || apiKey!.isEmpty) { + // Возвращаем mock данные пока нет баланса на DeepSeek + if (useMockMode || apiKey == null || apiKey!.isEmpty) { return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay); } @@ -238,9 +330,12 @@ Return JSON array: [{"taskId": index, "date": "YYYY-MM-DD", "hours": 0}] }; }).toList(); } + } else { + print('⚠️ Используется mock режим как fallback'); } } catch (e) { print('DeepSeek Service Error: $e'); + print('⚠️ Используется mock режим как fallback'); } return _mockSchedule(tasks, startDate, endDate, availableHoursPerDay); @@ -251,7 +346,8 @@ Return JSON array: [{"taskId": index, "date": "YYYY-MM-DD", "hours": 0}] required String projectName, required String description, }) async { - if (apiKey == null || apiKey!.isEmpty) { + // Возвращаем mock данные пока нет баланса на DeepSeek + if (useMockMode || apiKey == null || apiKey!.isEmpty) { return _mockMindMap(projectName); } @@ -297,9 +393,12 @@ Return JSON array of nodes with format: final List nodes = jsonDecode(jsonMatch.group(0)!); return nodes.cast>(); } + } else { + print('⚠️ Используется mock режим как fallback'); } } catch (e) { print('DeepSeek Service Error: $e'); + print('⚠️ Используется mock режим как fallback'); } return _mockMindMap(projectName); @@ -348,6 +447,24 @@ Return JSON array of nodes with format: return TaskPriority.low; } + String _mockGenerateDescription(String title, String? context) { + final descriptions = { + 'Анализ требований': + 'Изучить и проанализировать все требования проекта. Подготовить документацию с детальным описанием функционала и ожиданий пользователей.', + 'Проектирование': + 'Разработать архитектуру решения, создать схемы базы данных и определить основные компоненты системы.', + 'Реализация': + 'Написать чистый, поддерживаемый код согласно лучшим практикам. Реализовать весь запланированный функционал.', + 'Тестирование': + 'Провести комплексное тестирование: модульные, интеграционные и end-to-end тесты для обеспечения качества.', + 'Деплой': + 'Подготовить инфраструктуру, настроить CI/CD пайплайн и развернуть приложение на production сервере.', + }; + + return descriptions[title] ?? + 'Задача: $title. ${context ?? "Выполнить в срок с надлежащим качеством."}'; + } + List> _mockSchedule( List tasks, DateTime startDate, diff --git a/lib/core/local_storage_service.dart b/lib/core/local_storage_service.dart index 5b0a2c1..c53aa33 100644 --- a/lib/core/local_storage_service.dart +++ b/lib/core/local_storage_service.dart @@ -1,4 +1,5 @@ import 'package:hive_flutter/hive_flutter.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import '../models/task_model.dart'; import '../models/alarm_model.dart'; import '../models/mind_map_model.dart'; @@ -29,20 +30,23 @@ class LocalStorageService { // Tasks Future saveTask(TaskModel task) async { - await _tasksBox.put(task.id, task.toMap()); // Используем toMap() для Hive + final data = _convertTimestamps(task.toFirestore()); + data['id'] = task.id; // Добавляем id для fromMap + await _tasksBox.put(task.id, data); } Future saveTasks(List tasks) async { for (final task in tasks) { - await _tasksBox.put(task.id, task.toMap()); + final data = _convertTimestamps(task.toFirestore()); + data['id'] = task.id; + await _tasksBox.put(task.id, data); } } List getTasks() { return _tasksBox.values.map((data) { - return TaskModel.fromFirestore( - _createMockDocument(data as Map), - ); + final restoredData = _restoreTimestamps(data); + return TaskModel.fromMap(restoredData); }).toList(); } @@ -52,14 +56,14 @@ class LocalStorageService { // Alarms Future saveAlarm(AlarmModel alarm) async { - await _alarmsBox.put(alarm.id, alarm.toFirestore()); + await _alarmsBox.put(alarm.id, _convertTimestamps(alarm.toFirestore())); } List getAlarms() { - return _alarmsBox.values.map((data) { - return AlarmModel.fromFirestore( - _createMockDocument(data as Map), - ); + return _alarmsBox.keys.map((id) { + final data = _alarmsBox.get(id) as Map; + final restoredData = _restoreTimestamps(data); + return AlarmModel.fromMap(id.toString(), restoredData); }).toList(); } @@ -70,28 +74,28 @@ class LocalStorageService { // Mind Maps Future saveMindMapNodes(List nodes) async { for (final node in nodes) { - await _mindMapsBox.put(node.id, node.toFirestore()); + await _mindMapsBox.put(node.id, _convertTimestamps(node.toFirestore())); } } List getMindMapNodes() { - return _mindMapsBox.values.map((data) { - return MindMapNode.fromFirestore( - _createMockDocument(data as Map), - ); + return _mindMapsBox.keys.map((id) { + final data = _mindMapsBox.get(id) as Map; + final restoredData = _restoreTimestamps(data); + return MindMapNode.fromMap(id.toString(), restoredData); }).toList(); } // Events Future saveEvent(CalendarEventModel event) async { - await _eventsBox.put(event.id, event.toFirestore()); + await _eventsBox.put(event.id, _convertTimestamps(event.toFirestore())); } List getEvents() { - return _eventsBox.values.map((data) { - return CalendarEventModel.fromFirestore( - _createMockDocument(data as Map), - ); + return _eventsBox.keys.map((id) { + final data = _eventsBox.get(id) as Map; + final restoredData = _restoreTimestamps(data); + return CalendarEventModel.fromMap(id.toString(), restoredData); }).toList(); } @@ -124,14 +128,49 @@ class LocalStorageService { await _tasksBox.clear(); for (final task in merged.values) { - await _tasksBox.put(task.id, task.toFirestore()); + await _tasksBox.put(task.id, _convertTimestamps(task.toFirestore())); } } - // Helper method to create mock DocumentSnapshot - dynamic _createMockDocument(Map data) { - // Простая эмуляция для конвертации - return data; + // Конвертирует Firestore Timestamp в ISO строки для Hive + Map _convertTimestamps(Map data) { + final converted = {}; + for (final entry in data.entries) { + if (entry.value is Timestamp) { + // Конвертируем Timestamp → ISO строка + converted[entry.key] = { + '__type__': 'timestamp', + 'value': (entry.value as Timestamp).toDate().toIso8601String(), + }; + } else if (entry.value is Map) { + converted[entry.key] = + _convertTimestamps(entry.value as Map); + } else { + converted[entry.key] = entry.value; + } + } + return converted; + } + + // Восстанавливает ISO строки обратно в Timestamp из Hive + Map _restoreTimestamps(Map data) { + final converted = {}; + for (final entry in data.entries) { + final key = entry.key.toString(); + if (entry.value is Map && + (entry.value as Map).containsKey('__type__') && + (entry.value as Map)['__type__'] == 'timestamp') { + // Конвертируем ISO строку → Timestamp + final dateStr = (entry.value as Map)['value'] as String; + converted[key] = Timestamp.fromDate(DateTime.parse(dateStr)); + } else if (entry.value is Map) { + converted[key] = + _restoreTimestamps(entry.value as Map); + } else { + converted[key] = entry.value; + } + } + return converted; } } diff --git a/lib/core/mind_map_adapter.dart b/lib/core/mind_map_adapter.dart new file mode 100644 index 0000000..7873161 --- /dev/null +++ b/lib/core/mind_map_adapter.dart @@ -0,0 +1,100 @@ +import 'package:reactive_mind_map/reactive_mind_map.dart' as rm; +import '../models/mind_map_model.dart'; + +/// Адаптер для конвертации MindMapNode в формат reactive_mind_map +class MindMapAdapter { + /// Конвертирует список MindMapNode в rm.MindMapData для reactive_mind_map + static rm.MindMapData nodesToMindMapData(List nodes) { + if (nodes.isEmpty) { + return rm.MindMapData( + id: 'empty', + title: 'Пустая карта', + children: [], + ); + } + + // Находим корневой узел (level 0) + final rootNodes = nodes.where((n) => n.level == 0).toList(); + + if (rootNodes.isEmpty) { + // Если нет корневого узла, создаём его + return rm.MindMapData( + id: 'auto_root', + title: 'Автоматический корень', + children: _buildChildren(nodes, null, nodes), + ); + } + + final root = rootNodes.first; + return rm.MindMapData( + id: root.id, + title: root.title, + children: _buildChildren(nodes, root.id, nodes), + ); + } + + /// Рекурсивно строит дочерние узлы + static List _buildChildren( + List allNodes, + String? parentId, + List nodes, + ) { + final children = allNodes.where((n) => n.parentId == parentId).toList(); + + return children.map((node) { + return rm.MindMapData( + id: node.id, + title: node.title, + children: _buildChildren(allNodes, node.id, nodes), + ); + }).toList(); + } + + /// Конвертирует rm.MindMapData обратно в список MindMapNode + static List mindMapDataToNodes( + rm.MindMapData data, + String projectId, { + String? parentId, + int level = 0, + }) { + final nodes = []; + final now = DateTime.now(); + + nodes.add(MindMapNode( + id: data.id, + projectId: projectId, + parentId: parentId, + level: level, + title: data.title, + color: '#2196F3', // Default color, можно улучшить хранить цвет в metadata + createdAt: now, + updatedAt: now, + )); + + // Рекурсивно добавляем детей + for (final child in data.children!) { + nodes.addAll(mindMapDataToNodes( + child, + projectId, + parentId: data.id, + level: level + 1, + )); + } + + return nodes; + } + + /// HEX → int (для reactive_mind_map) + static int _hexToColorInt(String hex) { + hex = hex.replaceAll('#', ''); + if (hex.length == 6) { + return int.parse('FF$hex', radix: 16); + } + return 0xFF2196F3; // Default blue + } + + /// int → HEX (для MindMapNode) + static String _colorIntToHex(int color) { + return '#${(color & 0xFFFFFF).toRadixString(16).padLeft(6, '0').toUpperCase()}'; + } +} diff --git a/lib/core/nanaxe_server_service.dart b/lib/core/nanaxe_server_service.dart new file mode 100644 index 0000000..bc568f1 --- /dev/null +++ b/lib/core/nanaxe_server_service.dart @@ -0,0 +1,360 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Сервис для синхронизации с Nanaxe Server +/// Поддерживает работу с mindmaps через REST API +class NanaxeServerService { + static final NanaxeServerService _instance = NanaxeServerService._internal(); + factory NanaxeServerService() => _instance; + NanaxeServerService._internal(); + + String? _baseUrl; + String? _authToken; + + static const String _prefsUrlKey = 'nanaxe_server_url'; + static const String _prefsTokenKey = 'nanaxe_server_token'; + + /// Инициализация сервиса + Future initialize() async { + final prefs = await SharedPreferences.getInstance(); + _baseUrl = prefs.getString(_prefsUrlKey); + _authToken = prefs.getString(_prefsTokenKey); + } + + /// Настройка сервера + Future configure(String baseUrl, {String? token}) async { + _baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''); // Remove trailing slashes + _authToken = token; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsUrlKey, _baseUrl!); + if (token != null) { + await prefs.setString(_prefsTokenKey, token); + } + } + + /// Очистка конфигурации + Future clearConfiguration() async { + _baseUrl = null; + _authToken = null; + + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_prefsUrlKey); + await prefs.remove(_prefsTokenKey); + } + + /// Проверка, настроен ли сервер + bool get isConfigured => _baseUrl != null; + + /// Получение базового URL + String? get baseUrl => _baseUrl; + + // ==================== AUTH ==================== + + /// Регистрация нового пользователя + Future> register(String email, String password, String username) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.post( + Uri.parse('$_baseUrl/api/auth/register'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'email': email, + 'password': password, + 'username': username, + }), + ); + + final data = jsonDecode(response.body); + + if (response.statusCode == 201) { + _authToken = data['token']; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsTokenKey, _authToken!); + return data; + } else { + throw Exception(data['error'] ?? 'Registration failed'); + } + } + + /// Вход в систему + Future> login(String email, String password) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.post( + Uri.parse('$_baseUrl/api/auth/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'email': email, + 'password': password, + }), + ); + + final data = jsonDecode(response.body); + + if (response.statusCode == 200) { + _authToken = data['token']; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsTokenKey, _authToken!); + return data; + } else { + throw Exception(data['error'] ?? 'Login failed'); + } + } + + /// Проверка валидности токена + Future validateToken() async { + if (_baseUrl == null || _authToken == null) return false; + + try { + final response = await http.get( + Uri.parse('$_baseUrl/api/auth/validate'), + headers: _getHeaders(), + ); + + final data = jsonDecode(response.body); + return data['valid'] == true; + } catch (e) { + return false; + } + } + + // ==================== MINDMAPS ==================== + + /// Получение списка всех mindmaps + Future>> getMindMaps() async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.get( + Uri.parse('$_baseUrl/api/mindmaps'), + headers: _getHeaders(), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return List>.from(data['mindmaps'] ?? []); + } else { + throw Exception('Failed to load mindmaps'); + } + } + + /// Получение одной mindmap + Future> getMindMap(String id) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.get( + Uri.parse('$_baseUrl/api/mindmaps/$id'), + headers: _getHeaders(), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to load mindmap'); + } + } + + /// Создание новой mindmap + Future> createMindMap({ + required String title, + String? description, + List>? nodes, + Map? settings, + }) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.post( + Uri.parse('$_baseUrl/api/mindmaps'), + headers: _getHeaders(), + body: jsonEncode({ + 'title': title, + 'description': description ?? '', + 'nodes': nodes ?? [], + 'settings': settings ?? {}, + }), + ); + + if (response.statusCode == 201) { + return jsonDecode(response.body); + } else { + final data = jsonDecode(response.body); + throw Exception(data['error'] ?? 'Failed to create mindmap'); + } + } + + /// Обновление mindmap + Future> updateMindMap( + String id, { + String? title, + String? description, + List>? nodes, + Map? settings, + }) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final body = {}; + if (title != null) body['title'] = title; + if (description != null) body['description'] = description; + if (nodes != null) body['nodes'] = nodes; + if (settings != null) body['settings'] = settings; + + final response = await http.put( + Uri.parse('$_baseUrl/api/mindmaps/$id'), + headers: _getHeaders(), + body: jsonEncode(body), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to update mindmap'); + } + } + + /// Удаление mindmap + Future deleteMindMap(String id) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.delete( + Uri.parse('$_baseUrl/api/mindmaps/$id'), + headers: _getHeaders(), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to delete mindmap'); + } + } + + /// Получение mindmap как Markdown + Future getMindMapAsMarkdown(String id) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.get( + Uri.parse('$_baseUrl/api/mindmaps/$id/markdown'), + headers: _getHeaders(), + ); + + if (response.statusCode == 200) { + return response.body; + } else { + throw Exception('Failed to load markdown'); + } + } + + /// Сохранение mindmap из Markdown + Future> saveMindMapFromMarkdown( + String id, + String markdown, + ) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.post( + Uri.parse('$_baseUrl/api/mindmaps/$id/markdown'), + headers: _getHeaders(), + body: jsonEncode({'markdown': markdown}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to save markdown'); + } + } + + // ==================== SYNC ==================== + + /// Получение статуса синхронизации + Future> getSyncStatus() async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.get( + Uri.parse('$_baseUrl/api/sync/status'), + headers: _getHeaders(), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to get sync status'); + } + } + + /// Push изменений в Gitea + Future> pushToGitea({String? commitMessage}) async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.post( + Uri.parse('$_baseUrl/api/sync/gitea/push'), + headers: _getHeaders(), + body: jsonEncode({ + 'commitMessage': commitMessage ?? 'Sync from Nanaxe CRM', + }), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to push to Gitea'); + } + } + + /// Pull изменений из Gitea + Future> pullFromGitea() async { + if (_baseUrl == null) throw Exception('Server not configured'); + + final response = await http.post( + Uri.parse('$_baseUrl/api/sync/gitea/pull'), + headers: _getHeaders(), + body: jsonEncode({}), + ); + + if (response.statusCode == 200) { + return jsonDecode(response.body); + } else { + throw Exception('Failed to pull from Gitea'); + } + } + + // ==================== HELPERS ==================== + + Map _getHeaders() { + final headers = { + 'Content-Type': 'application/json', + }; + + if (_authToken != null) { + headers['Authorization'] = 'Bearer $_authToken'; + } + + return headers; + } + + /// Проверка соединения с сервером + Future checkConnection() async { + if (_baseUrl == null) return false; + + try { + final response = await http + .get(Uri.parse('$_baseUrl/health')) + .timeout(const Duration(seconds: 5)); + return response.statusCode == 200; + } catch (e) { + return false; + } + } +} + +/// Провайдер для работы с сервером +class ServerConfig { + final String baseUrl; + final String? token; + final bool isConnected; + + ServerConfig({ + required this.baseUrl, + this.token, + this.isConnected = false, + }); +} diff --git a/lib/core/router.dart b/lib/core/router.dart index 319b6f1..6ea3b88 100644 --- a/lib/core/router.dart +++ b/lib/core/router.dart @@ -6,13 +6,17 @@ import '../screens/home_screen.dart'; import '../screens/tasks_screen.dart'; import '../screens/task_form_screen.dart'; import '../screens/mind_map_screen.dart'; +import '../screens/mind_map_settings_screen.dart'; import '../screens/calendar_screen.dart'; import '../screens/alarms_screen.dart'; import '../screens/ai_test_screen.dart'; +import '../screens/profile_screen.dart'; import '../screens/admin/admin_screen.dart'; import '../screens/admin/team_management_screen.dart'; import '../screens/admin/projects_management_screen.dart'; import '../screens/admin/notifications_settings_screen.dart'; +import '../screens/admin/mind_map_admin_screen.dart'; +import '../screens/admin/ai_stats_screen.dart'; import '../providers/auth_provider.dart'; // Глобальный ключ для навигации @@ -67,6 +71,17 @@ class AppRouter { path: 'mindmap', name: 'mindmap', builder: (context, state) => const MindMapScreen(), + routes: [ + GoRoute( + path: 'settings', + name: 'mindmap-settings', + builder: (context, state) { + final projectId = + state.uri.queryParameters['projectId'] ?? 'default'; + return MindMapSettingsScreen(projectId: projectId); + }, + ), + ], ), GoRoute( path: 'calendar', @@ -78,6 +93,11 @@ class AppRouter { name: 'alarms', builder: (context, state) => const AlarmsScreen(), ), + GoRoute( + path: 'profile', + name: 'profile', + builder: (context, state) => const ProfileScreen(), + ), GoRoute( path: 'ai-test', name: 'ai-test', @@ -104,6 +124,16 @@ class AppRouter { builder: (context, state) => const NotificationsSettingsScreen(), ), + GoRoute( + path: 'mindmaps', + name: 'admin-mindmaps', + builder: (context, state) => const MindMapAdminScreen(), + ), + GoRoute( + path: 'ai-stats', + name: 'admin-ai-stats', + builder: (context, state) => const AIStatsScreen(), + ), ], ), ], diff --git a/lib/models/alarm_model.dart b/lib/models/alarm_model.dart index ff7394c..57e1911 100644 --- a/lib/models/alarm_model.dart +++ b/lib/models/alarm_model.dart @@ -35,8 +35,12 @@ class AlarmModel { factory AlarmModel.fromFirestore(DocumentSnapshot doc) { final data = doc.data() as Map; + return AlarmModel.fromMap(doc.id, data); + } + + factory AlarmModel.fromMap(String id, Map data) { return AlarmModel( - id: doc.id, + id: id, title: data['title'] ?? '', taskId: data['taskId'], eventId: data['eventId'], @@ -107,10 +111,10 @@ class AlarmModel { DateTime get nextOccurrence { if (!isRepeating) return dateTime; - + final now = DateTime.now(); if (dateTime.isAfter(now)) return dateTime; - + switch (repeatPattern) { case 'daily': return dateTime.add(const Duration(days: 1)); @@ -124,7 +128,8 @@ class AlarmModel { while (!repeatDays.contains(next.weekday)) { next = next.add(const Duration(days: 1)); } - return DateTime(next.year, next.month, next.day, dateTime.hour, dateTime.minute); + return DateTime( + next.year, next.month, next.day, dateTime.hour, dateTime.minute); default: return dateTime; } diff --git a/lib/models/calendar_event_model.dart b/lib/models/calendar_event_model.dart index a1f98d2..5d29c84 100644 --- a/lib/models/calendar_event_model.dart +++ b/lib/models/calendar_event_model.dart @@ -43,8 +43,12 @@ class CalendarEventModel { factory CalendarEventModel.fromFirestore(DocumentSnapshot doc) { final data = doc.data() as Map; + return CalendarEventModel.fromMap(doc.id, data); + } + + factory CalendarEventModel.fromMap(String id, Map data) { return CalendarEventModel( - id: doc.id, + id: id, title: data['title'] ?? '', description: data['description'], location: data['location'] ?? '', diff --git a/lib/models/mind_map_model.dart b/lib/models/mind_map_model.dart index 99f0576..f127c4f 100644 --- a/lib/models/mind_map_model.dart +++ b/lib/models/mind_map_model.dart @@ -35,8 +35,12 @@ class MindMapNode { factory MindMapNode.fromFirestore(DocumentSnapshot doc) { final data = doc.data() as Map; + return MindMapNode.fromMap(doc.id, data); + } + + factory MindMapNode.fromMap(String id, Map data) { return MindMapNode( - id: doc.id, + id: id, projectId: data['projectId'] ?? '', parentId: data['parentId'], level: data['level'] ?? 0, diff --git a/lib/models/mindmap_model.dart b/lib/models/mindmap_model.dart new file mode 100644 index 0000000..8e186b2 --- /dev/null +++ b/lib/models/mindmap_model.dart @@ -0,0 +1,88 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +class MindMapModel { + final String id; + final String title; + final String description; + final String ownerEmail; + final bool isMain; // Основные карты (админские) vs Личные + final List> nodes; + final Map settings; + final DateTime createdAt; + final DateTime updatedAt; + + MindMapModel({ + required this.id, + required this.title, + this.description = '', + required this.ownerEmail, + this.isMain = false, + this.nodes = const [], + this.settings = const {}, + required this.createdAt, + required this.updatedAt, + }); + + factory MindMapModel.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + return MindMapModel.fromMap(doc.id, data); + } + + factory MindMapModel.fromMap(String id, Map data) { + return MindMapModel( + id: id, + title: data['title'] ?? '', + description: data['description'] ?? '', + ownerEmail: data['ownerEmail'] ?? '', + isMain: data['isMain'] ?? false, + nodes: List>.from(data['nodes'] ?? []), + settings: data['settings'] ?? {}, + createdAt: (data['createdAt'] as Timestamp).toDate(), + updatedAt: (data['updatedAt'] as Timestamp).toDate(), + ); + } + + Map toFirestore() { + return { + 'title': title, + 'description': description, + 'ownerEmail': ownerEmail, + 'isMain': isMain, + 'nodes': nodes, + 'settings': settings, + 'createdAt': Timestamp.fromDate(createdAt), + 'updatedAt': Timestamp.fromDate(updatedAt), + }; + } + + MindMapModel copyWith({ + String? id, + String? title, + String? description, + String? ownerEmail, + bool? isMain, + List>? nodes, + Map? settings, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MindMapModel( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + ownerEmail: ownerEmail ?? this.ownerEmail, + isMain: isMain ?? this.isMain, + nodes: nodes ?? this.nodes, + settings: settings ?? this.settings, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + int get nodeCount => nodes.length; + int get maxLevel { + if (nodes.isEmpty) return 0; + return nodes.fold( + 0, (max, node) => max > (node['level'] ?? 0) ? max : (node['level'] ?? 0)); + } +} diff --git a/lib/models/project_model.dart b/lib/models/project_model.dart index 3434245..8d9b941 100644 --- a/lib/models/project_model.dart +++ b/lib/models/project_model.dart @@ -34,24 +34,33 @@ class ProjectModel { }); factory ProjectModel.fromFirestore(DocumentSnapshot doc) { - final data = doc.data() as Map; + final data = doc.data(); + if (data == null) { + throw Exception('Document data is null'); + } + + final mapData = data is Map + ? data + : (data as Map) + .map((k, v) => MapEntry(k.toString(), v)); + return ProjectModel( id: doc.id, - name: data['name'] ?? '', - description: data['description'] ?? '', - teamId: data['teamId'] ?? '', - ownerId: data['ownerId'] ?? '', - memberIds: List.from(data['memberIds'] ?? []), - status: data['status'] ?? 'active', - startDate: (data['startDate'] as Timestamp).toDate(), - endDate: data['endDate'] != null - ? (data['endDate'] as Timestamp).toDate() + name: mapData['name'] ?? '', + description: mapData['description'] ?? '', + teamId: mapData['teamId'] ?? '', + ownerId: mapData['ownerId'] ?? '', + memberIds: List.from(mapData['memberIds'] ?? []), + status: mapData['status'] ?? 'active', + startDate: (mapData['startDate'] as Timestamp).toDate(), + endDate: mapData['endDate'] != null + ? (mapData['endDate'] as Timestamp).toDate() : null, - color: data['color'] ?? '#1976D2', - icon: data['icon'] ?? 'project', - settings: data['settings'] ?? {}, - createdAt: (data['createdAt'] as Timestamp).toDate(), - updatedAt: (data['updatedAt'] as Timestamp).toDate(), + color: mapData['color'] ?? '#1976D2', + icon: mapData['icon'] ?? 'project', + settings: mapData['settings'] ?? {}, + createdAt: (mapData['createdAt'] as Timestamp).toDate(), + updatedAt: (mapData['updatedAt'] as Timestamp).toDate(), ); } diff --git a/lib/providers/mind_maps_provider.dart b/lib/providers/mind_maps_provider.dart new file mode 100644 index 0000000..759a4db --- /dev/null +++ b/lib/providers/mind_maps_provider.dart @@ -0,0 +1,178 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:uuid/uuid.dart'; +import '../models/mindmap_model.dart'; +import '../core/admin_access_service.dart'; +import '../core/deepseek_service.dart'; + +// Provider для получения текущего email пользователя +final currentUserEmailProvider = Provider((ref) { + return FirebaseAuth.instance.currentUser?.email; +}); + +// Provider проверки является ли пользователь админом +final isAdminProvider = Provider((ref) { + final email = ref.watch(currentUserEmailProvider); + if (email == null) return false; + return AdminAccessService.adminEmails.contains(email.toLowerCase()); +}); + +// Provider списка mind maps +final mindMapsProvider = StreamProvider>((ref) { + return FirebaseFirestore.instance + .collection('mind_maps') + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs.map((doc) { + return MindMapModel.fromFirestore(doc); + }).toList(); + }); +}); + +// Notifier для управления mind maps +class MindMapsNotifier extends StateNotifier>> { + final Ref ref; + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final FirebaseAuth _auth = FirebaseAuth.instance; + + MindMapsNotifier(this.ref) : super(const AsyncValue.loading()) { + _listenToMindMaps(); + } + + void _listenToMindMaps() { + _firestore + .collection('mind_maps') + .orderBy('createdAt', descending: true) + .snapshots() + .listen( + (snapshot) { + final mindMaps = snapshot.docs.map((doc) { + return MindMapModel.fromFirestore(doc); + }).toList(); + state = AsyncValue.data(mindMaps); + }, + onError: (error, st) { + state = AsyncValue.error(error, st); + }, + ); + } + + // Создание новой mind map + Future createMindMap({ + required String title, + String description = '', + required List> nodes, + bool isMain = false, + }) async { + try { + final userEmail = _auth.currentUser?.email ?? 'unknown'; + final now = DateTime.now(); + final id = const Uuid().v4(); + + final mindMap = MindMapModel( + id: id, + title: title, + description: description, + ownerEmail: userEmail, + isMain: isMain, + nodes: nodes, + createdAt: now, + updatedAt: now, + ); + + await _firestore.collection('mind_maps').doc(id).set(mindMap.toFirestore()); + + return id; + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + // Удаление mind map (админы могут удалять любые, обычные - только свои) + Future deleteMindMap(String id, String ownerEmail) async { + try { + final isAdmin = ref.read(isAdminProvider); + final currentUserEmail = _auth.currentUser?.email ?? ''; + + // Проверка прав + if (!isAdmin && ownerEmail != currentUserEmail) { + throw Exception('Нет прав для удаления этой mind map'); + } + + await _firestore.collection('mind_maps').doc(id).delete(); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + // Обновление mind map + Future updateMindMap(String id, MindMapModel mindMap) async { + try { + final isAdmin = ref.read(isAdminProvider); + final currentUserEmail = _auth.currentUser?.email ?? ''; + + // Проверка прав + if (!isAdmin && mindMap.ownerEmail != currentUserEmail) { + throw Exception('Нет прав для редактирования этой mind map'); + } + + await _firestore + .collection('mind_maps') + .doc(id) + .update(mindMap.copyWith(updatedAt: DateTime.now()).toFirestore()); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + // AI генерация mind map + Future generateWithAI({ + required String title, + required String description, + }) async { + try { + final nodesData = await deepSeekService.generateMindMap( + projectName: title, + description: description, + ); + + final nodes = >[]; + for (var i = 0; i < nodesData.length; i++) { + final nodeData = nodesData[i]; + nodes.add({ + 'id': 'node_$i', + 'level': nodeData['level'] ?? 0, + 'parentId': nodeData['parentId'], + 'title': nodeData['title'] ?? '', + 'color': _getColorForLevel(nodeData['level'] ?? 0), + }); + } + + return await createMindMap( + title: title, + description: description, + nodes: nodes, + ); + } catch (e, st) { + state = AsyncValue.error(e, st); + rethrow; + } + } + + String _getColorForLevel(int level) { + final colors = [ + '#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4' + ]; + return colors[level % colors.length]; + } +} + +final mindMapsNotifierProvider = + StateNotifierProvider>>( + (ref) => MindMapsNotifier(ref), +); diff --git a/lib/providers/mindmap_provider.dart b/lib/providers/mindmap_provider.dart index 1bcc6a1..e648cf3 100644 --- a/lib/providers/mindmap_provider.dart +++ b/lib/providers/mindmap_provider.dart @@ -1,13 +1,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; +import 'package:reactive_mind_map/reactive_mind_map.dart' as rm; import '../models/mind_map_model.dart'; -import '../models/task_model.dart'; import '../core/firestore_service.dart'; import '../core/local_storage_service.dart'; import '../core/deepseek_service.dart'; +import '../core/mind_map_adapter.dart'; // Provider для mind-карты проекта -final mindMapProvider = StateNotifierProvider.family, String>((ref, projectId) { +final mindMapProvider = StateNotifierProvider.family, String>((ref, projectId) { return MindMapNotifier(projectId); }); @@ -31,9 +33,16 @@ class MindMapData { // Получить родительский узел MindMapNode? getParent(String nodeId) { - final node = nodes.firstWhere((n) => n.id == nodeId, orElse: () => throw Exception('Node not found')); + final node = nodes.firstWhere((n) => n.id == nodeId, + orElse: () => throw Exception('Node not found')); if (node.parentId == null) return null; - return nodes.firstWhere((n) => n.id == node.parentId, orElse: () => throw Exception('Parent not found')); + return nodes.firstWhere((n) => n.id == node.parentId, + orElse: () => throw Exception('Parent not found')); + } + + // Конвертировать в reactive_mind_map формат + rm.MindMapData toReactiveMindMapData() { + return MindMapAdapter.nodesToMindMapData(nodes); } } @@ -49,8 +58,9 @@ class MindMapNotifier extends StateNotifier> { try { // Загружаем из локального хранилища final localNodes = localStorageService.getMindMapNodes(); - final projectNodes = localNodes.where((n) => n.projectId == projectId).toList(); - + final projectNodes = + localNodes.where((n) => n.projectId == projectId).toList(); + state = AsyncValue.data(_createMindMapData(projectNodes)); // TODO: Загрузить из Firestore и синхронизировать @@ -90,7 +100,7 @@ class MindMapNotifier extends StateNotifier> { String icon = 'folder', }) async { try { - final parentNode = parentId != null + final parentNode = parentId != null ? state.value?.nodes.firstWhere((n) => n.id == parentId) : null; @@ -123,11 +133,11 @@ class MindMapNotifier extends StateNotifier> { final currentData = state.value; if (currentData != null) { final updatedNodes = [...currentData.nodes, node]; - + if (parentId != null) { currentData.childrenMap.putIfAbsent(parentId, () => []).add(node.id); } - + state = AsyncValue.data(MindMapData( nodes: updatedNodes, childrenMap: currentData.childrenMap, @@ -143,7 +153,8 @@ class MindMapNotifier extends StateNotifier> { } // Обновление узла - Future updateNode(String nodeId, { + Future updateNode( + String nodeId, { String? title, String? color, String? icon, @@ -203,7 +214,9 @@ class MindMapNotifier extends StateNotifier> { _collectChildNodes(nodeId, currentData, nodesToDelete); // Сохраняем для удаления - final nodes = currentData.nodes.where((n) => !nodesToDelete.contains(n.id)).toList(); + final nodes = currentData.nodes + .where((n) => !nodesToDelete.contains(n.id)) + .toList(); // Сохраняем локально (удаляем) for (final id in nodesToDelete) { @@ -234,7 +247,8 @@ class MindMapNotifier extends StateNotifier> { } // Рекурсивный сбор дочерних узлов - void _collectChildNodes(String parentId, MindMapData data, Set result) { + void _collectChildNodes( + String parentId, MindMapData data, Set result) { final childIds = data.childrenMap[parentId] ?? []; for (final childId in childIds) { result.add(childId); @@ -260,7 +274,7 @@ class MindMapNotifier extends StateNotifier> { final nodeData = nodesData[i]; final level = nodeData['level'] as int; final parentId = nodeData['parentId'] as int?; - + String? actualParentId; if (parentId != null && parentId < nodes.length) { actualParentId = nodes[parentId].id; @@ -272,16 +286,16 @@ class MindMapNotifier extends StateNotifier> { x: (level * 200).toDouble(), // Автоматическое позиционирование y: (i * 50).toDouble(), ); - + nodes.add(node); - + if (level == 0) { rootNode = node; } } return nodes; - } catch (e, st) { + } catch (e) { rethrow; } } @@ -300,16 +314,18 @@ class MindMapNotifier extends StateNotifier> { return { 'projectId': projectId, 'rootNodeId': data.rootNodeId, - 'nodes': data.nodes.map((n) => { - 'id': n.id, - 'parentId': n.parentId, - 'level': n.level, - 'title': n.title, - 'x': n.x, - 'y': n.y, - 'color': n.color, - 'icon': n.icon, - }).toList(), + 'nodes': data.nodes + .map((n) => { + 'id': n.id, + 'parentId': n.parentId, + 'level': n.level, + 'title': n.title, + 'x': n.x, + 'y': n.y, + 'color': n.color, + 'icon': n.icon, + }) + .toList(), }; } diff --git a/lib/providers/task_provider.dart b/lib/providers/task_provider.dart index 0334adc..14fd8a8 100644 --- a/lib/providers/task_provider.dart +++ b/lib/providers/task_provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:uuid/uuid.dart'; import '../models/task_model.dart'; -import '../models/project_model.dart'; import '../core/firestore_service.dart'; import '../core/local_storage_service.dart'; import '../core/deepseek_service.dart'; @@ -253,7 +252,7 @@ class TasksNotifier extends StateNotifier>> { } return subtasks; - } catch (e, st) { + } catch (e) { rethrow; } } diff --git a/lib/screens/admin/ai_stats_screen.dart b/lib/screens/admin/ai_stats_screen.dart new file mode 100644 index 0000000..4c9f4d9 --- /dev/null +++ b/lib/screens/admin/ai_stats_screen.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AIStatsScreen extends ConsumerStatefulWidget { + const AIStatsScreen({super.key}); + + @override + ConsumerState createState() => _AIStatsScreenState(); +} + +class _AIStatsScreenState extends ConsumerState { + int _totalRequests = 0; + int _totalTokens = 0; + int _mockRequests = 0; + int _realRequests = 0; + Map _requestsByType = {}; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadStats(); + } + + Future _loadStats() async { + try { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _totalRequests = prefs.getInt('ai_total_requests') ?? 0; + _totalTokens = prefs.getInt('ai_total_tokens') ?? 0; + _mockRequests = prefs.getInt('ai_mock_requests') ?? 0; + _realRequests = prefs.getInt('ai_real_requests') ?? 0; + _requestsByType = { + 'Генерация подзадач': prefs.getInt('ai_subtasks') ?? 0, + 'Приоритизация': prefs.getInt('ai_priority') ?? 0, + 'Описания': prefs.getInt('ai_descriptions') ?? 0, + 'Mind-карты': prefs.getInt('ai_mindmaps') ?? 0, + 'Планирование': prefs.getInt('ai_scheduling') ?? 0, + }; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + } + } + + Future _resetStats() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Сбросить статистику?'), + content: const Text('Все данные об использовании AI будут удалены'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Сбросить'), + ), + ], + ), + ); + + if (confirmed == true) { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('ai_total_requests'); + await prefs.remove('ai_total_tokens'); + await prefs.remove('ai_mock_requests'); + await prefs.remove('ai_real_requests'); + await prefs.remove('ai_subtasks'); + await prefs.remove('ai_priority'); + await prefs.remove('ai_descriptions'); + await prefs.remove('ai_mindmaps'); + await prefs.remove('ai_scheduling'); + + await _loadStats(); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('AI Статистика'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadStats, + tooltip: 'Обновить', + ), + IconButton( + icon: const Icon(Icons.delete_sweep), + onPressed: _resetStats, + tooltip: 'Сбросить', + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Общая статистика + _buildStatCard( + 'Общая статистика', + [ + _buildBigStat('Всего запросов', _totalRequests.toString()), + const Divider(), + _buildStatRow('Mock запросов', _mockRequests.toString()), + _buildStatRow('Реальных запросов', _realRequests.toString()), + _buildStatRow('Использовано токенов', _totalTokens.toString()), + ], + ), + const SizedBox(height: 24), + + // Запросы по типам + _buildStatCard( + 'Запросы по типам', + _requestsByType.entries + .map((e) => _buildStatRow(e.key, e.value.toString())) + .toList(), + ), + const SizedBox(height: 24), + + // График использования (простой) + _buildStatCard( + 'Использование', + [ + SizedBox( + height: 150, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.end, + children: _requestsByType.entries.map((entry) { + final max = _requestsByType.values.fold( + 1, (prev, element) => prev > element ? prev : element); + final height = (entry.value / max) * 100; + return Column( + children: [ + Text(entry.value.toString()), + const SizedBox(height: 4), + Container( + width: 30, + height: height, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.blue, Colors.blueAccent], + ), + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: 60, + child: Text( + entry.key.split(' ')[0], + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + }).toList(), + ), + ), + ], + ), + const SizedBox(height: 24), + + // Информация + _buildInfoCard(), + ], + ), + ); + } + + Widget _buildStatCard(String title, List children) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...children, + ], + ), + ); + } + + Widget _buildBigStat(String label, String value) { + return Column( + children: [ + Text( + value, + style: const TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + const SizedBox(height: 4), + Text(label, style: TextStyle(color: Colors.grey[600])), + ], + ); + } + + Widget _buildStatRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey[600])), + Text( + value, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + ); + } + + Widget _buildInfoCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.blue.withOpacity(0.3)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.info_outline, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Информация', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + ], + ), + const SizedBox(height: 12), + const Text( + '• Сейчас работает MOCK режим\n' + '• Реальные запросы к DeepSeek отключены\n' + '• Токены не расходуются\n\n' + 'Для включения реального AI:\n' + '1. Пополните баланс DeepSeek\n' + '2. Установите useMockMode = false\n' + '3. Перезапустите приложение', + style: TextStyle(fontSize: 13), + ), + ], + ), + ); + } +} diff --git a/lib/screens/admin/mind_map_admin_screen.dart b/lib/screens/admin/mind_map_admin_screen.dart new file mode 100644 index 0000000..0808460 --- /dev/null +++ b/lib/screens/admin/mind_map_admin_screen.dart @@ -0,0 +1,436 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'dart:convert'; + +class MindMapAdminScreen extends ConsumerStatefulWidget { + const MindMapAdminScreen({super.key}); + + @override + ConsumerState createState() => _MindMapAdminScreenState(); +} + +class _MindMapAdminScreenState extends ConsumerState { + String _searchQuery = ''; + String? _filterProject; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Управление Mind Maps'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => setState(() {}), + tooltip: 'Обновить', + ), + ], + ), + body: Column( + children: [ + _buildSearchBar(), + _buildFilterBar(), + Expanded(child: _buildMindMapsList()), + ], + ), + ); + } + + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.all(16), + child: TextField( + decoration: InputDecoration( + hintText: 'Поиск mind-карт...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _searchQuery = ''), + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onChanged: (value) => setState(() => _searchQuery = value), + ), + ); + } + + Widget _buildFilterBar() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.filter_list, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + FilterChip( + label: const Text('Все проекты'), + selected: _filterProject == null, + onSelected: (selected) => + setState(() => _filterProject = null), + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Проект A'), + selected: _filterProject == 'project_a', + onSelected: (selected) => + setState(() => _filterProject = 'project_a'), + ), + const SizedBox(width: 8), + FilterChip( + label: const Text('Проект B'), + selected: _filterProject == 'project_b', + onSelected: (selected) => + setState(() => _filterProject = 'project_b'), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildMindMapsList() { + return StreamBuilder( + stream: FirebaseFirestore.instance + .collection('mind_map_nodes') + .orderBy('createdAt', descending: true) + .snapshots(), + builder: (context, snapshot) { + if (snapshot.hasError) { + print('Firestore error: ${snapshot.error}'); + return _buildErrorState(snapshot.error); + } + + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + final allDocs = snapshot.data?.docs ?? []; + print('Total docs from Firestore: ${allDocs.length}'); + + // Преобразуем в Map для безопасной работы + final mindMaps = >[]; + for (final doc in allDocs) { + try { + final data = doc.data() as Map; + data['_id'] = doc.id; + mindMaps.add(data); + } catch (e) { + print('Error converting doc: $e'); + } + } + + // Фильтруем по проекту + final filtered = mindMaps.where((data) { + final projectId = data['projectId'] ?? 'unknown'; + final title = data['title']?.toString().toLowerCase() ?? ''; + + final matchesProject = + _filterProject == null || projectId == _filterProject; + final matchesSearch = title.contains(_searchQuery.toLowerCase()); + + return matchesProject && matchesSearch; + }).toList(); + + if (filtered.isEmpty) { + return _buildEmptyState(); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + itemBuilder: (context, index) { + final data = filtered[index]; + final id = data['_id'] as String; + return _buildMindMapCard(id, data); + }, + ); + }, + ); + } + + Widget _buildMindMapCard(String id, Map data) { + final title = data['title'] ?? 'Без названия'; + final projectId = data['projectId'] ?? 'unknown'; + final createdAt = data['createdAt'] != null + ? (data['createdAt'] as Timestamp).toDate() + : DateTime.now(); + final nodeCount = data['nodeCount'] ?? 0; + final maxLevel = data['maxLevel'] ?? 0; + final createdBy = data['createdBy'] ?? 'unknown'; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ExpansionTile( + leading: const Icon(Icons.account_tree, color: Colors.blue), + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Проект: $projectId'), + Text('Создана: ${_formatDate(createdAt)}'), + ], + ), + trailing: PopupMenuButton( + onSelected: (value) => _handleAction(id, value, data), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'view', + child: Row( + children: [ + Icon(Icons.visibility, size: 20), + SizedBox(width: 12), + Text('Просмотр'), + ], + ), + ), + const PopupMenuItem( + value: 'export', + child: Row( + children: [ + Icon(Icons.download, size: 20), + SizedBox(width: 12), + Text('Экспорт'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red, size: 20), + SizedBox(width: 12), + Text('Удалить', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatRow('Узлов', nodeCount.toString()), + _buildStatRow('Уровней', maxLevel.toString()), + _buildStatRow('Автор', createdBy), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _handleAction(id, 'export', data), + icon: const Icon(Icons.download, size: 18), + label: const Text('Экспорт JSON'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: ElevatedButton.icon( + onPressed: () => _handleAction(id, 'delete', data), + icon: const Icon(Icons.delete, size: 18), + label: const Text('Удалить'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildStatRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey[600])), + Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_tree, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Mind-карты не найдены', + style: TextStyle(fontSize: 18, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Создайте первую mind-карту', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildErrorState(dynamic error) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text('Ошибка: $error'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => setState(() {}), + child: const Text('Повторить'), + ), + ], + ), + ); + } + + Future _handleAction( + String id, String action, Map data) async { + switch (action) { + case 'view': + // TODO: Открыть mind map + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Просмотр в разработке')), + ); + break; + + case 'export': + await _exportMindMap(id, data); + break; + + case 'delete': + await _confirmDelete(id, data['title'] ?? 'Без названия'); + break; + } + } + + Future _exportMindMap(String id, Map data) async { + try { + final jsonData = { + 'id': id, + 'title': data['title'], + 'projectId': data['projectId'], + 'createdAt': data['createdAt']?.toString(), + 'data': data, + }; + + final jsonStr = const JsonEncoder.withIndent(' ').convert(jsonData); + + // TODO: Сохранить в файл + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('📤 Экспорт: скопируйте JSON из консоли'), + backgroundColor: Colors.green, + ), + ); + print('=== EXPORT JSON ==='); + print(jsonStr); + print('=== END EXPORT ==='); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ Ошибка экспорта: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _confirmDelete(String id, String title) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить mind-карту?'), + content: Text( + '"$title" будет удалена навсегда. Это действие нельзя отменить.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], + ), + ); + + if (confirmed == true) { + await _deleteMindMap(id); + } + } + + Future _deleteMindMap(String id) async { + try { + await FirebaseFirestore.instance + .collection('mind_map_nodes') + .doc(id) + .delete(); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Mind-карта удалена'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ Ошибка удаления: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + + if (diff.inMinutes < 1) return 'только что'; + if (diff.inMinutes < 60) return '${diff.inMinutes} мин. назад'; + if (diff.inHours < 24) return '${diff.inHours} ч. назад'; + if (diff.inDays < 7) return '${diff.inDays} дн. назад'; + + return '${date.day}.${date.month}.${date.year}'; + } +} diff --git a/lib/screens/admin/projects_management_screen.dart b/lib/screens/admin/projects_management_screen.dart index c0e0c93..a7ca5cc 100644 --- a/lib/screens/admin/projects_management_screen.dart +++ b/lib/screens/admin/projects_management_screen.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:uuid/uuid.dart'; import '../../providers/auth_provider.dart'; diff --git a/lib/screens/alarms_screen.dart b/lib/screens/alarms_screen.dart index 717709f..c909740 100644 --- a/lib/screens/alarms_screen.dart +++ b/lib/screens/alarms_screen.dart @@ -1,49 +1,260 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../models/alarm_model.dart'; +import '../core/notification_service.dart'; +import 'package:uuid/uuid.dart'; -class AlarmsScreen extends StatelessWidget { +class AlarmsScreen extends ConsumerStatefulWidget { const AlarmsScreen({super.key}); @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Будильники'), - ), - body: ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: 5, - itemBuilder: (context, index) { - return _AlarmCard(index: index); - }, - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () {}, - icon: const Icon(Icons.add), - label: const Text('Новый будильник'), + ConsumerState createState() => _AlarmsScreenState(); +} + +class _AlarmsScreenState extends ConsumerState { + List _alarms = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadAlarms(); + } + + Future _loadAlarms() async { + setState(() => _isLoading = true); + // TODO: Загрузить из Firestore/Hive + setState(() { + _alarms = []; + _isLoading = false; + }); + } + + Future _toggleAlarm(AlarmModel alarm) async { + final updated = alarm.copyWith(isEnabled: !alarm.isEnabled); + setState(() { + final index = _alarms.indexWhere((a) => a.id == alarm.id); + if (index != -1) { + _alarms[index] = updated; + } + }); + + if (updated.isEnabled) { + await notificationService.scheduleNotification( + id: updated.hashCode, + title: updated.title, + body: 'Напоминание: ${updated.title}', + scheduledDate: updated.dateTime, + ); + } else { + await notificationService.cancelNotification(updated.hashCode); + } + } + + Future _deleteAlarm(String id) async { + final alarm = _alarms.firstWhere((a) => a.id == id); + await notificationService.cancelNotification(alarm.hashCode); + + setState(() { + _alarms.removeWhere((a) => a.id == id); + }); + } + + Future _showAddAlarmDialog() async { + final titleController = TextEditingController(); + DateTime selectedDate = DateTime.now(); + TimeOfDay selectedTime = TimeOfDay.now(); + + await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: const Text('Новый будильник'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + ListTile( + leading: const Icon(Icons.calendar_today), + title: const Text('Дата'), + subtitle: Text( + '${selectedDate.day}.${selectedDate.month}.${selectedDate.year}', + ), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setDialogState(() => selectedDate = picked); + } + }, + ), + ListTile( + leading: const Icon(Icons.access_time), + title: const Text('Время'), + subtitle: Text(selectedTime.format(context)), + trailing: const Icon(Icons.arrow_forward_ios, size: 16), + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: selectedTime, + ); + if (picked != null) { + setDialogState(() => selectedTime = picked); + } + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () { + if (titleController.text.isEmpty) return; + + final dateTime = DateTime( + selectedDate.year, + selectedDate.month, + selectedDate.day, + selectedTime.hour, + selectedTime.minute, + ); + + final alarm = AlarmModel( + id: const Uuid().v4(), + title: titleController.text, + dateTime: dateTime, + isEnabled: true, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + setState(() { + _alarms.add(alarm); + }); + + notificationService.scheduleNotification( + id: alarm.hashCode, + title: alarm.title, + body: 'Будильник: ${alarm.title}', + scheduledDate: alarm.dateTime, + ); + + Navigator.pop(context); + }, + child: const Text('Создать'), + ), + ], + ), ), ); } -} - -class _AlarmCard extends StatelessWidget { - final int index; - - const _AlarmCard({required this.index}); @override Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Будильники'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadAlarms, + tooltip: 'Обновить', + ), + ], + ), + body: _alarms.isEmpty + ? _buildEmptyState() + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _alarms.length, + itemBuilder: (context, index) { + final alarm = _alarms[index]; + return _buildAlarmCard(alarm); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _showAddAlarmDialog, + child: const Icon(Icons.add), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.alarm, size: 100, color: Colors.grey[400]), + const SizedBox(height: 24), + Text( + 'Нет будильников', + style: TextStyle(fontSize: 20, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Создайте первый будильник', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildAlarmCard(AlarmModel alarm) { return Card( margin: const EdgeInsets.only(bottom: 12), child: ListTile( - leading: CircleAvatar( - backgroundColor: index % 2 == 0 ? Colors.blue : Colors.grey, - child: const Icon(Icons.alarm, color: Colors.white), + leading: Icon( + alarm.isEnabled ? Icons.alarm : Icons.alarm_off, + color: alarm.isEnabled ? Colors.blue : Colors.grey, ), - title: Text('Будильник #${index + 1}'), - subtitle: Text('${8 + index}:00'), - trailing: Switch( - value: index % 2 == 0, - onChanged: (value) {}, + title: Text( + alarm.title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: alarm.isEnabled ? null : Colors.grey, + ), + ), + subtitle: Text( + '${alarm.dateTime.day}.${alarm.dateTime.month}.${alarm.dateTime.year} ' + '${alarm.dateTime.hour.toString().padLeft(2, '0')}:' + '${alarm.dateTime.minute.toString().padLeft(2, '0')}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Switch( + value: alarm.isEnabled, + onChanged: (_) => _toggleAlarm(alarm), + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _deleteAlarm(alarm.id), + ), + ], ), ), ); diff --git a/lib/screens/calendar_screen.dart b/lib/screens/calendar_screen.dart index d01b590..e593a9b 100644 --- a/lib/screens/calendar_screen.dart +++ b/lib/screens/calendar_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:table_calendar/table_calendar.dart'; import '../models/calendar_event_model.dart'; -import '../core/google_calendar_service.dart'; class CalendarScreen extends ConsumerStatefulWidget { const CalendarScreen({super.key}); @@ -267,8 +266,8 @@ class _CalendarScreenState extends ConsumerState { final descriptionController = TextEditingController(); final locationController = TextEditingController(); - TimeOfDay _startTime = const TimeOfDay(hour: 10, minute: 0); - TimeOfDay _endTime = const TimeOfDay(hour: 11, minute: 0); + TimeOfDay startTime = const TimeOfDay(hour: 10, minute: 0); + TimeOfDay endTime = const TimeOfDay(hour: 11, minute: 0); showDialog( context: context, @@ -312,16 +311,16 @@ class _CalendarScreenState extends ConsumerState { Expanded( child: ListTile( title: const Text('Начало'), - subtitle: Text(_startTime.format(context)), + subtitle: Text(startTime.format(context)), leading: const Icon(Icons.access_time), onTap: () async { final picked = await showTimePicker( context: context, - initialTime: _startTime, + initialTime: startTime, ); if (picked != null) { setDialogState(() { - _startTime = picked; + startTime = picked; }); } }, @@ -330,16 +329,16 @@ class _CalendarScreenState extends ConsumerState { Expanded( child: ListTile( title: const Text('Конец'), - subtitle: Text(_endTime.format(context)), + subtitle: Text(endTime.format(context)), leading: const Icon(Icons.access_time), onTap: () async { final picked = await showTimePicker( context: context, - initialTime: _endTime, + initialTime: endTime, ); if (picked != null) { setDialogState(() { - _endTime = picked; + endTime = picked; }); } }, diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 7b8970b..e1eddde 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,7 +3,6 @@ import 'dashboard_screen.dart'; import 'tasks_screen.dart'; import 'mind_map_screen.dart'; import 'calendar_screen.dart'; -import 'alarms_screen.dart'; class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); diff --git a/lib/screens/mind_map_editor_screen.dart b/lib/screens/mind_map_editor_screen.dart new file mode 100644 index 0000000..15dd2ff --- /dev/null +++ b/lib/screens/mind_map_editor_screen.dart @@ -0,0 +1,515 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:uuid/uuid.dart'; +import '../models/mindmap_model.dart'; +import '../providers/mind_maps_provider.dart'; +import '../core/admin_access_service.dart'; +import '../core/deepseek_service.dart'; + +class MindMapEditorScreen extends ConsumerStatefulWidget { + final MindMapModel? mindMap; // null = создание новой + + const MindMapEditorScreen({super.key, this.mindMap}); + + @override + ConsumerState createState() => _MindMapEditorScreenState(); +} + +class _MindMapEditorScreenState extends ConsumerState { + late TextEditingController _titleController; + late TextEditingController _descController; + late List> _nodes; + late bool _isMain; + bool _isSaving = false; + bool _isGeneratingAI = false; + + @override + void initState() { + super.initState(); + _titleController = TextEditingController(text: widget.mindMap?.title ?? ''); + _descController = TextEditingController(text: widget.mindMap?.description ?? ''); + _nodes = widget.mindMap?.nodes.toList() ?? []; + _isMain = widget.mindMap?.isMain ?? false; + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + + Future _save() async { + if (_titleController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Введите название')), + ); + return; + } + + setState(() => _isSaving = true); + + try { + final notifier = ref.read(mindMapsNotifierProvider.notifier); + + if (widget.mindMap == null) { + // Создание новой + await notifier.createMindMap( + title: _titleController.text.trim(), + description: _descController.text.trim(), + nodes: _nodes, + isMain: _isMain, + ); + } else { + // Обновление существующей + final updated = widget.mindMap!.copyWith( + title: _titleController.text.trim(), + description: _descController.text.trim(), + nodes: _nodes, + isMain: _isMain, + updatedAt: DateTime.now(), + ); + await notifier.updateMindMap(widget.mindMap!.id, updated); + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.mindMap == null ? '✅ Mind map создана' : '✅ Mind map сохранена'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red), + ); + } + } finally { + setState(() => _isSaving = false); + } + } + + Future _addNode({String? parentId}) async { + final title = await _showNodeTitleDialog(); + if (title == null || title.isEmpty) return; + + final parentNode = parentId != null + ? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {}) + : null; + + final newNode = { + 'id': const Uuid().v4(), + 'title': title, + 'level': parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0, + 'parentId': parentId, + 'color': _getColorForLevel(parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0), + }; + + setState(() => _nodes.add(newNode)); + } + + Future _addChildNode(String parentId) async { + final title = await _showNodeTitleDialog(); + if (title == null || title.isEmpty) return; + + final parentNode = _nodes.firstWhere((n) => n['id'] == parentId); + + final newNode = { + 'id': const Uuid().v4(), + 'title': title, + 'level': (parentNode['level'] ?? 0) + 1, + 'parentId': parentId, + 'color': _getColorForLevel((parentNode['level'] ?? 0) + 1), + }; + + setState(() => _nodes.add(newNode)); + } + + void _deleteNode(String nodeId) { + // Удаляем узел и всех его потомков + final nodesToDelete = {nodeId}; + _collectChildren(nodeId, nodesToDelete); + + setState(() { + _nodes.removeWhere((n) => nodesToDelete.contains(n['id'])); + }); + } + + void _collectChildren(String parentId, Set result) { + final children = _nodes.where((n) => n['parentId'] == parentId).toList(); + for (final child in children) { + final childId = child['id'] as String; + result.add(childId); + _collectChildren(childId, result); + } + } + + Future _showNodeTitleDialog() async { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Название узла'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'Введите название', + border: OutlineInputBorder(), + ), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, controller.text), + child: const Text('Добавить'), + ), + ], + ), + ); + } + + Future _generateWithAI() async { + if (_titleController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Введите название проекта')), + ); + return; + } + + setState(() => _isGeneratingAI = true); + + try { + final projectName = _titleController.text.trim(); + final projectDesc = _descController.text.trim(); + + final nodesData = await deepSeekService.generateMindMap( + projectName: projectName, + description: projectDesc.isNotEmpty ? projectDesc : 'Mind map для проекта', + ); + + // Преобразуем и добавляем к существующим узлам + final newNodes = >[]; + for (var i = 0; i < nodesData.length; i++) { + final nodeData = nodesData[i]; + final level = nodeData['level'] ?? 0; + final parentIdIndex = nodeData['parentId'] as int?; + + // Находим реальный parentId по индексу + String? actualParentId; + if (parentIdIndex != null && parentIdIndex < newNodes.length) { + actualParentId = newNodes[parentIdIndex]['id'] as String; + } + + newNodes.add({ + 'id': const Uuid().v4(), + 'title': nodeData['title'] ?? '', + 'level': level, + 'parentId': actualParentId, + 'color': _getColorForLevel(level), + }); + } + + // Добавляем новые узлы к существующим + setState(() { + _nodes.addAll(newNodes); + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('✅ Добавлено ${newNodes.length} узлов'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red), + ); + } + } finally { + setState(() => _isGeneratingAI = false); + } + } + + String _getColorForLevel(int level) { + final colors = [ + '#2196F3', '#4CAF50', '#FF9800', '#E91E63', '#9C27B0', '#00BCD4' + ]; + return colors[level % colors.length]; + } + + @override + Widget build(BuildContext context) { + final isAdmin = ref.watch(isAdminProvider); + + return Scaffold( + appBar: AppBar( + title: Text(widget.mindMap == null ? 'Новая Mind Map' : 'Редактировать'), + actions: [ + // Переключатель Общая/Личная (только для админов) + if (isAdmin) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.person, color: _isMain ? Colors.grey : Colors.white), + Switch( + value: _isMain, + onChanged: (value) => setState(() => _isMain = value), + ), + Icon(Icons.star, color: _isMain ? Colors.amber : Colors.grey), + ], + ), + ), + // AI Генерация + IconButton( + icon: _isGeneratingAI + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome), + onPressed: _isGeneratingAI ? null : _generateWithAI, + tooltip: 'AI Дополнение', + ), + // Сохранить + IconButton( + icon: _isSaving + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save), + onPressed: _isSaving ? null : _save, + tooltip: 'Сохранить', + ), + ], + ), + body: Column( + children: [ + // Заголовок и описание + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + TextField( + controller: _titleController, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.title), + ), + ), + const SizedBox(height: 16), + TextField( + controller: _descController, + decoration: const InputDecoration( + labelText: 'Описание', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.description), + ), + maxLines: 2, + ), + if (isAdmin) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + Icon( + _isMain ? Icons.star : Icons.person, + color: _isMain ? Colors.amber : Colors.blue, + ), + const SizedBox(width: 8), + Text( + _isMain ? 'Основная (видна всем)' : 'Личная (только вам)', + style: TextStyle( + color: _isMain ? Colors.amber : Colors.blue, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + ), + + const Divider(), + + // Список узлов + Expanded( + child: _nodes.isEmpty + ? _buildEmptyNodesState() + : _buildNodesTree(), + ), + + // Кнопки добавления + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _addNode(), + icon: const Icon(Icons.add), + label: const Text('Корневой узел'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: FilledButton.icon( + onPressed: _isGeneratingAI ? null : _generateWithAI, + icon: const Icon(Icons.auto_awesome), + label: const Text('AI Дополнить'), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEmptyNodesState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_tree, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Нет узлов', + style: TextStyle(fontSize: 20, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Добавьте узлы вручную или с помощью AI', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + Widget _buildNodesTree() { + // Находим корневые узлы (без parentId) + final rootNodes = _nodes.where((n) => n['parentId'] == null).toList(); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: rootNodes.length, + itemBuilder: (context, index) { + return _buildNodeWidget(rootNodes[index]); + }, + ); + } + + Widget _buildNodeWidget(Map node) { + final nodeId = node['id'] as String; + final title = node['title'] as String; + final level = node['level'] as int; + final color = _hexToColor(node['color'] as String? ?? '#2196F3'); + final children = _nodes.where((n) => n['parentId'] == nodeId).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.only(left: level * 24.0), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + border: Border(left: BorderSide(color: color, width: 4)), + borderRadius: BorderRadius.circular(8), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: color, + child: Text( + '${level + 1}', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + title: Text( + title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('Уровень $level'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.add_circle, color: Colors.green), + onPressed: () => _addChildNode(nodeId), + tooltip: 'Добавить дочерний', + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _confirmDeleteNode(nodeId, title), + tooltip: 'Удалить', + ), + ], + ), + ), + ), + // Рекурсивно строим дочерние узлы + ...children.map((child) => _buildNodeWidget(child)), + ], + ); + } + + void _confirmDeleteNode(String nodeId, String title) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить узел?'), + content: Text('"$title" и все дочерние узлы будут удалены'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () { + _deleteNode(nodeId); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], + ), + ); + } + + Color _hexToColor(String hex) { + hex = hex.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } + return const Color(0xFF2196F3); + } +} diff --git a/lib/screens/mind_map_interactive_screen.dart b/lib/screens/mind_map_interactive_screen.dart new file mode 100644 index 0000000..3668629 --- /dev/null +++ b/lib/screens/mind_map_interactive_screen.dart @@ -0,0 +1,835 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:reactive_mind_map/reactive_mind_map.dart' as rm; +import 'package:uuid/uuid.dart'; +import '../models/mindmap_model.dart'; +import '../providers/mind_maps_provider.dart'; +import '../core/admin_access_service.dart'; +import '../core/deepseek_service.dart'; + +class MindMapInteractiveScreen extends ConsumerStatefulWidget { + final MindMapModel mindMap; + + const MindMapInteractiveScreen({super.key, required this.mindMap}); + + @override + ConsumerState createState() => + _MindMapInteractiveScreenState(); +} + +class _MindMapInteractiveScreenState + extends ConsumerState { + late List> _nodes; + late bool _isMain; + bool _isEditing = false; + bool _showChat = false; + bool _isAISubmitting = false; + final TextEditingController _chatController = TextEditingController(); + final List _chatMessages = []; + + @override + void initState() { + super.initState(); + _nodes = widget.mindMap.nodes.toList(); + _isMain = widget.mindMap.isMain; + } + + @override + void dispose() { + _chatController.dispose(); + super.dispose(); + } + + // ========== CHAT FUNCTIONS ========== + + void _addChatMessage(String text, bool isUser, {AIAction? action}) { + setState(() { + _chatMessages.add(ChatMessage( + text: text, + isUser: isUser, + timestamp: DateTime.now(), + action: action, + )); + }); + } + + Future _sendToAI(String message) async { + if (message.trim().isEmpty) return; + + _addChatMessage(message, true); + _chatController.clear(); + setState(() => _isAISubmitting = true); + + try { + // Отправляем запрос к DeepSeek + final response = await deepSeekService.chatCompletion( + messages: [ + ..._chatMessages + .where((m) => m.isUser || m.text.startsWith('AI:')) + .map((m) => { + 'role': m.isUser ? 'user' : 'assistant', + 'content': m.text, + }) + .toList(), + { + 'role': 'system', + 'content': ''' +Ты AI ассистент для работы с mind maps. +Текущая структура mind map "${widget.mindMap.title}": +${_nodes.map((n) => '${" " * (n["level"] ?? 0)}- ${n["title"]} (уровень ${n["level"] ?? 0})').join('\n')} + +Когда пользователь просит что-то сделать с mind map, отвечай в формате: +ACTION: +TARGET: +CHANGES: [ + {"action": "add", "title": "...", "level": 0, "parentId": null}, + {"action": "edit", "id": "...", "title": "..."}, + {"action": "delete", "id": "..."} +] +EXPLANATION: <объяснение на русском> + +Или просто отвечай текстом если действие не требуется. +''', + }, + ], + ); + + // Парсим ответ AI + if (response.containsKey('ACTION')) { + final action = response['ACTION'] as String; + final changes = response['CHANGES'] as List; + final explanation = response['EXPLANATION'] as String? ?? ''; + + _addChatMessage( + 'AI: $explanation', + false, + action: AIAction( + type: action, + changes: changes.map((c) => c as Map).toList(), + isApplied: false, + ), + ); + } else { + _addChatMessage('AI: $response', false); + } + } catch (e) { + _addChatMessage('❌ Ошибка AI: $e', false); + } finally { + setState(() => _isAISubmitting = false); + } + } + + void _applyAIChanges(AIAction action) { + for (final change in action.changes) { + final actionType = change['action'] as String; + + switch (actionType) { + case 'add': + _nodes.add({ + 'id': const Uuid().v4(), + 'title': change['title'] ?? '', + 'level': change['level'] ?? 0, + 'parentId': change['parentId'], + 'color': _getColorForLevel(change['level'] ?? 0), + }); + break; + + case 'edit': + final index = _nodes.indexWhere((n) => n['id'] == change['id']); + if (index != -1) { + _nodes[index] = { + ..._nodes[index], + 'title': change['title'] ?? _nodes[index]['title'], + }; + } + break; + + case 'delete': + final nodesToDelete = {change['id'] as String}; + _collectChildren(change['id'] as String, nodesToDelete); + _nodes.removeWhere((n) => nodesToDelete.contains(n['id'])); + break; + } + } + + // Обновляем сообщение в чате + final msgIndex = _chatMessages.indexWhere((m) => m.action == action); + if (msgIndex != -1) { + setState(() { + _chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith( + action: action.copyWith(isApplied: true), + ); + _nodes = _nodes.toList(); // Триггер rebuild + }); + } + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Изменения AI применены'), + backgroundColor: Colors.green, + ), + ); + } + + void _rejectAIChanges(AIAction action) { + final msgIndex = _chatMessages.indexWhere((m) => m.action == action); + if (msgIndex != -1) { + setState(() { + _chatMessages[msgIndex] = _chatMessages[msgIndex].copyWith( + action: action.copyWith(isApplied: false), + ); + }); + } + } + + // ========== EDIT FUNCTIONS ========== + + void _addNode({String? parentId}) { + showDialog( + context: context, + builder: (context) => _NodeTitleDialog( + onAdd: (title) { + final parentNode = parentId != null + ? _nodes.firstWhere((n) => n['id'] == parentId, orElse: () => {}) + : null; + + setState(() { + _nodes.add({ + 'id': const Uuid().v4(), + 'title': title, + 'level': parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0, + 'parentId': parentId, + 'color': _getColorForLevel( + parentNode != null ? (parentNode['level'] ?? 0) + 1 : 0), + }); + }); + }, + ), + ); + } + + void _editNode(String nodeId) { + final node = _nodes.firstWhere((n) => n['id'] == nodeId); + final controller = TextEditingController(text: node['title'] as String); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Редактировать узел'), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Название', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () { + final index = _nodes.indexWhere((n) => n['id'] == nodeId); + if (index != -1) { + setState(() { + _nodes[index] = {..._nodes[index], 'title': controller.text}; + }); + } + Navigator.pop(context); + }, + child: const Text('Сохранить'), + ), + ], + ), + ); + } + + void _deleteNode(String nodeId) { + final nodesToDelete = {nodeId}; + _collectChildren(nodeId, nodesToDelete); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Удалить узел?'), + content: Text( + 'Удалить "${_nodes.firstWhere((n) => n['id'] == nodeId)['title']}" и все дочерние узлы?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () { + setState(() { + _nodes.removeWhere((n) => nodesToDelete.contains(n['id'])); + }); + Navigator.pop(context); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Удалить'), + ), + ], + ), + ); + } + + void _collectChildren(String parentId, Set result) { + final children = _nodes.where((n) => n['parentId'] == parentId).toList(); + for (final child in children) { + final childId = child['id'] as String; + result.add(childId); + _collectChildren(childId, result); + } + } + + Future _saveChanges() async { + try { + final notifier = ref.read(mindMapsNotifierProvider.notifier); + final isAdmin = ref.read(isAdminProvider); + final userEmail = FirebaseAuth.instance.currentUser?.email ?? ''; + + // Проверка прав + if (!isAdmin && widget.mindMap.ownerEmail != userEmail) { + throw Exception('Нет прав для редактирования'); + } + + final updated = widget.mindMap.copyWith( + nodes: _nodes, + isMain: _isMain, + updatedAt: DateTime.now(), + ); + + await notifier.updateMindMap(widget.mindMap.id, updated); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Изменения сохранены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('❌ Ошибка: $e'), backgroundColor: Colors.red), + ); + } + } + } + + String _getColorForLevel(int level) { + final colors = [ + '#2196F3', + '#4CAF50', + '#FF9800', + '#E91E63', + '#9C27B0', + '#00BCD4' + ]; + return colors[level % colors.length]; + } + + Color _hexToColor(String hex) { + hex = hex.replaceAll('#', ''); + if (hex.length == 6) return Color(int.parse('FF$hex', radix: 16)); + return const Color(0xFF2196F3); + } + + // ========== BUILD ========== + + @override + Widget build(BuildContext context) { + final isAdmin = ref.watch(isAdminProvider); + final userEmail = FirebaseAuth.instance.currentUser?.email ?? ''; + final canEdit = isAdmin || widget.mindMap.ownerEmail == userEmail; + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.mindMap.title), + Row( + children: [ + Icon( + _isMain ? Icons.star : Icons.person, + size: 14, + color: _isMain ? Colors.amber : Colors.blue, + ), + const SizedBox(width: 4), + Text( + _isMain ? 'Основная' : 'Личная', + style: TextStyle( + fontSize: 12, + color: _isMain ? Colors.amber : Colors.blue, + ), + ), + if (canEdit && _isEditing) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + '✏️ Редактирование', + style: const TextStyle( + fontSize: 12, + color: Colors.green, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + actions: [ + // Toggle edit mode + if (canEdit) + IconButton( + icon: Icon(_isEditing ? Icons.visibility : Icons.edit), + onPressed: () => setState(() => _isEditing = !_isEditing), + tooltip: _isEditing ? 'Режим просмотра' : 'Режим редактирования', + ), + // AI Chat + IconButton( + icon: Icon(_showChat ? Icons.close : Icons.auto_awesome), + onPressed: () => setState(() => _showChat = !_showChat), + tooltip: _showChat ? 'Закрыть AI чат' : 'Открыть AI чат', + ), + // Save + if (_isEditing) + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveChanges, + tooltip: 'Сохранить изменения', + ), + ], + ), + body: Stack( + children: [ + // Mind Map (фон) + _nodes.isEmpty + ? _buildEmptyState() + : rm.MindMapWidget( + data: _buildMindMapData(), + style: rm.MindMapStyle( + layout: rm.MindMapLayout.radial, + nodeShape: rm.NodeShape.roundedRectangle, + connectionColor: Colors.blue.shade100, + connectionWidth: 2.0, + ), + cameraFocus: rm.CameraFocus.fitAll, + onNodeTap: _isEditing ? (node) => _editNode(node.id) : null, + onNodeLongPress: + _isEditing ? (node) => _deleteNode(node.id) : null, + ), + + // Чат поверх карты + if (_showChat) _buildChatOverlay(), + ], + ), + + // FAB для добавления узлов (в режиме редактирования) + floatingActionButton: _isEditing && !_showChat + ? FloatingActionButton( + onPressed: () => _addNode(), + child: const Icon(Icons.add), + tooltip: 'Добавить корневой узел', + ) + : null, + ); + } + + Widget _buildChatOverlay() { + return Positioned( + left: 0, + right: 0, + bottom: 0, + height: MediaQuery.of(context).size.height * 0.45, + child: Column( + children: [ + // Градиентная полоска сверху + Container( + height: 4, + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Colors.purple, Colors.deepPurple], + ), + ), + ), + + // Панель чата + Expanded( + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: Column( + children: [ + // Header + Container( + width: double.infinity, + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 16), + color: Colors.purple.shade800, + child: Row( + children: [ + const Icon(Icons.auto_awesome, + color: Colors.white, size: 18), + const SizedBox(width: 8), + const Text( + 'AI Ассистент', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + const Spacer(), + Text( + '${_chatMessages.length}', + style: TextStyle( + color: Colors.white.withOpacity(0.7), + fontSize: 12, + ), + ), + ], + ), + ), + + // Messages + Expanded( + child: _chatMessages.isEmpty + ? _buildChatEmptyState() + : ListView.builder( + padding: const EdgeInsets.all(6), + itemCount: _chatMessages.length, + itemBuilder: (context, index) { + return _buildChatMessage(_chatMessages[index]); + }, + ), + ), + + // Input + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: Theme.of(context).cardColor, + child: SafeArea( + top: false, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 40, + ), + child: TextField( + controller: _chatController, + maxLines: null, + minLines: 1, + decoration: InputDecoration( + hintText: 'AI помощь...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 2), + isDense: true, + ), + onSubmitted: (text) => _sendToAI(text), + ), + ), + ), + const SizedBox(width: 4), + SizedBox( + width: 34, + height: 34, + child: Material( + color: Colors.purple, + shape: const CircleBorder(), + child: InkWell( + onTap: _isAISubmitting + ? null + : () => _sendToAI(_chatController.text), + customBorder: const CircleBorder(), + child: _isAISubmitting + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.send, + color: Colors.white, size: 12), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_tree, size: 100, color: Colors.grey[400]), + const SizedBox(height: 24), + Text( + 'Mind map пуста', + style: TextStyle(fontSize: 20, color: Colors.grey[600]), + ), + if (_isEditing) ...[ + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: () => _addNode(), + icon: const Icon(Icons.add), + label: const Text('Добавить первый узел'), + ), + ], + ], + ), + ); + } + + Widget _buildChatEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.auto_awesome, size: 40, color: Colors.purple[300]), + const SizedBox(height: 8), + Text( + 'AI Ассистент', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 4), + Text( + 'Напишите "добавь узлы" или\n"предложи улучшения"', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11, color: Colors.grey[600]), + ), + ], + ), + ), + ); + } + + Widget _buildChatMessage(ChatMessage msg) { + return Align( + alignment: msg.isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: msg.isUser ? Colors.blue : Colors.grey[200], + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + msg.isUser ? msg.text : msg.text.replaceFirst('AI: ', ''), + style: TextStyle( + color: msg.isUser ? Colors.white : Colors.black87, + fontSize: 13, + ), + ), + if (msg.action != null) ...[ + const SizedBox(height: 6), + _buildAIActionButtons(msg.action!), + ], + ], + ), + ), + ); + } + + Widget _buildAIActionButtons(AIAction action) { + if (action.isApplied) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 16), + SizedBox(width: 4), + Text('Применено', style: TextStyle(color: Colors.green)), + ], + ), + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton.icon( + onPressed: () => _applyAIChanges(action), + icon: const Icon(Icons.check, size: 18), + label: const Text('Применить'), + style: TextButton.styleFrom( + foregroundColor: Colors.green, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () => _rejectAIChanges(action), + icon: const Icon(Icons.close, size: 18), + label: const Text('Отклонить'), + style: TextButton.styleFrom( + foregroundColor: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0), + ), + ), + ], + ); + } + + rm.MindMapData _buildMindMapData() { + final rootNodes = _nodes.where((n) => n['parentId'] == null).toList(); + + if (rootNodes.isEmpty) { + return rm.MindMapData( + id: 'empty', title: widget.mindMap.title, children: []); + } + + final root = rootNodes.first; + return rm.MindMapData( + id: root['id'] as String, + title: root['title'] as String, + color: _hexToColor(root['color'] as String? ?? '#2196F3'), + children: _buildChildren(root['id'] as String), + ); + } + + List _buildChildren(String parentId) { + final children = _nodes.where((n) => n['parentId'] == parentId).toList(); + return children.map((node) { + return rm.MindMapData( + id: node['id'] as String, + title: node['title'] as String, + color: _hexToColor(node['color'] as String? ?? '#4CAF50'), + children: _buildChildren(node['id'] as String), + ); + }).toList(); + } +} + +// ========== MODELS ========== + +class ChatMessage { + final String text; + final bool isUser; + final DateTime timestamp; + final AIAction? action; + + ChatMessage({ + required this.text, + required this.isUser, + required this.timestamp, + this.action, + }); + + ChatMessage copyWith({String? text, bool? isUser, AIAction? action}) { + return ChatMessage( + text: text ?? this.text, + isUser: isUser ?? this.isUser, + timestamp: timestamp, + action: action ?? this.action, + ); + } +} + +class AIAction { + final String type; + final List> changes; + final bool isApplied; + + AIAction({ + required this.type, + required this.changes, + this.isApplied = false, + }); + + AIAction copyWith({bool? isApplied}) { + return AIAction( + type: type, + changes: changes, + isApplied: isApplied ?? this.isApplied, + ); + } +} + +class _NodeTitleDialog extends StatefulWidget { + final Function(String) onAdd; + + const _NodeTitleDialog({required this.onAdd}); + + @override + State<_NodeTitleDialog> createState() => _NodeTitleDialogState(); +} + +class _NodeTitleDialogState extends State<_NodeTitleDialog> { + final _controller = TextEditingController(); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Название узла'), + content: TextField( + controller: _controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Введите название', + border: OutlineInputBorder(), + ), + onSubmitted: (text) { + if (text.isNotEmpty) widget.onAdd(text); + Navigator.pop(context); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ElevatedButton( + onPressed: () { + if (_controller.text.isNotEmpty) { + widget.onAdd(_controller.text); + } + Navigator.pop(context); + }, + child: const Text('Добавить'), + ), + ], + ); + } +} diff --git a/lib/screens/mind_map_screen.dart b/lib/screens/mind_map_screen.dart index ed0def6..2cbf3d4 100644 --- a/lib/screens/mind_map_screen.dart +++ b/lib/screens/mind_map_screen.dart @@ -1,57 +1,37 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../providers/mindmap_provider.dart'; -import '../models/mind_map_model.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../providers/mind_maps_provider.dart'; +import '../models/mindmap_model.dart'; +import '../core/admin_access_service.dart'; +import 'mind_map_viewer_screen.dart'; +import 'mind_map_editor_screen.dart'; +import 'mind_map_interactive_screen.dart'; -class MindMapScreen extends ConsumerStatefulWidget { +class MindMapScreen extends ConsumerWidget { const MindMapScreen({super.key}); @override - ConsumerState createState() => _MindMapScreenState(); -} - -class _MindMapScreenState extends ConsumerState { - bool _isEditing = false; - bool _isGenerating = false; - - @override - Widget build(BuildContext context) { - final projectId = 'default'; // TODO: Получить текущий проект - final mindMapState = ref.watch(mindMapProvider(projectId)); + Widget build(BuildContext context, WidgetRef ref) { + final mindMapsAsync = ref.watch(mindMapsProvider); + final isAdmin = ref.watch(isAdminProvider); + final currentUserEmail = FirebaseAuth.instance.currentUser?.email ?? ''; return Scaffold( appBar: AppBar( - title: const Text('Mind-карта'), + title: const Text('Mind Maps'), actions: [ - IconButton( - icon: Icon(_isEditing ? Icons.check : Icons.edit), - onPressed: () { - setState(() { - _isEditing = !_isEditing; - }); - }, - tooltip: _isEditing ? 'Готово' : 'Редактировать', - ), - IconButton( - icon: const Icon(Icons.auto_awesome), - onPressed: _isGenerating ? null : () => _generateWithAI(projectId), - tooltip: 'AI Генерация', - ), - IconButton( - icon: const Icon(Icons.add), - onPressed: () => _showAddNodeDialog(context, projectId, null), - tooltip: 'Добавить узел', - ), + if (isAdmin) + IconButton( + icon: const Icon(Icons.admin_panel_settings), + onPressed: () => _showAdminOptions(context), + tooltip: 'Админ панель', + ), ], ), - body: mindMapState.when( - data: (mindMapData) { - if (mindMapData.nodes.isEmpty) { - return _buildEmptyState(context, projectId); - } - - return _buildSimpleMindMap(mindMapData); - }, + body: mindMapsAsync.when( + data: (mindMaps) => + _buildContent(context, mindMaps, isAdmin, currentUserEmail, ref), loading: () => const Center(child: CircularProgressIndicator()), error: (error, stack) => Center( child: Column( @@ -62,121 +42,185 @@ class _MindMapScreenState extends ConsumerState { Text('Ошибка: $error'), const SizedBox(height: 16), ElevatedButton( - onPressed: () => ref.refresh(mindMapProvider(projectId)), + onPressed: () => ref.invalidate(mindMapsProvider), child: const Text('Повторить'), ), ], ), ), ), - floatingActionButton: _isEditing - ? FloatingActionButton.extended( - onPressed: () => _showAddNodeDialog(context, projectId, null), - icon: const Icon(Icons.add), - label: const Text('Добавить узел'), - ) - : null, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _showCreateOptions(context, ref), + icon: const Icon(Icons.add), + label: const Text('Создать'), + ), ); } - // Простое отображение дерева (замена GraphView) - Widget _buildSimpleMindMap(MindMapData mindMapData) { - return ListView.builder( + Widget _buildContent( + BuildContext context, + List mindMaps, + bool isAdmin, + String currentUserEmail, + WidgetRef ref, + ) { + // Разделяем на основные и личные + final mainMaps = mindMaps.where((m) => m.isMain).toList(); + final personalMaps = mindMaps.where((m) => !m.isMain).toList(); + + if (mindMaps.isEmpty) { + return _buildEmptyState(context, ref); + } + + return ListView( padding: const EdgeInsets.all(16), - itemCount: mindMapData.nodes.length, - itemBuilder: (context, index) { - final node = mindMapData.nodes[index]; - return _buildTreeNodeWidget(node, mindMapData); - }, - ); - } - - Widget _buildTreeNodeWidget(MindMapNode node, MindMapData mindMapData) { - final children = mindMapData.getChildren(node.id); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - margin: EdgeInsets.only(left: node.level * 20.0), - decoration: BoxDecoration( - color: _hexToColor(node.color), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - _getIconData(node.icon), - color: Colors.white, - size: 20, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - node.title, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - if (_isEditing) - IconButton( - icon: const Icon(Icons.delete, color: Colors.white), - onPressed: () => _confirmDeleteNode(node), - ), - ], - ), - ), - if (children.isNotEmpty) ...[ - const SizedBox(height: 8), - ...children.map((child) => _buildTreeNodeWidget(child, mindMapData)), + // Основные карты + if (mainMaps.isNotEmpty) ...[ + _buildSectionHeader('Основные', Icons.star, Colors.amber), + ...mainMaps.map((map) => + _buildMindMapCard(context, map, isAdmin, currentUserEmail, ref)), + const SizedBox(height: 16), + ], + + // Личные карты + if (personalMaps.isNotEmpty) ...[ + _buildSectionHeader('Личные', Icons.person, Colors.blue), + ...personalMaps.map((map) => + _buildMindMapCard(context, map, isAdmin, currentUserEmail, ref)), ], ], ); } - Widget _buildEmptyState(BuildContext context, String projectId) { + Widget _buildSectionHeader(String title, IconData icon, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Icon(icon, color: color, size: 24), + const SizedBox(width: 8), + Text( + title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Widget _buildMindMapCard( + BuildContext context, + MindMapModel mindMap, + bool isAdmin, + String currentUserEmail, + WidgetRef ref, + ) { + final isOwner = mindMap.ownerEmail == currentUserEmail; + final canDelete = isAdmin || isOwner; + final canEdit = isAdmin || isOwner; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: CircleAvatar( + backgroundColor: mindMap.isMain ? Colors.amber : Colors.blue, + child: Icon( + mindMap.isMain ? Icons.star : Icons.account_tree, + color: Colors.white, + ), + ), + title: Text( + mindMap.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (mindMap.description.isNotEmpty) + Text(mindMap.description, + maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 4), + Text('Владелец: ${mindMap.ownerEmail}'), + Text( + '${mindMap.nodeCount} узлов • ${_formatDate(mindMap.createdAt)}'), + ], + ), + trailing: PopupMenuButton( + onSelected: (value) => _handleAction(context, value, mindMap, ref), + itemBuilder: (context) => [ + PopupMenuItem( + value: 'view', + child: Row( + children: [ + const Icon(Icons.visibility, size: 20), + const SizedBox(width: 12), + const Text('Открыть'), + ], + ), + ), + if (canEdit) + PopupMenuItem( + value: 'edit', + child: Row( + children: [ + const Icon(Icons.edit, size: 20), + const SizedBox(width: 12), + const Text('Редактировать'), + ], + ), + ), + if (canDelete) + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete, color: Colors.red, size: 20), + const SizedBox(width: 12), + const Text('Удалить', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + onTap: () => _openMindMap(context, mindMap), + ), + ); + } + + Widget _buildEmptyState(BuildContext context, WidgetRef ref) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon( - Icons.account_tree, - size: 100, - color: Colors.grey, - ), + Icon(Icons.account_tree, size: 100, color: Colors.grey[400]), const SizedBox(height: 24), - const Text( - 'Mind-карта пуста', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + Text( + 'Нет mind карт', + style: TextStyle(fontSize: 20, color: Colors.grey[600]), ), const SizedBox(height: 8), - const Text( - 'Создайте структуру проекта', - style: TextStyle(color: Colors.grey), + Text( + 'Создайте первую карту вручную или с помощью AI', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[500]), ), const SizedBox(height: 32), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ ElevatedButton.icon( - onPressed: () => _showAddNodeDialog(context, projectId, null), + onPressed: () => _showCreateManualDialog(context, ref), icon: const Icon(Icons.add), - label: const Text('Добавить вручную'), + label: const Text('Вручную'), ), const SizedBox(width: 16), - OutlinedButton.icon( - onPressed: - _isGenerating ? null : () => _generateWithAI(projectId), - icon: _isGenerating - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.auto_awesome), + FilledButton.icon( + onPressed: () => _showCreateAIDialog(context, ref), + icon: const Icon(Icons.auto_awesome), label: const Text('AI Генерация'), ), ], @@ -186,159 +230,133 @@ class _MindMapScreenState extends ConsumerState { ); } - Future _generateWithAI(String projectId) async { - setState(() { - _isGenerating = true; - }); + void _showCreateOptions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.add, color: Colors.blue), + title: const Text('Создать вручную'), + subtitle: const Text('Пустая mind map'), + onTap: () { + Navigator.pop(context); + _showCreateManualDialog(context, ref); + }, + ), + ListTile( + leading: const Icon(Icons.auto_awesome, color: Colors.purple), + title: const Text('AI Генерация'), + subtitle: const Text('С помощью DeepSeek'), + onTap: () { + Navigator.pop(context); + _showCreateAIDialog(context, ref); + }, + ), + ], + ), + ), + ); + } - try { - final projectName = await _showProjectNameDialog(); - if (projectName == null || projectName.isEmpty) return; + void _showCreateManualDialog(BuildContext context, WidgetRef ref) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const MindMapEditorScreen(), + ), + ); + } - final projectDescription = await _showProjectDescriptionDialog(); + void _showCreateAIDialog(BuildContext context, WidgetRef ref) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const MindMapEditorScreen(), + ), + ); + } - await ref.read(mindMapProvider(projectId).notifier).generateMindMapWithAI( - projectName: projectName, - description: projectDescription ?? '', - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('✅ Mind-карта сгенерирована!'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('❌ Ошибка: $e'), - backgroundColor: Colors.red, - ), - ); - } - } finally { - setState(() { - _isGenerating = false; - }); + void _handleAction( + BuildContext context, + String action, + MindMapModel mindMap, + WidgetRef ref, + ) { + switch (action) { + case 'view': + _openMindMap(context, mindMap); + break; + case 'edit': + _editMindMap(context, mindMap, ref); + break; + case 'delete': + _confirmDelete(context, mindMap, ref); + break; } } - Future _showProjectNameDialog() { - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Название проекта'), - content: TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Введите название', - border: OutlineInputBorder(), - ), - autofocus: true, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Отмена'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, controller.text), - child: const Text('Генерировать'), - ), - ], + void _openMindMap(BuildContext context, MindMapModel mindMap) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MindMapInteractiveScreen(mindMap: mindMap), ), ); } - Future _showProjectDescriptionDialog() { - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Описание проекта'), - content: TextField( - controller: controller, - decoration: const InputDecoration( - labelText: 'Описание (опционально)', - border: OutlineInputBorder(), - ), - maxLines: 3, - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, ''), - child: const Text('Пропустить'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, controller.text), - child: const Text('Генерировать'), - ), - ], + void _editMindMap(BuildContext context, MindMapModel mindMap, WidgetRef ref) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MindMapEditorScreen(mindMap: mindMap), ), ); } - void _showAddNodeDialog( - BuildContext context, String projectId, String? parentId) { - final titleController = TextEditingController(); - + void _confirmDelete( + BuildContext context, MindMapModel mindMap, WidgetRef ref) { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Добавить узел'), - content: TextField( - controller: titleController, - decoration: const InputDecoration( - labelText: 'Название', - border: OutlineInputBorder(), - ), - autofocus: true, - ), + title: const Text('Удалить mind map?'), + content: Text( + '"${mindMap.title}" будет удалена навсегда. Это действие нельзя отменить.'), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Отмена'), ), - ElevatedButton( + TextButton( onPressed: () async { - if (titleController.text.isEmpty) return; + try { + await ref + .read(mindMapsNotifierProvider.notifier) + .deleteMindMap(mindMap.id, mindMap.ownerEmail); - await ref.read(mindMapProvider(projectId).notifier).addNode( - title: titleController.text, - parentId: parentId, + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Mind map удалена'), + backgroundColor: Colors.green, + ), ); - - if (context.mounted) { - Navigator.pop(context); + } + } catch (e) { + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ Ошибка: $e'), + backgroundColor: Colors.red, + ), + ); + } } }, - child: const Text('Добавить'), - ), - ], - ), - ); - } - - void _confirmDeleteNode(MindMapNode node) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Удалить узел?'), - content: const Text('Все дочерние узлы также будут удалены'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Отмена'), - ), - TextButton( - onPressed: () async { - // TODO: Удалить узел - Navigator.pop(context); - }, style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('Удалить'), ), @@ -347,26 +365,16 @@ class _MindMapScreenState extends ConsumerState { ); } - Color _hexToColor(String hex) { - hex = hex.replaceAll('#', ''); - if (hex.length == 6) { - return Color(int.parse('0xFF$hex')); - } - return Colors.blue; + void _showAdminOptions(BuildContext context) { + // TODO: Открыть админ панель + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Админ панель в разработке')), + ); } - IconData _getIconData(String iconName) { - switch (iconName) { - case 'folder': - return Icons.folder; - case 'task': - return Icons.task; - case 'star': - return Icons.star; - case 'flag': - return Icons.flag; - default: - return Icons.circle; - } + String _formatDate(DateTime date) { + return '${date.day.toString().padLeft(2, '0')}.' + '${date.month.toString().padLeft(2, '0')}.' + '${date.year}'; } } diff --git a/lib/screens/mind_map_settings_screen.dart b/lib/screens/mind_map_settings_screen.dart new file mode 100644 index 0000000..07db53c --- /dev/null +++ b/lib/screens/mind_map_settings_screen.dart @@ -0,0 +1,464 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:reactive_mind_map/reactive_mind_map.dart' as rm; +import 'dart:convert'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MindMapSettingsScreen extends ConsumerStatefulWidget { + final String projectId; + + const MindMapSettingsScreen({super.key, required this.projectId}); + + @override + ConsumerState createState() => + _MindMapSettingsScreenState(); +} + +class _MindMapSettingsScreenState extends ConsumerState { + rm.MindMapLayout _defaultLayout = rm.MindMapLayout.radial; + rm.NodeShape _defaultShape = rm.NodeShape.roundedRectangle; + rm.CameraFocus _defaultCamera = rm.CameraFocus.fitAll; + bool _autoExpandNodes = true; + bool _showConnections = true; + bool _enableShadows = true; + double _connectionWidth = 2.0; + Color _connectionColor = const Color(0xFFB0BEC5); + List _nodeColors = const [ + Color(0xFF2196F3), + Color(0xFF4CAF50), + Color(0xFFFF9800), + Color(0xFFE91E63), + Color(0xFF9C27B0), + ]; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _defaultLayout = rm.MindMapLayout.values[ + prefs.getInt('${widget.projectId}_layout') ?? 0]; + _defaultShape = rm.NodeShape.values[ + prefs.getInt('${widget.projectId}_shape') ?? 0]; + _defaultCamera = rm.CameraFocus.values[ + prefs.getInt('${widget.projectId}_camera') ?? 0]; + _autoExpandNodes = + prefs.getBool('${widget.projectId}_auto_expand') ?? true; + _showConnections = + prefs.getBool('${widget.projectId}_show_connections') ?? true; + _enableShadows = + prefs.getBool('${widget.projectId}_shadows') ?? true; + _connectionWidth = + prefs.getDouble('${widget.projectId}_conn_width') ?? 2.0; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + } + } + + Future _saveSettings() async { + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt( + '${widget.projectId}_layout', _defaultLayout.index); + await prefs.setInt('${widget.projectId}_shape', _defaultShape.index); + await prefs.setInt( + '${widget.projectId}_camera', _defaultCamera.index); + await prefs.setBool( + '${widget.projectId}_auto_expand', _autoExpandNodes); + await prefs.setBool( + '${widget.projectId}_show_connections', _showConnections); + await prefs.setBool('${widget.projectId}_shadows', _enableShadows); + await prefs.setDouble( + '${widget.projectId}_conn_width', _connectionWidth); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Настройки сохранены'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ Ошибка: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _exportMindMap() async { + // TODO: Экспорт mind map в JSON файл + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('📤 Экспорт в разработке'), + backgroundColor: Colors.orange, + ), + ); + } + } + + Future _importMindMap() async { + // TODO: Импорт mind map из JSON файла + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('📥 Импорт в разработке'), + backgroundColor: Colors.orange, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Настройки Mind Map'), + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveSettings, + tooltip: 'Сохранить', + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSection( + 'Layout', + [ + _buildDropdown( + 'Layout по умолчанию', + _defaultLayout, + rm.MindMapLayout.values, + (value) => setState(() => _defaultLayout = value!), + labels: const { + rm.MindMapLayout.radial: 'Радиальный', + rm.MindMapLayout.right: 'Вправо', + rm.MindMapLayout.left: 'Влево', + rm.MindMapLayout.horizontal: 'Горизонтальный', + rm.MindMapLayout.vertical: 'Вертикальный', + }, + ), + _buildDropdown( + 'Форма узлов', + _defaultShape, + rm.NodeShape.values, + (value) => setState(() => _defaultShape = value!), + labels: const { + rm.NodeShape.roundedRectangle: 'Скругленный прямоугольник', + rm.NodeShape.circle: 'Круг', + rm.NodeShape.diamond: 'Ромб', + rm.NodeShape.hexagon: 'Шестиугольник', + rm.NodeShape.ellipse: 'Эллипс', + rm.NodeShape.rectangle: 'Прямоугольник', + }, + ), + _buildDropdown( + 'Фокус камеры', + _defaultCamera, + rm.CameraFocus.values, + (value) => setState(() => _defaultCamera = value!), + labels: const { + rm.CameraFocus.fitAll: 'Вместить всё', + rm.CameraFocus.rootNode: 'Корневой узел', + rm.CameraFocus.center: 'Центр', + rm.CameraFocus.firstLeaf: 'Первый лист', + }, + ), + ], + ), + const SizedBox(height: 24), + _buildSection( + 'Поведение', + [ + SwitchListTile( + title: const Text('Авто-раскрытие узлов'), + subtitle: const Text('Раскрывать все узлы при загрузке'), + value: _autoExpandNodes, + onChanged: (value) => + setState(() => _autoExpandNodes = value), + ), + SwitchListTile( + title: const Text('Показывать соединения'), + value: _showConnections, + onChanged: (value) => + setState(() => _showConnections = value), + ), + SwitchListTile( + title: const Text('Тени узлов'), + value: _enableShadows, + onChanged: (value) => setState(() => _enableShadows = value), + ), + ], + ), + const SizedBox(height: 24), + _buildSection( + 'Соединительные линии', + [ + ListTile( + title: const Text('Толщина линий'), + subtitle: Text('${_connectionWidth.toStringAsFixed(1)}px'), + trailing: SizedBox( + width: 150, + child: Slider( + value: _connectionWidth, + min: 1.0, + max: 5.0, + divisions: 8, + label: _connectionWidth.toStringAsFixed(1), + onChanged: (value) => + setState(() => _connectionWidth = value), + ), + ), + ), + ListTile( + title: const Text('Цвет линий'), + trailing: GestureDetector( + onTap: () => _showColorPicker('connection'), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _connectionColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 24), + _buildSection( + 'Цвета узлов', + [ + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_nodeColors.length, (index) { + return GestureDetector( + onTap: () => _showColorPicker('node_$index'), + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: _nodeColors[index], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + child: Center( + child: Text( + '${index + 1}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + }), + ), + const SizedBox(height: 8), + Text( + 'Нажмите на цвет чтобы изменить', + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ], + ), + const SizedBox(height: 32), + _buildSection( + 'Экспорт / Импорт', + [ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: _exportMindMap, + icon: const Icon(Icons.download), + label: const Text('Экспорт JSON'), + ), + ), + const SizedBox(width: 16), + Expanded( + child: OutlinedButton.icon( + onPressed: _importMindMap, + icon: const Icon(Icons.upload), + label: const Text('Импорт JSON'), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 32), + _buildResetButton(), + ], + ), + ); + } + + Widget _buildSection(String title, List children) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...children, + ], + ), + ); + } + + Widget _buildDropdown( + String label, + T value, + List items, + void Function(T?) onChanged, { + Map? labels, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: DropdownButtonFormField( + value: value, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + items: items.map((item) { + return DropdownMenuItem( + value: item, + child: Text(labels?[item] ?? item.toString().split('.').last), + ); + }).toList(), + onChanged: onChanged, + ), + ); + } + + void _showColorPicker(String type) async { + final colors = [ + const Color(0xFF2196F3), + const Color(0xFF4CAF50), + const Color(0xFFFF9800), + const Color(0xFFE91E63), + const Color(0xFF9C27B0), + const Color(0xFF00BCD4), + const Color(0xFFF44336), + const Color(0xFF795548), + const Color(0xFF607D8B), + const Color(0xFFB0BEC5), + ]; + + final selected = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Выберите цвет'), + content: Wrap( + spacing: 8, + runSpacing: 8, + children: colors.map((color) { + return GestureDetector( + onTap: () => Navigator.pop(context, color), + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey), + ), + ), + ); + }).toList(), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Отмена'), + ), + ], + ), + ); + + if (selected != null) { + setState(() { + if (type == 'connection') { + _connectionColor = selected; + } else if (type.startsWith('node_')) { + final index = int.parse(type.split('_')[1]); + _nodeColors[index] = selected; + } + }); + } + } + + Widget _buildResetButton() { + return OutlinedButton.icon( + onPressed: () { + setState(() { + _defaultLayout = rm.MindMapLayout.radial; + _defaultShape = rm.NodeShape.roundedRectangle; + _defaultCamera = rm.CameraFocus.fitAll; + _autoExpandNodes = true; + _showConnections = true; + _enableShadows = true; + _connectionWidth = 2.0; + _connectionColor = const Color(0xFFB0BEC5); + _nodeColors = const [ + Color(0xFF2196F3), + Color(0xFF4CAF50), + Color(0xFFFF9800), + Color(0xFFE91E63), + Color(0xFF9C27B0), + ]; + }); + }, + icon: const Icon(Icons.restore, color: Colors.red), + label: const Text('Сбросить настройки', + style: TextStyle(color: Colors.red)), + ); + } +} diff --git a/lib/screens/mind_map_viewer_screen.dart b/lib/screens/mind_map_viewer_screen.dart new file mode 100644 index 0000000..9ded9e7 --- /dev/null +++ b/lib/screens/mind_map_viewer_screen.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:reactive_mind_map/reactive_mind_map.dart' as rm; +import '../models/mindmap_model.dart'; + +class MindMapViewerScreen extends StatefulWidget { + final MindMapModel mindMap; + + const MindMapViewerScreen({super.key, required this.mindMap}); + + @override + State createState() => _MindMapViewerScreenState(); +} + +class _MindMapViewerScreenState extends State { + rm.MindMapLayout _currentLayout = rm.MindMapLayout.radial; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.mindMap.title), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.account_tree), + tooltip: 'Тип layout', + onSelected: (layout) => setState(() => _currentLayout = layout), + itemBuilder: (context) => [ + _popupMenuItem( + rm.MindMapLayout.radial, 'Радиальный', Icons.circle_outlined), + _popupMenuItem( + rm.MindMapLayout.right, 'Вправо', Icons.arrow_forward), + _popupMenuItem(rm.MindMapLayout.left, 'Влево', Icons.arrow_back), + _popupMenuItem(rm.MindMapLayout.horizontal, 'Горизонтальный', + Icons.swap_horiz), + _popupMenuItem( + rm.MindMapLayout.vertical, 'Вертикальный', Icons.swap_vert), + ], + ), + ], + ), + body: widget.mindMap.nodes.isEmpty + ? _buildEmptyState() + : rm.MindMapWidget( + data: _buildMindMapData(), + style: rm.MindMapStyle( + layout: _currentLayout, + nodeShape: rm.NodeShape.roundedRectangle, + // Используем стандартные узлы (без кастомизации) + connectionColor: Colors.blue.shade100, + connectionWidth: 2.0, + ), + cameraFocus: rm.CameraFocus.fitAll, + onNodeTap: (node) { + // TODO: Редактирование узла + }, + ), + ); + } + + rm.MindMapData _buildMindMapData() { + // Находим корневой узел (level 0) + final rootNodes = + widget.mindMap.nodes.where((n) => n['level'] == 0).toList(); + + if (rootNodes.isEmpty) { + return rm.MindMapData( + id: 'empty', + title: widget.mindMap.title, + children: [], + ); + } + + final root = rootNodes.first; + return rm.MindMapData( + id: root['id'] as String, + title: root['title'] as String, + color: _hexToColor(root['color'] as String? ?? '#2196F3'), + children: _buildChildren(root['id'] as String), + ); + } + + List _buildChildren(String parentId) { + final children = + widget.mindMap.nodes.where((n) => n['parentId'] == parentId).toList(); + + return children.map((node) { + return rm.MindMapData( + id: node['id'] as String, + title: node['title'] as String, + color: _hexToColor(node['color'] as String? ?? '#4CAF50'), + children: _buildChildren(node['id'] as String), + ); + }).toList(); + } + + Widget _buildCustomNode( + rm.MindMapData node, + VoidCallback onTap, + VoidCallback onLongPress, + bool isSelected, + ) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: node.color ?? const Color(0xFF2196F3), + borderRadius: BorderRadius.circular(12), + border: isSelected ? Border.all(color: Colors.white, width: 3) : null, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, color: Colors.white70, size: 12), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + node.title, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + ), + if (node.children.isNotEmpty) ...[ + const SizedBox(width: 8), + Icon(Icons.keyboard_arrow_down, color: Colors.white70, size: 16), + ], + ], + ), + ), + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.account_tree, size: 100, color: Colors.grey[400]), + const SizedBox(height: 24), + Text( + 'Mind map пуста', + style: TextStyle(fontSize: 20, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Добавьте узлы для начала работы', + style: TextStyle(color: Colors.grey[500]), + ), + ], + ), + ); + } + + PopupMenuItem _popupMenuItem( + rm.MindMapLayout layout, + String title, + IconData icon, + ) { + return PopupMenuItem( + value: layout, + child: Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 12), + Text(title), + ], + ), + ); + } + + Color _hexToColor(String hex) { + hex = hex.replaceAll('#', ''); + if (hex.length == 6) { + return Color(int.parse('FF$hex', radix: 16)); + } + return const Color(0xFF2196F3); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart new file mode 100644 index 0000000..bff3cfa --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; + +class ProfileScreen extends ConsumerStatefulWidget { + const ProfileScreen({super.key}); + + @override + ConsumerState createState() => _ProfileScreenState(); +} + +class _ProfileScreenState extends ConsumerState { + bool _isEditing = false; + final _nameController = TextEditingController(); + String? _photoUrl; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadProfile(); + } + + Future _loadProfile() async { + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + _nameController.text = user.displayName ?? ''; + _photoUrl = user.photoURL; + } + setState(() => _isLoading = false); + } + + Future _saveProfile() async { + final user = FirebaseAuth.instance.currentUser; + if (user != null) { + await user.updateDisplayName(_nameController.text); + await FirebaseFirestore.instance + .collection('users') + .doc(user.uid) + .update({ + 'displayName': _nameController.text, + 'updatedAt': Timestamp.now(), + }); + + setState(() => _isEditing = false); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ Профиль сохранён'), + backgroundColor: Colors.green, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + final user = FirebaseAuth.instance.currentUser; + + return Scaffold( + appBar: AppBar( + title: const Text('Профиль'), + actions: [ + IconButton( + icon: Icon(_isEditing ? Icons.check : Icons.edit), + onPressed: () { + if (_isEditing) { + _saveProfile(); + } else { + setState(() => _isEditing = true); + } + }, + tooltip: _isEditing ? 'Сохранить' : 'Редактировать', + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Аватар + Center( + child: Stack( + children: [ + CircleAvatar( + radius: 60, + backgroundColor: Colors.blue, + backgroundImage: _photoUrl != null + ? NetworkImage(_photoUrl!) + : null, + child: _photoUrl == null + ? Text( + user?.displayName?.substring(0, 1).toUpperCase() ?? + 'U', + style: const TextStyle( + fontSize: 40, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ) + : null, + ), + if (_isEditing) + Positioned( + bottom: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(4), + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.camera_alt, + color: Colors.white, + size: 20, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Имя + TextField( + controller: _nameController, + enabled: _isEditing, + decoration: const InputDecoration( + labelText: 'Имя', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 16), + + // Email + ListTile( + leading: const Icon(Icons.email), + title: const Text('Email'), + subtitle: Text(user?.email ?? 'Не указан'), + enabled: false, + ), + + // UID + ListTile( + leading: const Icon(Icons.fingerprint), + title: const Text('User ID'), + subtitle: Text(user?.uid ?? 'Неизвестно'), + enabled: false, + ), + + const Divider(height: 32), + + // Статистика + _buildStatCard( + 'Статистика', + [ + _buildStatRow('Задач создано', '12'), + _buildStatRow('Mind-карт', '5'), + _buildStatRow('Будильников', '3'), + _buildStatRow('Дней в приложении', '30'), + ], + ), + + const SizedBox(height: 24), + + // Кнопка выхода + OutlinedButton.icon( + onPressed: () async { + await FirebaseAuth.instance.signOut(); + }, + icon: const Icon(Icons.logout, color: Colors.red), + label: const Text('Выйти', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + Widget _buildStatCard(String title, List children) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ...children, + ], + ), + ); + } + + Widget _buildStatRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey[600])), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/task_form_screen.dart b/lib/screens/task_form_screen.dart index a9347cb..98eb529 100644 --- a/lib/screens/task_form_screen.dart +++ b/lib/screens/task_form_screen.dart @@ -26,7 +26,7 @@ class _TaskFormScreenState extends ConsumerState { TaskPriority _priority = TaskPriority.medium; DateTime? _dueDate; int _estimatedHours = 0; - List _alarms = []; + final List _alarms = []; bool _isGenerating = false; @override @@ -267,7 +267,7 @@ class _TaskFormScreenState extends ConsumerState { // Приоритет DropdownButtonFormField( - value: _priority, + initialValue: _priority, decoration: const InputDecoration( labelText: 'Приоритет', border: OutlineInputBorder(), diff --git a/lib/widgets/common_widgets.dart b/lib/widgets/common_widgets.dart new file mode 100644 index 0000000..67e5260 --- /dev/null +++ b/lib/widgets/common_widgets.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +/// Карточка статистики +class StatCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color? color; + final VoidCallback? onTap; + + const StatCard({ + super.key, + required this.title, + required this.value, + required this.icon, + this.color, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, color: color ?? Colors.blue), + const SizedBox(height: 12), + Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: color ?? Colors.blue, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ], + ), + ), + ); + } +} + +/// Пустое состояние +class EmptyState extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final Widget? action; + + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + this.action, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 80, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + title, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[500]), + ), + if (action != null) ...[ + const SizedBox(height: 24), + action!, + ], + ], + ), + ); + } +} + +/// Загрузочный экран +class LoadingScreen extends StatelessWidget { + final String? message; + + const LoadingScreen({super.key, this.message}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: TextStyle(color: Colors.grey[600]), + ), + ], + ], + ), + ); + } +} + +/// Экран ошибки +class ErrorScreen extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const ErrorScreen({ + super.key, + required this.message, + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 64, color: Colors.red), + const SizedBox(height: 16), + Text( + 'Ошибка', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey[600]), + ), + if (onRetry != null) ...[ + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Повторить'), + ), + ], + ], + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index b37349a..1b99304 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1008,6 +1008,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + reactive_mind_map: + dependency: "direct main" + description: + name: reactive_mind_map + sha256: ee7a09c48fc3cd593c8539fee8e4229bf26229580aad3ee35a235c1523dabee8 + url: "https://pub.dev" + source: hosted + version: "1.2.2" riverpod: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 17e7c59..62902fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: cupertino_icons: ^1.0.8 flutter_staggered_grid_view: ^0.7.0 syncfusion_flutter_gauges: ^25.1.38 + + # Mind Maps + reactive_mind_map: ^1.0.3 graphview: ^1.2.0 flutter_mind_map: ^1.0.23 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..7fb34b2 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,21 @@ +# Nanaxe Server Configuration +# Copy this file to .env and update the values + +# Domain configuration +GITEA_DOMAIN=your-domain.com + +# Gitea Configuration +GITEA_TOKEN=your_gitea_api_token_here + +# Security +JWT_SECRET=your-super-secret-jwt-key-change-in-production + +# WebDAV (optional) +WEBDAV_USER=admin +WEBDAV_PASSWORD=secure_password_here + +# Database (change in production!) +POSTGRES_PASSWORD=change_this_password + +# API Configuration +API_PORT=8080 diff --git a/server/DEPLOY.md b/server/DEPLOY.md new file mode 100644 index 0000000..120e6d6 --- /dev/null +++ b/server/DEPLOY.md @@ -0,0 +1,369 @@ +# 🚀 Развёртывание Nanaxe Server на Proxmox VPS + +## 📋 Содержание +1. [Подготовка VPS](#подготовка-vps) +2. [Установка Docker](#установка-docker) +3. [Настройка сервера](#настройка-сервера) +4. [Запуск](#запуск) +5. [Настройка домена и SSL](#настройка-домена-и-ssl) +6. [Подключение Flutter-приложения](#подключение-flutter-приложения) + +--- + +## Подготовка VPS + +### 1. Создание LXC контейнера в Proxmox + +```bash +# На хосте Proxmox создайте контейнер +pct create 100 local:vztmpl/ubuntu-22.04-standard_22.04-1_amd64.tar.zst \ + --hostname nanaxe-server \ + --memory 2048 \ + --cores 2 \ + --rootfs local-lvm:10 \ + --net0 name=eth0,bridge=vmbr0,ip=dhcp + +# Запустите контейнер +pct start 100 + +# Войдите в контейнер +pct enter 100 +``` + +### 2. Обновление системы + +```bash +apt update && apt upgrade -y +apt install -y curl wget git nano htop +``` + +--- + +## Установка Docker + +```bash +# Установка Docker +curl -fsSL https://get.docker.com -o get-docker.sh +sh get-docker.sh + +# Добавление пользователя в группу docker +usermod -aG docker $USER + +# Установка Docker Compose +apt install -y docker-compose-plugin + +# Проверка +docker --version +docker compose version +``` + +--- + +## Настройка сервера + +### 1. Клонирование репозитория + +```bash +cd /opt +git clone nanaxe-server +cd nanaxe-server/server +``` + +### 2. Настройка переменных окружения + +```bash +cp .env.example .env +nano .env +``` + +Заполните файл: + +```env +# Domain configuration +GITEA_DOMAIN=your-domain.com + +# Gitea Configuration +GITEA_TOKEN=your_gitea_api_token_here + +# Security (сгенерируйте случайный ключ) +JWT_SECRET=$(openssl rand -base64 32) + +# WebDAV (optional) +WEBDAV_USER=admin +WEBDAV_PASSWORD=your_secure_password + +# Database (обязательно измените!) +POSTGRES_PASSWORD=$(openssl rand -base64 24) +``` + +### 3. Создание директорий + +```bash +mkdir -p api/logs +mkdir -p nginx/html +``` + +--- + +## Запуск + +### 1. Первый запуск + +```bash +docker compose up -d +``` + +### 2. Проверка статуса + +```bash +docker compose ps +docker compose logs -f +``` + +### 3. Инициализация Gitea + +Откройте `http://your-server-ip:3000` и выполните первоначальную настройку: + +1. Установите базу данных (уже настроена PostgreSQL) +2. Создайте администратора +3. Создайте API токен в Settings → Applications +4. Добавьте токен в `.env` файл + +```bash +# Перезапустите сервисы +docker compose restart +``` + +--- + +## Настройка домена и SSL + +### 1. Настройка DNS + +Укажите A-записи: +- `your-domain.com` → IP сервера +- `git.your-domain.com` → IP сервера +- `api.your-domain.com` → IP сервера + +### 2. Установка Certbot + +```bash +apt install -y certbot + +# Получение сертификатов +certbot certonly --standalone -d your-domain.com -d git.your-domain.com -d api.your-domain.com +``` + +### 3. Настройка Nginx + +Отредактируйте `nginx/nginx.conf`: + +```nginx +# Раскомментируйте HTTPS server section +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + + # ... rest of config +} +``` + +### 4. Обновление docker-compose.yml + +Добавьте volume для SSL сертификатов: + +```yaml +nginx: + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/html:/usr/share/nginx/html:ro + - /etc/letsencrypt:/etc/letsencrypt:ro # Добавьте это +``` + +### 5. Перезапуск + +```bash +docker compose down +docker compose up -d +``` + +--- + +## Подключение Flutter-приложения + +### 1. Настройка сервера в приложении + +```dart +import 'package:nanaxe_crm/core/nanaxe_server_service.dart'; + +final serverService = NanaxeServerService(); + +// При инициализации +await serverService.initialize(); + +// Если не настроен - настройте +if (!serverService.isConfigured) { + await serverService.configure( + 'https://api.your-domain.com', + ); +} + +// Проверка соединения +final isConnected = await serverService.checkConnection(); +``` + +### 2. Авторизация + +```dart +// Регистрация +await serverService.register( + 'user@example.com', + 'password', + 'username', +); + +// Или вход +await serverService.login( + 'user@example.com', + 'password', +); +``` + +### 3. Работа с mindmaps + +```dart +// Получение списка +final mindmaps = await serverService.getMindMaps(); + +// Создание +final newMap = await serverService.createMindMap( + title: 'My Project', + description: 'Project planning', + nodes: [ + {'id': '1', 'title': 'Root', 'level': 0, 'parentId': null}, + {'id': '2', 'title': 'Task 1', 'level': 1, 'parentId': '1'}, + ], +); + +// Синхронизация с Gitea +await serverService.pushToGitea(commitMessage: 'Updated project plan'); +``` + +--- + +## 🔧 Обслуживание + +### Обновление + +```bash +cd /opt/nanaxe-server/server +git pull +docker compose pull +docker compose up -d +``` + +### Бэкап + +```bash +# Бэкап данных +tar -czf backup-$(date +%Y%m%d).tar.gz /opt/nanaxe-server/server + +# Или бэкап volumes +docker run --rm -v nanaxe-server_mindmap-data:/data -v $(pwd):/backup alpine tar czf /backup/mindmaps-backup.tar.gz -C /data . +``` + +### Мониторинг + +```bash +# Логи +docker compose logs -f + +# Статистика +docker stats + +# Дисковое пространство +df -h +docker system df +``` + +--- + +## 🌐 Доступ к сервисам + +После развёртывания: + +| Сервис | URL | Описание | +|--------|-----|----------| +| Web UI | `http://your-domain.com` | Главная страница | +| Gitea | `http://your-domain.com:3000` | Git репозиторий | +| API | `http://your-domain.com:8080` | REST API | +| WebDAV | `http://your-domain.com:8081` | Прямой доступ к файлам | + +--- + +## 📱 Obsidian Интеграция + +### 1. Git Sync плагин + +Установите плагин **Obsidian Git** и настройте: + +```bash +# В настройках плагина +Remote URL: http://your-domain.com:3000/username/mindmaps.git +``` + +### 2. Прямой доступ через WebDAV + +Установите плагин **Remote Save** и настройте WebDAV: + +``` +URL: http://your-domain.com:8081 +Username: admin +Password: your_secure_password +``` + +--- + +## 🆘 Устранение неполадок + +### Проблема: Порты заняты + +```bash +# Проверка занятых портов +netstat -tlnp | grep -E '3000|8080|80' + +# Измените порты в docker-compose.yml +``` + +### Проблема: Permission denied + +```bash +# Проверьте права +ls -la /opt/nanaxe-server/server + +# Исправьте +chown -R $USER:$USER /opt/nanaxe-server/server +``` + +### Проблема: Контейнеры не запускаются + +```bash +# Проверьте логи +docker compose logs + +# Пересоберите +docker compose down +docker compose build --no-cache +docker compose up -d +``` + +--- + +## 📚 Дополнительно + +- [Gitea Documentation](https://docs.gitea.io/) +- [Docker Documentation](https://docs.docker.com/) +- [Nginx Documentation](https://nginx.org/en/docs/) diff --git a/server/api/Dockerfile b/server/api/Dockerfile new file mode 100644 index 0000000..fa53d12 --- /dev/null +++ b/server/api/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +# Install git for repository operations +RUN apk add --no-cache git + +# Create app directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy app source +COPY src/ ./src/ + +# Create directories +RUN mkdir -p /app/mindmaps /app/logs + +# Expose port +EXPOSE 8080 + +# Start the server +CMD ["npm", "start"] diff --git a/server/api/package.json b/server/api/package.json new file mode 100644 index 0000000..64a348e --- /dev/null +++ b/server/api/package.json @@ -0,0 +1,34 @@ +{ + "name": "nanaxe-api-server", + "version": "1.0.0", + "description": "API server for Nanaxe CRM mindmap synchronization", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "test": "jest" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "helmet": "^7.1.0", + "morgan": "^1.10.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "axios": "^1.6.2", + "simple-git": "^3.21.0", + "fs-extra": "^11.2.0", + "uuid": "^9.0.1", + "dotenv": "^16.3.1", + "express-rate-limit": "^7.1.5", + "multer": "^1.4.5-lts.1", + "yaml": "^2.3.4" + }, + "devDependencies": { + "nodemon": "^3.0.2", + "jest": "^29.7.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/server/api/src/index.js b/server/api/src/index.js new file mode 100644 index 0000000..d34dbbf --- /dev/null +++ b/server/api/src/index.js @@ -0,0 +1,64 @@ +const express = require('express'); +const cors = require('cors'); +const helmet = require('helmet'); +const morgan = require('morgan'); +const rateLimit = require('express-rate-limit'); +require('dotenv').config(); + +const mindmapRoutes = require('./routes/mindmap'); +const syncRoutes = require('./routes/sync'); +const authRoutes = require('./routes/auth'); +const { errorHandler } = require('./middleware/errorHandler'); + +const app = express(); +const PORT = process.env.PORT || 8080; + +// Security middleware +app.use(helmet()); +app.use(cors({ + origin: process.env.CORS_ORIGIN || '*', + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +})); + +// Rate limiting +const limiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100 // limit each IP to 100 requests per windowMs +}); +app.use(limiter); + +// Body parsing +app.use(express.json({ limit: '10mb' })); +app.use(express.urlencoded({ extended: true })); + +// Logging +app.use(morgan('combined')); + +// Health check +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: '1.0.0' + }); +}); + +// API Routes +app.use('/api/auth', authRoutes); +app.use('/api/mindmaps', mindmapRoutes); +app.use('/api/sync', syncRoutes); + +// Error handling +app.use(errorHandler); + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 Nanaxe API Server running on port ${PORT}`); + console.log(`📁 Mindmap storage: ${process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'}`); + console.log(`🔗 Gitea URL: ${process.env.GITEA_URL || 'http://gitea:3000'}`); +}); diff --git a/server/api/src/middleware/auth.js b/server/api/src/middleware/auth.js new file mode 100644 index 0000000..fd57f8b --- /dev/null +++ b/server/api/src/middleware/auth.js @@ -0,0 +1,34 @@ +const jwt = require('jsonwebtoken'); + +const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key'; + +const authenticateToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + jwt.verify(token, JWT_SECRET, (err, user) => { + if (err) { + return res.status(403).json({ error: 'Invalid or expired token' }); + } + req.user = user; + next(); + }); +}; + +const generateToken = (user) => { + return jwt.sign( + { + id: user.id, + email: user.email, + username: user.username + }, + JWT_SECRET, + { expiresIn: '7d' } + ); +}; + +module.exports = { authenticateToken, generateToken }; diff --git a/server/api/src/middleware/errorHandler.js b/server/api/src/middleware/errorHandler.js new file mode 100644 index 0000000..1618b7f --- /dev/null +++ b/server/api/src/middleware/errorHandler.js @@ -0,0 +1,33 @@ +const errorHandler = (err, req, res, next) => { + console.error('Error:', err); + + // Default error + let statusCode = err.statusCode || 500; + let message = err.message || 'Internal Server Error'; + + // Handle specific error types + if (err.name === 'ValidationError') { + statusCode = 400; + message = 'Validation Error: ' + message; + } else if (err.name === 'UnauthorizedError') { + statusCode = 401; + message = 'Unauthorized'; + } else if (err.name === 'ForbiddenError') { + statusCode = 403; + message = 'Forbidden'; + } else if (err.name === 'NotFoundError') { + statusCode = 404; + message = 'Resource not found'; + } + + res.status(statusCode).json({ + error: { + message, + status: statusCode, + timestamp: new Date().toISOString(), + path: req.path + } + }); +}; + +module.exports = { errorHandler }; diff --git a/server/api/src/routes/auth.js b/server/api/src/routes/auth.js new file mode 100644 index 0000000..d11c568 --- /dev/null +++ b/server/api/src/routes/auth.js @@ -0,0 +1,97 @@ +const express = require('express'); +const router = express.Router(); +const { generateToken } = require('../middleware/auth'); + +// Mock user storage (в продакшене использовать базу данных) +const users = new Map(); + +// Register +router.post('/register', async (req, res) => { + try { + const { email, password, username } = req.body; + + if (!email || !password || !username) { + return res.status(400).json({ error: 'Email, password and username are required' }); + } + + if (users.has(email)) { + return res.status(409).json({ error: 'User already exists' }); + } + + const userId = Date.now().toString(); + const user = { + id: userId, + email, + username, + password, // В продакшене хешировать! + createdAt: new Date().toISOString() + }; + + users.set(email, user); + + const token = generateToken(user); + + res.status(201).json({ + message: 'User registered successfully', + token, + user: { + id: user.id, + email: user.email, + username: user.username + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Login +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body; + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); + } + + const user = users.get(email); + + if (!user || user.password !== password) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const token = generateToken(user); + + res.json({ + message: 'Login successful', + token, + user: { + id: user.id, + email: user.email, + username: user.username + } + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Validate token +router.get('/validate', async (req, res) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ valid: false, error: 'No token provided' }); + } + + try { + const jwt = require('jsonwebtoken'); + const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-super-secret-jwt-key'); + res.json({ valid: true, user: decoded }); + } catch (error) { + res.status(403).json({ valid: false, error: 'Invalid token' }); + } +}); + +module.exports = router; diff --git a/server/api/src/routes/mindmap.js b/server/api/src/routes/mindmap.js new file mode 100644 index 0000000..32158b0 --- /dev/null +++ b/server/api/src/routes/mindmap.js @@ -0,0 +1,266 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs-extra'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const { authenticateToken } = require('../middleware/auth'); +const MarkdownConverter = require('../utils/markdownConverter'); + +const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'; + +// Ensure storage directory exists +fs.ensureDirSync(STORAGE_PATH); + +/** + * GET /api/mindmaps - List all mindmaps + */ +router.get('/', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const userDir = path.join(STORAGE_PATH, userId); + + await fs.ensureDir(userDir); + + const files = await fs.readdir(userDir); + const mindmaps = []; + + for (const file of files) { + if (file.endsWith('.md')) { + const filePath = path.join(userDir, file); + const content = await fs.readFile(filePath, 'utf-8'); + const mindmap = MarkdownConverter.fromMarkdown(content); + + mindmaps.push({ + id: mindmap.id, + title: mindmap.title, + description: mindmap.description, + nodeCount: mindmap.nodes.length, + updatedAt: mindmap.updatedAt, + filename: file + }); + } + } + + // Sort by updatedAt desc + mindmaps.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); + + res.json({ + count: mindmaps.length, + mindmaps + }); + } catch (error) { + console.error('Error listing mindmaps:', error); + res.status(500).json({ error: 'Failed to list mindmaps' }); + } +}); + +/** + * GET /api/mindmaps/:id - Get single mindmap + */ +router.get('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const filePath = path.join(STORAGE_PATH, userId, `${id}.md`); + + if (!await fs.pathExists(filePath)) { + return res.status(404).json({ error: 'Mindmap not found' }); + } + + const content = await fs.readFile(filePath, 'utf-8'); + const mindmap = MarkdownConverter.fromMarkdown(content, id); + + res.json(mindmap); + } catch (error) { + console.error('Error getting mindmap:', error); + res.status(500).json({ error: 'Failed to get mindmap' }); + } +}); + +/** + * POST /api/mindmaps - Create new mindmap + */ +router.post('/', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { title, description, nodes = [], settings = {} } = req.body; + + if (!title) { + return res.status(400).json({ error: 'Title is required' }); + } + + const id = uuidv4(); + const now = new Date().toISOString(); + + const mindmap = { + id, + title, + description: description || '', + nodes: nodes.length > 0 ? nodes : [{ + id: uuidv4(), + title: 'Root', + level: 0, + parentId: null, + createdAt: now, + updatedAt: now + }], + settings, + createdAt: now, + updatedAt: now + }; + + const userDir = path.join(STORAGE_PATH, userId); + await fs.ensureDir(userDir); + + const markdown = MarkdownConverter.toMarkdown(mindmap); + const filePath = path.join(userDir, `${id}.md`); + + await fs.writeFile(filePath, markdown, 'utf-8'); + + res.status(201).json(mindmap); + } catch (error) { + console.error('Error creating mindmap:', error); + res.status(500).json({ error: 'Failed to create mindmap' }); + } +}); + +/** + * PUT /api/mindmaps/:id - Update mindmap + */ +router.put('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const updates = req.body; + + const filePath = path.join(STORAGE_PATH, userId, `${id}.md`); + + if (!await fs.pathExists(filePath)) { + return res.status(404).json({ error: 'Mindmap not found' }); + } + + // Read existing + const content = await fs.readFile(filePath, 'utf-8'); + const existing = MarkdownConverter.fromMarkdown(content, id); + + // Merge updates + const updated = { + ...existing, + ...updates, + id, // Preserve ID + updatedAt: new Date().toISOString() + }; + + // Save + const markdown = MarkdownConverter.toMarkdown(updated); + await fs.writeFile(filePath, markdown, 'utf-8'); + + res.json(updated); + } catch (error) { + console.error('Error updating mindmap:', error); + res.status(500).json({ error: 'Failed to update mindmap' }); + } +}); + +/** + * DELETE /api/mindmaps/:id - Delete mindmap + */ +router.delete('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const filePath = path.join(STORAGE_PATH, userId, `${id}.md`); + + if (!await fs.pathExists(filePath)) { + return res.status(404).json({ error: 'Mindmap not found' }); + } + + await fs.remove(filePath); + + res.json({ message: 'Mindmap deleted successfully' }); + } catch (error) { + console.error('Error deleting mindmap:', error); + res.status(500).json({ error: 'Failed to delete mindmap' }); + } +}); + +/** + * GET /api/mindmaps/:id/markdown - Get mindmap as raw markdown + */ +router.get('/:id/markdown', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const filePath = path.join(STORAGE_PATH, userId, `${id}.md`); + + if (!await fs.pathExists(filePath)) { + return res.status(404).json({ error: 'Mindmap not found' }); + } + + const content = await fs.readFile(filePath, 'utf-8'); + + res.setHeader('Content-Type', 'text/markdown'); + res.setHeader('Content-Disposition', `attachment; filename="${id}.md"`); + res.send(content); + } catch (error) { + console.error('Error getting markdown:', error); + res.status(500).json({ error: 'Failed to get markdown' }); + } +}); + +/** + * POST /api/mindmaps/:id/markdown - Update mindmap from markdown + */ +router.post('/:id/markdown', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const { markdown } = req.body; + + if (!markdown) { + return res.status(400).json({ error: 'Markdown content is required' }); + } + + const userDir = path.join(STORAGE_PATH, userId); + await fs.ensureDir(userDir); + + const filePath = path.join(userDir, `${id}.md`); + + await fs.writeFile(filePath, markdown, 'utf-8'); + + // Parse and return + const mindmap = MarkdownConverter.fromMarkdown(markdown, id); + + res.json(mindmap); + } catch (error) { + console.error('Error saving markdown:', error); + res.status(500).json({ error: 'Failed to save markdown' }); + } +}); + +/** + * GET /api/mindmaps/:id/markmap - Get mindmap in Markmap format + */ +router.get('/:id/markmap', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.id; + const filePath = path.join(STORAGE_PATH, userId, `${id}.md`); + + if (!await fs.pathExists(filePath)) { + return res.status(404).json({ error: 'Mindmap not found' }); + } + + const content = await fs.readFile(filePath, 'utf-8'); + const mindmap = MarkdownConverter.fromMarkdown(content, id); + const markmap = MarkdownConverter.toMarkmap(mindmap); + + res.setHeader('Content-Type', 'text/markdown'); + res.send(markmap); + } catch (error) { + console.error('Error getting markmap:', error); + res.status(500).json({ error: 'Failed to get markmap' }); + } +}); + +module.exports = router; diff --git a/server/api/src/routes/sync.js b/server/api/src/routes/sync.js new file mode 100644 index 0000000..225797f --- /dev/null +++ b/server/api/src/routes/sync.js @@ -0,0 +1,268 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs-extra'); +const path = require('path'); +const simpleGit = require('simple-git'); +const { authenticateToken } = require('../middleware/auth'); +const MarkdownConverter = require('../utils/markdownConverter'); + +const STORAGE_PATH = process.env.MINDMAP_STORAGE_PATH || '/app/mindmaps'; +const GITEA_URL = process.env.GITEA_URL || 'http://gitea:3000'; +const GITEA_TOKEN = process.env.GITEA_TOKEN; + +/** + * POST /api/sync/gitea/push - Push mindmaps to Gitea repository + */ +router.post('/gitea/push', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { repoName, commitMessage } = req.body; + + const userDir = path.join(STORAGE_PATH, userId); + const repoDir = path.join(userDir, '.git-repo'); + + if (!await fs.pathExists(userDir)) { + return res.status(404).json({ error: 'No mindmaps found' }); + } + + // Initialize or open git repo + const git = simpleGit(repoDir); + + if (!await fs.pathExists(path.join(repoDir, '.git'))) { + await fs.ensureDir(repoDir); + await git.init(); + + // Configure git + await git.addConfig('user.email', req.user.email || 'user@nanaxe.app'); + await git.addConfig('user.name', req.user.username || 'Nanaxe User'); + } + + // Copy all mindmaps to repo + const files = await fs.readdir(userDir); + for (const file of files) { + if (file.endsWith('.md')) { + await fs.copy( + path.join(userDir, file), + path.join(repoDir, file) + ); + } + } + + // Commit changes + await git.add('.'); + const status = await git.status(); + + if (status.files.length === 0) { + return res.json({ message: 'No changes to commit', synced: false }); + } + + await git.commit(commitMessage || 'Sync mindmaps from Nanaxe CRM'); + + // Push to Gitea if configured + if (GITEA_TOKEN) { + const remoteUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`; + + try { + // Check if remote exists + const remotes = await git.getRemotes(true); + const originExists = remotes.some(r => r.name === 'origin'); + + if (!originExists) { + await git.addRemote('origin', remoteUrl); + } + + await git.push('origin', 'master'); + } catch (pushError) { + console.error('Push error:', pushError); + // Continue even if push fails - local commit is successful + } + } + + res.json({ + message: 'Mindmaps synced successfully', + synced: true, + filesCommitted: status.files.length, + commitMessage: commitMessage || 'Sync mindmaps from Nanaxe CRM' + }); + } catch (error) { + console.error('Error syncing to Gitea:', error); + res.status(500).json({ error: 'Failed to sync mindmaps' }); + } +}); + +/** + * POST /api/sync/gitea/pull - Pull mindmaps from Gitea repository + */ +router.post('/gitea/pull', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { repoName } = req.body; + + const userDir = path.join(STORAGE_PATH, userId); + const repoDir = path.join(userDir, '.git-repo'); + + if (!GITEA_TOKEN) { + return res.status(400).json({ error: 'Gitea not configured' }); + } + + const git = simpleGit(repoDir); + + if (!await fs.pathExists(path.join(repoDir, '.git'))) { + await fs.ensureDir(repoDir); + + // Clone from Gitea + const cloneUrl = `${GITEA_URL}/api/v1/repos/${req.user.username}/${repoName || 'mindmaps'}`; + await git.clone(cloneUrl, repoDir); + } else { + // Pull latest + await git.pull('origin', 'master'); + } + + // Copy files from repo to storage + const files = await fs.readdir(repoDir); + let importedCount = 0; + + for (const file of files) { + if (file.endsWith('.md') && !file.startsWith('.')) { + await fs.copy( + path.join(repoDir, file), + path.join(userDir, file), + { overwrite: true } + ); + importedCount++; + } + } + + res.json({ + message: 'Mindmaps pulled successfully', + imported: importedCount + }); + } catch (error) { + console.error('Error pulling from Gitea:', error); + res.status(500).json({ error: 'Failed to pull mindmaps' }); + } +}); + +/** + * GET /api/sync/status - Get sync status + */ +router.get('/status', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const userDir = path.join(STORAGE_PATH, userId); + const repoDir = path.join(userDir, '.git-repo'); + + const status = { + hasLocalData: await fs.pathExists(userDir), + hasGitRepo: await fs.pathExists(path.join(repoDir, '.git')), + giteaConfigured: !!GITEA_TOKEN, + lastSync: null, + pendingChanges: 0 + }; + + if (status.hasGitRepo) { + const git = simpleGit(repoDir); + const gitStatus = await git.status(); + status.pendingChanges = gitStatus.files.length; + + // Get last commit date + try { + const log = await git.log({ maxCount: 1 }); + if (log.latest) { + status.lastSync = log.latest.date; + } + } catch (e) { + // No commits yet + } + } + + res.json(status); + } catch (error) { + console.error('Error getting sync status:', error); + res.status(500).json({ error: 'Failed to get sync status' }); + } +}); + +/** + * POST /api/sync/export - Export all mindmaps as zip + */ +router.post('/export', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const userDir = path.join(STORAGE_PATH, userId); + + if (!await fs.pathExists(userDir)) { + return res.status(404).json({ error: 'No mindmaps found' }); + } + + // Create temporary zip + const archiver = require('archiver'); + const archive = archiver('zip', { zlib: { level: 9 } }); + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', 'attachment; filename="nanaxe-mindmaps.zip"'); + + archive.pipe(res); + + const files = await fs.readdir(userDir); + for (const file of files) { + if (file.endsWith('.md')) { + archive.file(path.join(userDir, file), { name: file }); + } + } + + await archive.finalize(); + } catch (error) { + console.error('Error exporting mindmaps:', error); + res.status(500).json({ error: 'Failed to export mindmaps' }); + } +}); + +/** + * POST /api/sync/import - Import mindmaps from files + */ +router.post('/import', authenticateToken, async (req, res) => { + try { + const userId = req.user.id; + const { files } = req.body; // Array of { filename, content } + + if (!files || !Array.isArray(files)) { + return res.status(400).json({ error: 'Files array required' }); + } + + const userDir = path.join(STORAGE_PATH, userId); + await fs.ensureDir(userDir); + + const results = { + imported: 0, + errors: [] + }; + + for (const file of files) { + try { + if (!file.filename.endsWith('.md')) { + results.errors.push({ file: file.filename, error: 'Not a markdown file' }); + continue; + } + + // Validate markdown + const mindmap = MarkdownConverter.fromMarkdown(file.content); + + // Save + const filePath = path.join(userDir, file.filename); + await fs.writeFile(filePath, file.content, 'utf-8'); + + results.imported++; + } catch (error) { + results.errors.push({ file: file.filename, error: error.message }); + } + } + + res.json(results); + } catch (error) { + console.error('Error importing mindmaps:', error); + res.status(500).json({ error: 'Failed to import mindmaps' }); + } +}); + +module.exports = router; diff --git a/server/api/src/utils/markdownConverter.js b/server/api/src/utils/markdownConverter.js new file mode 100644 index 0000000..0476c6e --- /dev/null +++ b/server/api/src/utils/markdownConverter.js @@ -0,0 +1,261 @@ +/** + * Конвертер между MindMap форматом и Markdown (Obsidian-совместимый) + * + * Obsidian MindMap формат: + * - Заголовки (# ## ###) = уровни узлов + * - Списки (- *) = дочерние узлы + * - [[ссылки]] = внутренние связи + * - Теги #тег = метки узлов + */ + +const YAML = require('yaml'); + +class MarkdownConverter { + /** + * Конвертирует MindMap из JSON в Markdown + * @param {Object} mindmap - Объект mindmap + * @returns {string} Markdown строка + */ + static toMarkdown(mindmap) { + const { id, title, description, nodes, settings = {} } = mindmap; + + // Frontmatter с метаданными + const frontmatter = { + id, + title, + description, + createdAt: mindmap.createdAt, + updatedAt: mindmap.updatedAt, + ...settings + }; + + let markdown = '---\n'; + markdown += YAML.stringify(frontmatter); + markdown += '---\n\n'; + + // Заголовок mindmap + markdown += `# ${title}\n\n`; + + if (description) { + markdown += `> ${description}\n\n`; + } + + // Конвертируем узлы в иерархию + if (nodes && nodes.length > 0) { + const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId); + + for (const root of rootNodes) { + markdown += this._nodeToMarkdown(nodes, root, 0); + } + } + + return markdown; + } + + /** + * Рекурсивно конвертирует узел в Markdown + */ + static _nodeToMarkdown(allNodes, node, level) { + const indent = ' '.repeat(level); + let markdown = ''; + + // Заголовок или список в зависимости от уровня + if (level === 0) { + markdown += `## ${node.title}\n`; + } else if (level === 1) { + markdown += `### ${node.title}\n`; + } else if (level === 2) { + markdown += `#### ${node.title}\n`; + } else { + markdown += `${indent}- ${node.title}\n`; + } + + // Добавляем метаданные узла + if (node.taskId) { + markdown += `${indent} - Task: [[${node.taskId}]]\n`; + } + if (node.color) { + markdown += `${indent} - Color: \`${node.color}\`\n`; + } + + // Находим дочерние узлы + const children = allNodes.filter(n => n.parentId === node.id); + + for (const child of children) { + markdown += this._nodeToMarkdown(allNodes, child, level + 1); + } + + markdown += '\n'; + return markdown; + } + + /** + * Парсит Markdown в MindMap JSON + * @param {string} markdown - Markdown строка + * @returns {Object} Объект mindmap + */ + static fromMarkdown(markdown, existingId = null) { + const lines = markdown.split('\n'); + + // Парсим frontmatter + let frontmatter = {}; + let contentStart = 0; + + if (lines[0] === '---') { + const endIndex = lines.indexOf('---', 1); + if (endIndex !== -1) { + const yamlContent = lines.slice(1, endIndex).join('\n'); + frontmatter = YAML.parse(yamlContent) || {}; + contentStart = endIndex + 1; + } + } + + // Парсим контент + const content = lines.slice(contentStart).join('\n'); + const nodes = this._parseNodes(content); + + return { + id: existingId || frontmatter.id || this._generateId(), + title: frontmatter.title || this._extractTitle(content) || 'Untitled MindMap', + description: frontmatter.description || '', + nodes, + settings: frontmatter.settings || {}, + createdAt: frontmatter.createdAt || new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + } + + /** + * Парсит узлы из Markdown контента + */ + static _parseNodes(content) { + const lines = content.split('\n'); + const nodes = []; + const nodeStack = []; // Стек для отслеживания родительских узлов + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + // Определяем уровень и тип + let level = 0; + let title = ''; + let parentId = null; + + // Заголовки H2-H4 + const headingMatch = trimmed.match(/^(#{2,4})\s+(.+)$/); + if (headingMatch) { + level = headingMatch[1].length - 1; // ## = 1, ### = 2, #### = 3 + title = headingMatch[2].trim(); + } + // Списки + else if (trimmed.match(/^[-*]\s+/)) { + const indent = line.search(/\S/); + level = Math.floor(indent / 2) + 3; + title = trimmed.replace(/^[-*]\s+/, ''); + } + // Пропускаем H1 (это заголовок карты) + else if (trimmed.startsWith('# ')) { + continue; + } + else { + continue; + } + + // Находим родителя + while (nodeStack.length > 0 && nodeStack[nodeStack.length - 1].level >= level) { + nodeStack.pop(); + } + + if (nodeStack.length > 0) { + parentId = nodeStack[nodeStack.length - 1].id; + } + + const node = { + id: this._generateId(), + title, + level, + parentId, + color: this._extractColor(trimmed), + taskId: this._extractTaskId(trimmed), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + + nodes.push(node); + nodeStack.push({ id: node.id, level }); + } + + return nodes; + } + + /** + * Извлекает цвет из строки + */ + static _extractColor(line) { + const colorMatch = line.match(/`#([0-9A-Fa-f]{6})`/); + return colorMatch ? `#${colorMatch[1]}` : null; + } + + /** + * Извлекает ID задачи из ссылки + */ + static _extractTaskId(line) { + const taskMatch = line.match(/\[\[([^\]]+)\]\]/); + return taskMatch ? taskMatch[1] : null; + } + + /** + * Извлекает заголовок из контента + */ + static _extractTitle(content) { + const match = content.match(/^#\s+(.+)$/m); + return match ? match[1].trim() : null; + } + + /** + * Генерирует уникальный ID + */ + static _generateId() { + return 'mm_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + } + + /** + * Конвертирует MindMap в формат Markmap (для визуализации) + * @param {Object} mindmap - Объект mindmap + * @returns {string} Markmap Markdown + */ + static toMarkmap(mindmap) { + const { title, nodes } = mindmap; + + let markdown = `# ${title}\n\n`; + + if (nodes && nodes.length > 0) { + const rootNodes = nodes.filter(n => n.level === 0 || !n.parentId); + + for (const root of rootNodes) { + markdown += this._nodeToMarkmap(nodes, root, 0); + } + } + + return markdown; + } + + /** + * Рекурсивно конвертирует узел в Markmap формат + */ + static _nodeToMarkmap(allNodes, node, level) { + const indent = ' '.repeat(level); + let markdown = `${indent}- ${node.title}\n`; + + const children = allNodes.filter(n => n.parentId === node.id); + + for (const child of children) { + markdown += this._nodeToMarkmap(allNodes, child, level + 1); + } + + return markdown; + } +} + +module.exports = MarkdownConverter; diff --git a/server/docker-compose.prod.yml b/server/docker-compose.prod.yml new file mode 100644 index 0000000..4da6ab0 --- /dev/null +++ b/server/docker-compose.prod.yml @@ -0,0 +1,251 @@ +version: '3.8' + +# Production Docker Compose Configuration +# Usage: docker compose -f docker-compose.prod.yml up -d + +services: + # ========================================== + # Gitea - Production Configuration + # ========================================== + gitea: + image: gitea/gitea:latest-rootless + container_name: nanaxe-gitea + restart: unless-stopped + networks: + - nanaxe-network + volumes: + - gitea-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=postgres:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=gitea + - GITEA__database__PASSWD=${POSTGRES_PASSWORD} + - GITEA__server__DOMAIN=${GITEA_DOMAIN} + - GITEA__server__ROOT_URL=https://${GITEA_DOMAIN} + - GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN} + - GITEA__server__SSH_PORT=222 + - GITEA__server__SSH_LISTEN_PORT=22 + - GITEA__security__INSTALL_LOCK=true + - GITEA__security__SECRET_KEY=${GITEA_SECRET_KEY} + - GITEA__service__DISABLE_REGISTRATION=false + - GITEA__service__REQUIRE_SIGNIN_VIEW=false + - GITEA__log__MODE=console + - GITEA__log__LEVEL=Info + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + + # ========================================== + # PostgreSQL - Production + # ========================================== + postgres: + image: postgres:16-alpine + container_name: nanaxe-postgres + restart: unless-stopped + networks: + - nanaxe-network + volumes: + - postgres-data:/var/lib/postgresql/data + - ./backups:/backups + environment: + - POSTGRES_USER=gitea + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=gitea + - PGDATA=/var/lib/postgresql/data/pgdata + healthcheck: + test: ["CMD-SHELL", "pg_isready -U gitea"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + + # ========================================== + # Nanaxe API - Production + # ========================================== + nanaxe-api: + image: ghcr.io/${GITHUB_REPOSITORY:-nanaxe}/nanaxe-api:latest + container_name: nanaxe-api + restart: unless-stopped + networks: + - nanaxe-network + volumes: + - mindmap-data:/app/mindmaps + - api-logs:/app/logs + environment: + - NODE_ENV=production + - PORT=8080 + - GITEA_URL=http://gitea:3000 + - GITEA_TOKEN=${GITEA_TOKEN} + - JWT_SECRET=${JWT_SECRET} + - MINDMAP_STORAGE_PATH=/app/mindmaps + - LOG_LEVEL=info + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + replicas: 1 + update_config: + parallelism: 1 + delay: 10s + failure_action: rollback + order: start-first + rollback_config: + parallelism: 1 + delay: 10s + failure_action: pause + depends_on: + postgres: + condition: service_healthy + gitea: + condition: service_healthy + + # ========================================== + # Nginx - Production with SSL + # ========================================== + nginx: + image: nginx:alpine + container_name: nanaxe-nginx + restart: unless-stopped + networks: + - nanaxe-network + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/html:/usr/share/nginx/html:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - certbot-data:/etc/letsencrypt:ro + - certbot-www:/var/www/certbot:ro + healthcheck: + test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost/health"] + interval: 30s + timeout: 10s + retries: 3 + deploy: + resources: + limits: + cpus: '0.25' + memory: 128M + depends_on: + - gitea + - nanaxe-api + + # ========================================== + # Certbot - SSL Certificates + # ========================================== + certbot: + image: certbot/certbot + container_name: nanaxe-certbot + restart: "no" + networks: + - nanaxe-network + volumes: + - certbot-data:/etc/letsencrypt + - certbot-www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + profiles: + - ssl + + # ========================================== + # Watchtower - Auto-update containers + # ========================================== + watchtower: + image: containrrr/watchtower + container_name: nanaxe-watchtower + restart: unless-stopped + networks: + - nanaxe-network + volumes: + - /var/run/docker.sock:/var/run/docker.sock + environment: + - WATCHTOWER_CLEANUP=true + - WATCHTOWER_POLL_INTERVAL=3600 + - WATCHTOWER_INCLUDE_STOPPED=true + - WATCHTOWER_REVIVE_STOPPED=false + - WATCHTOWER_ROLLING_RESTART=true + - WATCHTOWER_LABEL_ENABLE=true + command: --interval 3600 --cleanup + profiles: + - auto-update + + # ========================================== + # Redis - Caching (optional) + # ========================================== + redis: + image: redis:7-alpine + container_name: nanaxe-redis + restart: unless-stopped + networks: + - nanaxe-network + volumes: + - redis-data:/data + command: redis-server --appendonly yes --maxmemory 64mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + deploy: + resources: + limits: + cpus: '0.25' + memory: 128M + profiles: + - cache + +networks: + nanaxe-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 + +volumes: + gitea-data: + driver: local + postgres-data: + driver: local + mindmap-data: + driver: local + api-logs: + driver: local + certbot-data: + driver: local + certbot-www: + driver: local + redis-data: + driver: local diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..21d88cd --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,116 @@ +version: '3.8' + +services: + # Gitea - Self-hosted Git server для хранения markdown-файлов + gitea: + image: gitea/gitea:latest + container_name: nanaxe-gitea + environment: + - USER_UID=1000 + - USER_GID=1000 + - GITEA__database__DB_TYPE=postgres + - GITEA__database__HOST=postgres:5432 + - GITEA__database__NAME=gitea + - GITEA__database__USER=gitea + - GITEA__database__PASSWD=gitea_password + - GITEA__server__DOMAIN=${GITEA_DOMAIN:-localhost} + - GITEA__server__ROOT_URL=http://${GITEA_DOMAIN:-localhost}:3000 + - GITEA__server__SSH_DOMAIN=${GITEA_DOMAIN:-localhost} + - GITEA__server__SSH_PORT=222 + - GITEA__server__SSH_LISTEN_PORT=22 + - GITEA__security__INSTALL_LOCK=true + - GITEA__service__DISABLE_REGISTRATION=false + - GITEA__service__REQUIRE_SIGNIN_VIEW=false + restart: always + networks: + - nanaxe-network + volumes: + - gitea-data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "222:22" + depends_on: + - postgres + + # PostgreSQL для Gitea + postgres: + image: postgres:16-alpine + container_name: nanaxe-postgres + environment: + - POSTGRES_USER=gitea + - POSTGRES_PASSWORD=gitea_password + - POSTGRES_DB=gitea + restart: always + networks: + - nanaxe-network + volumes: + - postgres-data:/var/lib/postgresql/data + + # Nanaxe API Server - для синхронизации mindmaps + nanaxe-api: + build: + context: ./api + dockerfile: Dockerfile + container_name: nanaxe-api + environment: + - NODE_ENV=production + - PORT=8080 + - GITEA_URL=http://gitea:3000 + - GITEA_TOKEN=${GITEA_TOKEN} + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - MINDMAP_STORAGE_PATH=/app/mindmaps + restart: always + networks: + - nanaxe-network + volumes: + - mindmap-data:/app/mindmaps + - ./api/logs:/app/logs + ports: + - "8080:8080" + depends_on: + - gitea + - postgres + + # Nginx - Reverse Proxy и статические файлы + nginx: + image: nginx:alpine + container_name: nanaxe-nginx + restart: always + networks: + - nanaxe-network + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/html:/usr/share/nginx/html:ro + ports: + - "80:80" + - "443:443" + depends_on: + - gitea + - nanaxe-api + + # (Опционально) WebDAV сервер для прямого доступа к файлам + webdav: + image: bytemark/webdav + container_name: nanaxe-webdav + restart: always + networks: + - nanaxe-network + volumes: + - mindmap-data:/var/lib/dav + environment: + - AUTH_TYPE=Basic + - USERNAME=${WEBDAV_USER:-admin} + - PASSWORD=${WEBDAV_PASSWORD:-admin} + ports: + - "8081:80" + +networks: + nanaxe-network: + driver: bridge + +volumes: + gitea-data: + postgres-data: + mindmap-data: diff --git a/server/nginx/html/index.html b/server/nginx/html/index.html new file mode 100644 index 0000000..2f79be4 --- /dev/null +++ b/server/nginx/html/index.html @@ -0,0 +1,144 @@ + + + + + + Nanaxe Server + + + +
+

🚀 Nanaxe Server

+

Self-hosted mindmap synchronization server

+ +
+
+

📚 Git Repository

+

Access your mindmaps via Git

+ Open Gitea +
+ +
+

🔌 API

+

REST API for mindmap operations

+ Check API +
+ +
+

📱 WebDAV

+

Direct file access

+ Open WebDAV +
+
+ +
+ + Server is running +
+
+ + diff --git a/server/nginx/nginx.conf b/server/nginx/nginx.conf new file mode 100644 index 0000000..fe35c7e --- /dev/null +++ b/server/nginx/nginx.conf @@ -0,0 +1,124 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; + + # Upstream servers + upstream gitea { + server gitea:3000; + } + + upstream nanaxe_api { + server nanaxe-api:8080; + } + + # HTTP server - redirects to HTTPS + server { + listen 80; + server_name _; + + # For Let's Encrypt challenges + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # Uncomment for HTTPS redirect + # return 301 https://$server_name$request_uri; + + # Or proxy to services for local development + location / { + root /usr/share/nginx/html; + try_files $uri $uri/ /index.html; + } + } + + # API Server + server { + listen 80; + server_name api.*; + + location / { + proxy_pass http://nanaxe_api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + } + + # Gitea Server + server { + listen 80; + server_name git.*; + + location / { + proxy_pass http://gitea; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + } + + # HTTPS server (uncomment when SSL certificates are ready) + # server { + # listen 443 ssl http2; + # server_name your-domain.com; + # + # ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem; + # + # location / { + # root /usr/share/nginx/html; + # try_files $uri $uri/ /index.html; + # } + # + # location /api/ { + # proxy_pass http://nanaxe_api/; + # proxy_http_version 1.1; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # } + # + # location /git/ { + # proxy_pass http://gitea/; + # proxy_http_version 1.1; + # proxy_set_header Host $host; + # proxy_set_header X-Real-IP $remote_addr; + # proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # proxy_set_header X-Forwarded-Proto $scheme; + # } + # } +} diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh new file mode 100644 index 0000000..d9b5bef --- /dev/null +++ b/server/scripts/deploy.sh @@ -0,0 +1,263 @@ +#!/bin/bash + +# ========================================== +# Nanaxe Server Deployment Script +# ========================================== + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +COMPOSE_FILE="docker-compose.prod.yml" +BACKUP_DIR="./backups" +LOG_FILE="./logs/deploy-$(date +%Y%m%d-%H%M%S).log" + +# ========================================== +# Helper Functions +# ========================================== + +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" | tee -a "$LOG_FILE" +} + +error() { + echo -e "${RED}[ERROR]${NC} $1" | tee -a "$LOG_FILE" +} + +success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" | tee -a "$LOG_FILE" +} + +warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" | tee -a "$LOG_FILE" +} + +# ========================================== +# Pre-deployment Checks +# ========================================== + +check_prerequisites() { + log "Checking prerequisites..." + + # Check Docker + if ! command -v docker &> /dev/null; then + error "Docker is not installed" + exit 1 + fi + + # Check Docker Compose + if ! docker compose version &> /dev/null; then + error "Docker Compose is not installed" + exit 1 + fi + + # Check .env file + if [ ! -f .env ]; then + error ".env file not found. Please create it from .env.example" + exit 1 + fi + + # Check compose file + if [ ! -f "$COMPOSE_FILE" ]; then + error "Compose file $COMPOSE_FILE not found" + exit 1 + fi + + success "Prerequisites check passed" +} + +# ========================================== +# Backup +# ========================================== + +create_backup() { + log "Creating backup..." + + mkdir -p "$BACKUP_DIR" + + # Backup database + if docker compose ps postgres | grep -q "running"; then + log "Backing up PostgreSQL database..." + docker compose exec -T postgres pg_dump -U gitea gitea > "$BACKUP_DIR/db-$(date +%Y%m%d-%H%M%S).sql" 2>/dev/null || warning "Database backup failed" + fi + + # Backup mindmaps + if [ -d "mindmap-data" ]; then + log "Backing up mindmaps..." + tar -czf "$BACKUP_DIR/mindmaps-$(date +%Y%m%d-%H%M%S).tar.gz" mindmap-data/ 2>/dev/null || warning "Mindmaps backup failed" + fi + + success "Backup completed" +} + +# ========================================== +# Deployment +# ========================================== + +deploy() { + log "Starting deployment..." + + # Pull latest images + log "Pulling latest images..." + docker compose -f "$COMPOSE_FILE" pull + + # Deploy with zero downtime + log "Deploying services..." + docker compose -f "$COMPOSE_FILE" up -d --remove-orphans + + success "Deployment completed" +} + +# ========================================== +# Health Checks +# ========================================== + +health_check() { + log "Running health checks..." + + local max_attempts=30 + local attempt=1 + + # Wait for services to start + sleep 10 + + # Check API + log "Checking API health..." + while [ $attempt -le $max_attempts ]; do + if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + success "API is healthy" + break + fi + + if [ $attempt -eq $max_attempts ]; then + error "API health check failed after $max_attempts attempts" + show_logs + exit 1 + fi + + log "Attempt $attempt/$max_attempts: API not ready yet..." + sleep 5 + ((attempt++)) + done + + # Check Gitea + log "Checking Gitea health..." + attempt=1 + while [ $attempt -le $max_attempts ]; do + if curl -sf http://localhost:3000/api/healthz > /dev/null 2>&1; then + success "Gitea is healthy" + break + fi + + if [ $attempt -eq $max_attempts ]; then + warning "Gitea health check failed (may still be initializing)" + break + fi + + log "Attempt $attempt/$max_attempts: Gitea not ready yet..." + sleep 5 + ((attempt++)) + done + + success "All health checks passed" +} + +# ========================================== +# Cleanup +# ========================================== + +cleanup() { + log "Cleaning up..." + + # Remove old backups (keep last 7) + if [ -d "$BACKUP_DIR" ]; then + ls -t "$BACKUP_DIR"/*.sql 2>/dev/null | tail -n +8 | xargs -r rm + ls -t "$BACKUP_DIR"/*.tar.gz 2>/dev/null | tail -n +8 | xargs -r rm + fi + + # Clean Docker + docker system prune -f + docker volume prune -f + + success "Cleanup completed" +} + +# ========================================== +# Rollback +# ========================================== + +rollback() { + error "Deployment failed! Rolling back..." + + # Get previous image + local previous_image=$(docker images ghcr.io/*/nanaxe-api --format "{{.Repository}}:{{.Tag}}" | grep -v latest | head -1) + + if [ -n "$previous_image" ]; then + log "Reverting to: $previous_image" + docker compose -f "$COMPOSE_FILE" stop api + docker compose -f "$COMPOSE_FILE" rm -f api + docker run -d --name nanaxe-api-rollback \ + --network nanaxe-network \ + -v mindmap-data:/app/mindmaps \ + -e NODE_ENV=production \ + -e PORT=8080 \ + -p 8080:8080 \ + "$previous_image" + success "Rollback completed" + else + error "No previous image found for rollback" + fi +} + +# ========================================== +# Show Logs +# ========================================== + +show_logs() { + log "Showing recent logs..." + docker compose -f "$COMPOSE_FILE" logs --tail=50 +} + +# ========================================== +# Main +# ========================================== + +main() { + log "======================================" + log "Nanaxe Server Deployment" + log "======================================" + + # Create logs directory + mkdir -p logs + + # Run deployment steps + check_prerequisites + create_backup + + # Deploy with error handling + if deploy; then + if health_check; then + cleanup + success "======================================" + success "Deployment completed successfully!" + success "======================================" + log "API: http://localhost:8080" + log "Gitea: http://localhost:3000" + else + rollback + exit 1 + fi + else + rollback + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/server/scripts/setup-vps.sh b/server/scripts/setup-vps.sh new file mode 100644 index 0000000..42b7a20 --- /dev/null +++ b/server/scripts/setup-vps.sh @@ -0,0 +1,281 @@ +#!/bin/bash + +# ========================================== +# VPS Setup Script for Nanaxe Server +# Run this on your fresh VPS +# ========================================== + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${BLUE}[SETUP]${NC} $1"; } +success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +error() { echo -e "${RED}[ERROR]${NC} $1"; } + +# ========================================== +# Configuration +# ========================================== +DEPLOY_DIR="/opt/nanaxe-server" +REPO_URL="" # Will be set interactively +DOMAIN="" # Will be set interactively + +# ========================================== +# System Update +# ========================================== +update_system() { + log "Updating system packages..." + apt update && apt upgrade -y + apt install -y curl wget git nano htop ufw fail2ban + success "System updated" +} + +# ========================================== +# Install Docker +# ========================================== +install_docker() { + log "Installing Docker..." + + if command -v docker &> /dev/null; then + warning "Docker already installed, skipping" + return + fi + + curl -fsSL https://get.docker.com -o get-docker.sh + sh get-docker.sh + rm get-docker.sh + + usermod -aG docker $USER + + success "Docker installed" +} + +# ========================================== +# Setup Firewall +# ========================================== +setup_firewall() { + log "Configuring firewall..." + + ufw default deny incoming + ufw default allow outgoing + + ufw allow ssh + ufw allow 80/tcp + ufw allow 443/tcp + ufw allow 3000/tcp # Gitea + ufw allow 8080/tcp # API + ufw allow 222/tcp # Gitea SSH + + ufw --force enable + + success "Firewall configured" +} + +# ========================================== +# Setup Fail2Ban +# ========================================== +setup_fail2ban() { + log "Configuring Fail2Ban..." + + cat > /etc/fail2ban/jail.local << 'EOF' +[DEFAULT] +bantime = 3600 +findtime = 600 +maxretry = 3 + +[sshd] +enabled = true +port = ssh +filter = sshd +logpath = /var/log/auth.log +maxretry = 3 +EOF + + systemctl restart fail2ban + success "Fail2Ban configured" +} + +# ========================================== +# Setup Deployment Directory +# ========================================== +setup_deploy_dir() { + log "Setting up deployment directory..." + + mkdir -p "$DEPLOY_DIR" + cd "$DEPLOY_DIR" + + # Clone repository + if [ -n "$REPO_URL" ]; then + git clone "$REPO_URL" . + fi + + # Create necessary directories + mkdir -p backups logs + + success "Deployment directory ready" +} + +# ========================================== +# Setup SSL with Let's Encrypt +# ========================================== +setup_ssl() { + log "Setting up SSL certificates..." + + if [ -z "$DOMAIN" ]; then + warning "No domain specified, skipping SSL setup" + return + fi + + apt install -y certbot + + # Obtain certificates + certbot certonly --standalone -d "$DOMAIN" --agree-tos --non-interactive --email admin@$DOMAIN || { + warning "SSL setup failed, continuing without SSL" + return + } + + # Auto-renewal cron job + (crontab -l 2>/dev/null; echo "0 12 * * * /usr/bin/certbot renew --quiet") | crontab - + + success "SSL certificates configured" +} + +# ========================================== +# Create Environment File +# ========================================== +setup_env() { + log "Creating environment configuration..." + + cd "$DEPLOY_DIR/server" + + # Generate secrets + JWT_SECRET=$(openssl rand -base64 32) + POSTGRES_PASSWORD=$(openssl rand -base64 24) + GITEA_SECRET_KEY=$(openssl rand -base64 32) + WEBDAV_PASSWORD=$(openssl rand -base64 16) + + cat > .env << EOF +# Domain configuration +GITEA_DOMAIN=${DOMAIN:-localhost} + +# Gitea Configuration +GITEA_TOKEN= +GITEA_SECRET_KEY=$GITEA_SECRET_KEY + +# Security +JWT_SECRET=$JWT_SECRET + +# WebDAV +WEBDAV_USER=admin +WEBDAV_PASSWORD=$WEBDAV_PASSWORD + +# Database +POSTGRES_PASSWORD=$POSTGRES_PASSWORD + +# API Configuration +API_PORT=8080 +EOF + + success "Environment file created" + warning "Please edit .env file and add your GITEA_TOKEN after first Gitea setup" +} + +# ========================================== +# Create Systemd Service +# ========================================== +setup_systemd() { + log "Creating systemd service..." + + cat > /etc/systemd/system/nanaxe-server.service << EOF +[Unit] +Description=Nanaxe Server +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=$DEPLOY_DIR/server +ExecStart=/usr/bin/docker compose -f docker-compose.prod.yml up -d +ExecStop=/usr/bin/docker compose -f docker-compose.prod.yml down +TimeoutStartSec=0 + +[Install] +WantedBy=multi-user.target +EOF + + systemctl daemon-reload + systemctl enable nanaxe-server.service + + success "Systemd service created" +} + +# ========================================== +# Interactive Setup +# ========================================== +interactive_setup() { + echo "" + echo "======================================" + echo "Nanaxe Server VPS Setup" + echo "======================================" + echo "" + + read -p "Enter your GitHub repository URL (or leave empty to skip clone): " REPO_URL + read -p "Enter your domain name (or leave empty for local setup): " DOMAIN + + echo "" + log "Starting setup with:" + log " Repository: ${REPO_URL:-None}" + log " Domain: ${DOMAIN:-localhost}" + echo "" + read -p "Continue? (y/n): " confirm + + if [ "$confirm" != "y" ]; then + error "Setup cancelled" + exit 1 + fi +} + +# ========================================== +# Main +# ========================================== +main() { + interactive_setup + + log "Starting VPS setup..." + + update_system + install_docker + setup_firewall + setup_fail2ban + setup_deploy_dir + setup_ssl + setup_env + setup_systemd + + echo "" + success "======================================" + success "VPS Setup Complete!" + success "======================================" + echo "" + log "Next steps:" + echo " 1. Edit $DEPLOY_DIR/server/.env with your settings" + echo " 2. Run: cd $DEPLOY_DIR/server && docker compose -f docker-compose.prod.yml up -d" + echo " 3. Open Gitea at http://${DOMAIN:-your-server-ip}:3000" + echo " 4. Create admin account and API token" + echo " 5. Add GITEA_TOKEN to .env file" + echo " 6. Restart: systemctl restart nanaxe-server" + echo "" + log "Useful commands:" + echo " - View logs: docker compose -f docker-compose.prod.yml logs -f" + echo " - Restart: systemctl restart nanaxe-server" + echo " - Update: cd $DEPLOY_DIR && git pull && systemctl restart nanaxe-server" + echo "" +} + +main "$@" diff --git a/test_deepseek_api.dart b/test_deepseek_api.dart new file mode 100644 index 0000000..cf2fd58 --- /dev/null +++ b/test_deepseek_api.dart @@ -0,0 +1,349 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// Простой тест DeepSeek API без Flutter зависимостей +void main() async { + const apiKey = 'sk-6600b6bee68745aa88b076b0c3393638'; + const baseUrl = 'https://api.deepseek.com'; + + print('🧪 Тестирование DeepSeek API...\n'); + print('=' * 60); + + // Тест 1: Проверка API ключа (простой запрос) + print('\n📝 Тест 1: Проверка API ключа'); + print('-' * 60); + await testApiKey(apiKey, baseUrl); + + // Тест 2: Генерация подзадач + print('\n📋 Тест 2: Генерация подзадач'); + print('-' * 60); + await testGenerateSubtasks(apiKey, baseUrl); + + // Тест 3: Приоритизация задачи + print('\n🎯 Тест 3: Приоритизация задачи'); + print('-' * 60); + await testPrioritizeTask(apiKey, baseUrl); + + // Тест 4: Генерация описания + print('\n✍️ Тест 4: Генерация описания задачи'); + print('-' * 60); + await testGenerateDescription(apiKey, baseUrl); + + // Тест 5: Генерация mind-карты + print('️ Тест 5: Генерация mind-карты'); + print('-' * 60); + await testGenerateMindMap(apiKey, baseUrl); + + // Тест 6: Умное планирование + print('\n📅 Тест 6: Умное планирование'); + print('-' * 60); + await testSmartSchedule(apiKey, baseUrl); + + print('\n${'=' * 60}'); + print('✅ Тестирование завершено!'); + print('=' * 60); +} + +// Тест 1: Проверка API ключа +Future testApiKey(String apiKey, String baseUrl) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + { + 'role': 'user', + 'content': 'Привет! Скажи "API работает отлично!"', + }, + ], + 'max_tokens': 50, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final message = data['choices'][0]['message']['content']; + print('✅ API ключ валиден!'); + print('💬 Ответ: $message'); + print('📊 Использовано токенов: ${data['usage']['total_tokens']}'); + } else { + print('❌ Ошибка: ${response.statusCode}'); + print('📄 Ответ: ${response.body}'); + } + } catch (e) { + print('❌ Исключение: $e'); + } +} + +// Тест 2: Генерация подзадач +Future testGenerateSubtasks(String apiKey, String baseUrl) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + { + 'role': 'system', + 'content': 'You are a task planning assistant. Break down tasks into logical subtasks. Return ONLY a JSON array.', + }, + { + 'role': 'user', + 'content': 'Break down this task into 3-7 subtasks:\n\nTitle: Создать CRM систему\nDescription: Разработка CRM для управления задачами команды с AI планированием\n\nReturn ONLY a JSON array with format: [{"title": "...", "estimatedHours": 0, "description": "..."}]', + }, + ], + 'temperature': 0.7, + 'max_tokens': 2000, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final content = data['choices'][0]['message']['content']; + + print('✅ Подзадачи сгенерированы!'); + print('📊 Использовано токенов: ${data['usage']['total_tokens']}'); + print('\n📝 Результат:'); + + // Пытаемся распарсить JSON + final jsonMatch = RegExp(r'\[.*\]', dotAll: true).firstMatch(content); + if (jsonMatch != null) { + final List subtasks = jsonDecode(jsonMatch.group(0)!); + for (int i = 0; i < subtasks.length; i++) { + final task = subtasks[i]; + print('\n${i + 1}. ${task['title']}'); + print(' ⏱️ Часы: ${task['estimatedHours']}'); + print(' 📄 ${task['description']}'); + } + } else { + print(content); + } + } else { + print('❌ Ошибка: ${response.statusCode}'); + print('📄 Ответ: ${response.body}'); + } + } catch (e) { + print('❌ Исключение: $e'); + } +} + +// Тест 3: Приоритизация задачи +Future testPrioritizeTask(String apiKey, String baseUrl) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + { + 'role': 'system', + 'content': 'You are a task prioritization assistant. Determine task priority: low, medium, high, or urgent. Reply with ONE word only.', + }, + { + 'role': 'user', + 'content': 'What priority should this task have?\n\nTitle: Срочный баг - пользователи не могут войти\nDescription: Критическая ошибка авторизации, все пользователи заблокированы\nDue: 2026-04-09T12:00:00\n\nReply with ONE word: low, medium, high, or urgent', + }, + ], + 'temperature': 0.3, + 'max_tokens': 10, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final priority = data['choices'][0]['message']['content'].trim().toLowerCase(); + + print('✅ Приоритизация выполнена!'); + print('📊 Использовано токенов: ${data['usage']['total_tokens']}'); + print('🎯 Приоритет: ${priority.toUpperCase()}'); + } else { + print('❌ Ошибка: ${response.statusCode}'); + print('📄 Ответ: ${response.body}'); + } + } catch (e) { + print('❌ Исключение: $e'); + } +} + +// Тест 4: Генерация описания +Future testGenerateDescription(String apiKey, String baseUrl) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + { + 'role': 'system', + 'content': 'You are a task description writer. Write clear, concise task descriptions in Russian. Keep it under 100 words.', + }, + { + 'role': 'user', + 'content': 'Write a brief description for this task:\n\nTitle: Интеграция с Google Calendar\nContext: Синхронизация событий между CRM и Google Calendar\n\nWrite in Russian.', + }, + ], + 'temperature': 0.5, + 'max_tokens': 500, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final description = data['choices'][0]['message']['content']; + + print('✅ Описание сгенерировано!'); + print('📊 Использовано токенов: ${data['usage']['total_tokens']}'); + print('\n📝 Описание:\n$description'); + } else { + print('❌ Ошибка: ${response.statusCode}'); + print('📄 Ответ: ${response.body}'); + } + } catch (e) { + print('❌ Исключение: $e'); + } +} + +// Тест 5: Генерация mind-карты +Future testGenerateMindMap(String apiKey, String baseUrl) async { + try { + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + { + 'role': 'system', + 'content': 'You are a project structure assistant. Create a mind-map structure for projects. Return ONLY a JSON array of nodes.', + }, + { + 'role': 'user', + 'content': '''Create a mind-map structure for this project: + +Project: CRM App +Description: Мобильное CRM приложение с AI планированием, mind-картами и интеграцией с календарем + +Return JSON array of nodes with format: +[{"level": 0, "title": "Project Name", "parentId": null}, {"level": 1, "title": "Phase 1", "parentId": 0}, ...]''', + }, + ], + 'temperature': 0.7, + 'max_tokens': 3000, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final content = data['choices'][0]['message']['content']; + + print('✅ Mind-карта сгенерирована!'); + print('📊 Использовано токенов: ${data['usage']['total_tokens']}'); + + // Пытаемся распарсить JSON + final jsonMatch = RegExp(r'\[.*\]', dotAll: true).firstMatch(content); + if (jsonMatch != null) { + final List nodes = jsonDecode(jsonMatch.group(0)!); + print('\n🗺️ Структура mind-карты:'); + for (final node in nodes) { + final indent = ' ' * (node['level'] as int); + print('$indent${node['level'] == 0 ? '📌' : '└'} ${node['title']}'); + } + } else { + print('\n📝 Результат:\n$content'); + } + } else { + print('❌ Ошибка: ${response.statusCode}'); + print('📄 Ответ: ${response.body}'); + } + } catch (e) { + print('❌ Исключение: $e'); + } +} + +// Тест 6: Умное планирование +Future testSmartSchedule(String apiKey, String baseUrl) async { + try { + final tasks = [ + {'title': 'Анализ требований', 'estimatedHours': 4, 'priority': 'high'}, + {'title': 'Проектирование', 'estimatedHours': 6, 'priority': 'high'}, + {'title': 'Frontend разработка', 'estimatedHours': 12, 'priority': 'medium'}, + {'title': 'Backend разработка', 'estimatedHours': 10, 'priority': 'medium'}, + {'title': 'Тестирование', 'estimatedHours': 5, 'priority': 'high'}, + ]; + + final response = await http.post( + Uri.parse('$baseUrl/v1/chat/completions'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $apiKey', + }, + body: jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + { + 'role': 'system', + 'content': 'You are a smart scheduling assistant. Distribute tasks across available days optimally. Return ONLY a JSON array.', + }, + { + 'role': 'user', + 'content': '''Schedule these tasks optimally: +${jsonEncode(tasks)} + +Available hours per day: 6 +Start date: 2026-04-09 +End date: 2026-04-16 + +Return JSON array: [{"taskId": index, "taskName": "name", "date": "YYYY-MM-DD", "hours": 0}]''', + }, + ], + 'temperature': 0.5, + 'max_tokens': 3000, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + final content = data['choices'][0]['message']['content']; + + print('✅ Планирование выполнено!'); + print('📊 Использовано токенов: ${data['usage']['total_tokens']}'); + + // Пытаемся распарсить JSON + final jsonMatch = RegExp(r'\[.*\]', dotAll: true).firstMatch(content); + if (jsonMatch != null) { + final List schedule = jsonDecode(jsonMatch.group(0)!); + print('\n📅 Расписание:'); + for (final item in schedule) { + print(' 📆 ${item['date']}: ${item['taskName'] ?? item['taskId']} (${item['hours']}ч)'); + } + } else { + print('\n📝 Результат:\n$content'); + } + } else { + print('❌ Ошибка: ${response.statusCode}'); + print('📄 Ответ: ${response.body}'); + } + } catch (e) { + print('❌ Исключение: $e'); + } +} diff --git a/test_mock_mode.dart b/test_mock_mode.dart new file mode 100644 index 0000000..abdde87 --- /dev/null +++ b/test_mock_mode.dart @@ -0,0 +1,242 @@ +// Тестирование DeepSeek сервиса в mock режиме +// Запуск: dart run test_deepseek_mock.dart + + +// Минимальные модели для теста +enum TaskPriority { low, medium, high, urgent } + +class TaskModel { + final String id; + final String title; + final int estimatedHours; + final TaskPriority priority; + final DateTime? dueDate; + + TaskModel({ + required this.id, + required this.title, + required this.estimatedHours, + required this.priority, + this.dueDate, + }); +} + +void main() async { + print('🧪 Тестирование DeepSeek сервиса (MOCK режим)\n'); + print('=' * 60); + + // Импортируем сервис после определения моделей + await runTests(); +} + +Future runTests() async { + // Тест 1: Генерация подзадач + print('\n📋 Тест 1: Генерация подзадач'); + print('-' * 60); + final subtasks = mockGenerateSubtasks( + title: 'Создать CRM систему', + description: 'Разработка CRM для управления задачами команды', + ); + print('✅ Сгенерировано подзадач: ${subtasks.length}'); + for (int i = 0; i < subtasks.length; i++) { + final task = subtasks[i]; + print('\n${i + 1}. ${task['title']}'); + print(' ⏱️ Часы: ${task['estimatedHours']}'); + print(' 📄 ${task['description']}'); + } + + // Тест 2: Приоритизация + print('\n\n🎯 Тест 2: Приоритизация задачи'); + print('-' * 60); + final urgentPriority = mockPrioritizeTask( + dueDate: DateTime.now().add(Duration(hours: 12)), + ); + print('Срочная задача (12 часов): ${urgentPriority.name.toUpperCase()}'); + + final mediumPriority = mockPrioritizeTask( + dueDate: DateTime.now().add(Duration(days: 5)), + ); + print('Средняя задача (5 дней): ${mediumPriority.name.toUpperCase()}'); + + final lowPriority = mockPrioritizeTask( + dueDate: DateTime.now().add(Duration(days: 14)), + ); + print('Низкий приоритет (14 дней): ${lowPriority.name.toUpperCase()}'); + + // Тест 3: Генерация описания + print('\n\n✍️ Тест 3: Генерация описания'); + print('-' * 60); + final description = mockGenerateDescription( + 'Интеграция с Google Calendar', + 'Синхронизация событий между CRM и календарем', + ); + print('📝 Описание:\n$description'); + + // Тест 4: Умное планирование + print('\n\n📅 Тест 4: Умное планирование'); + print('-' * 60); + final tasks = [ + TaskModel( + id: '1', + title: 'Анализ', + estimatedHours: 4, + priority: TaskPriority.high), + TaskModel( + id: '2', + title: 'Проектирование', + estimatedHours: 6, + priority: TaskPriority.high), + TaskModel( + id: '3', + title: 'Разработка', + estimatedHours: 12, + priority: TaskPriority.medium), + ]; + final schedule = mockSchedule( + tasks: tasks, + startDate: DateTime.now(), + endDate: DateTime.now().add(Duration(days: 7)), + availableHoursPerDay: 6, + ); + print('📊 Запланировано задач: ${schedule.length}'); + for (final item in schedule) { + final task = tasks[item['taskId'] as int]; + print( + ' 📆 ${item['date'].toString().split(' ')[0]}: ${task.title} (${item['hours']}ч)'); + } + + // Тест 5: Mind-карта + print('\n\n🗺️ Тест 5: Генерация mind-карты'); + print('-' * 60); + final mindMap = mockMindMap('CRM App'); + print('📊 Узлов в mind-карте: ${mindMap.length}'); + for (final node in mindMap) { + final indent = ' ' * (node['level'] as int); + final icon = node['level'] == 0 ? '📌' : '└'; + print('$indent$icon ${node['title']}'); + } + + print('\n${'=' * 60}'); + print('✅ Все тесты пройдены! Mock режим работает отлично!'); + print('=' * 60); + print('\n💡 Чтобы включить реальный API:'); + print(' 1. Пополните баланс на https://platform.deepseek.com/billing'); + print( + ' 2. В lib/core/deepseek_service.dart установите useMockMode = false'); + print('=' * 60); +} + +// Mock функции (копия из deepseek_service.dart) +List> mockGenerateSubtasks({ + required String title, + required String description, +}) { + return [ + { + 'title': 'Анализ требований', + 'estimatedHours': 2, + 'description': 'Изучить требования и документацию' + }, + { + 'title': 'Проектирование', + 'estimatedHours': 3, + 'description': 'Спроектировать архитектуру решения' + }, + {'title': 'Реализация', 'estimatedHours': 8, 'description': 'Написать код'}, + { + 'title': 'Тестирование', + 'estimatedHours': 3, + 'description': 'Протестировать функциональность' + }, + { + 'title': 'Документирование', + 'estimatedHours': 2, + 'description': 'Создать документацию' + }, + ]; +} + +TaskPriority mockPrioritizeTask({DateTime? dueDate}) { + if (dueDate == null) return TaskPriority.medium; + + final daysUntilDue = dueDate.difference(DateTime.now()).inDays; + + if (daysUntilDue <= 1) return TaskPriority.urgent; + if (daysUntilDue <= 3) return TaskPriority.high; + if (daysUntilDue <= 7) return TaskPriority.medium; + return TaskPriority.low; +} + +String mockGenerateDescription(String title, String? context) { + final descriptions = { + 'Анализ требований': + 'Изучить и проанализировать все требования проекта. Подготовить документацию с детальным описанием функционала и ожиданий пользователей.', + 'Проектирование': + 'Разработать архитектуру решения, создать схемы базы данных и определить основные компоненты системы.', + 'Реализация': + 'Написать чистый, поддерживаемый код согласно лучшим практикам. Реализовать весь запланированный функционал.', + 'Тестирование': + 'Провести комплексное тестирование: модульные, интеграционные и end-to-end тесты для обеспечения качества.', + 'Деплой': + 'Подготовить инфраструктуру, настроить CI/CD пайплайн и развернуть приложение на production сервере.', + }; + + return descriptions[title] ?? + 'Задача: $title. ${context ?? "Выполнить в срок с надлежащим качеством."}'; +} + +List> mockSchedule({ + required List tasks, + required DateTime startDate, + required DateTime endDate, + required int availableHoursPerDay, +}) { + final schedule = >[]; + var currentDate = startDate; + var remainingHours = availableHoursPerDay; + + for (var i = 0; i < tasks.length; i++) { + if (currentDate.isAfter(endDate)) break; + + final task = tasks[i]; + if (task.estimatedHours <= remainingHours) { + schedule.add({ + 'taskId': i, + 'date': currentDate, + 'hours': task.estimatedHours, + }); + remainingHours -= task.estimatedHours; + } else { + currentDate = currentDate.add(const Duration(days: 1)); + remainingHours = availableHoursPerDay; + + if (currentDate.isAfter(endDate)) break; + + schedule.add({ + 'taskId': i, + 'date': currentDate, + 'hours': task.estimatedHours > remainingHours + ? remainingHours + : task.estimatedHours, + }); + } + } + + return schedule; +} + +List> mockMindMap(String projectName) { + return [ + {'level': 0, 'title': projectName, 'parentId': null}, + {'level': 1, 'title': 'Планирование', 'parentId': 0}, + {'level': 1, 'title': 'Разработка', 'parentId': 0}, + {'level': 1, 'title': 'Тестирование', 'parentId': 0}, + {'level': 1, 'title': 'Деплой', 'parentId': 0}, + {'level': 2, 'title': 'Анализ', 'parentId': 1}, + {'level': 2, 'title': 'Дизайн', 'parentId': 1}, + {'level': 2, 'title': 'Frontend', 'parentId': 2}, + {'level': 2, 'title': 'Backend', 'parentId': 2}, + {'level': 2, 'title': 'Unit тесты', 'parentId': 3}, + {'level': 2, 'title': 'E2E тесты', 'parentId': 3}, + ]; +}