feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
|
||||
|
||||
function loadConfig() {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return {
|
||||
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
|
||||
userId: process.env.XFLOW_USER_ID || 'default-user'
|
||||
};
|
||||
}
|
||||
|
||||
function saveConfig(config) {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
|
||||
}
|
||||
|
||||
async function addNote(title, listTitle = 'Фильмы на просмотр', category = 'movies') {
|
||||
const config = loadConfig();
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
|
||||
|
||||
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
listTitle,
|
||||
category,
|
||||
ownerId: config.userId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
|
||||
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
|
||||
} else {
|
||||
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
|
||||
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listNotes(category) {
|
||||
const config = loadConfig();
|
||||
const query = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
|
||||
for (const list of data.lists) {
|
||||
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
|
||||
for (const item of list.items || []) {
|
||||
const check = item.isCompleted ? '☑' : '☐';
|
||||
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
console.error(`Ошибка:`, data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Ошибка связи с сервером: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log(`
|
||||
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
|
||||
|
||||
Использование:
|
||||
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
|
||||
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
|
||||
xflow list [категория] Показать списки и элементы
|
||||
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
|
||||
|
||||
Примеры:
|
||||
xflow movie "Интерстеллар"
|
||||
xflow add "Купить молоко" --list "Покупки"
|
||||
xflow list
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'movie') {
|
||||
const title = args.slice(1).join(' ');
|
||||
if (!title) {
|
||||
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, 'Фильмы на просмотр', 'movies');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'add') {
|
||||
const title = args[1];
|
||||
let listTitle = 'Общий список';
|
||||
let category = 'general';
|
||||
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--list' && args[i + 1]) {
|
||||
listTitle = args[i + 1];
|
||||
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
|
||||
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
console.error('Укажите текст заметки: xflow add "Заметка"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, listTitle, category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'list') {
|
||||
const category = args[1];
|
||||
await listNotes(category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'config') {
|
||||
const config = loadConfig();
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
|
||||
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
|
||||
}
|
||||
saveConfig(config);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Неизвестная команда: ${command}. Используйте --help`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@xflow/desktop-cli",
|
||||
"version": "1.0.0",
|
||||
"description": "XFlow Windows/Linux Native CLI and Agent Bridge",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"xflow": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const CONFIG_FILE = path.join(os.homedir(), '.xflow.json');
|
||||
|
||||
interface XFlowConfig {
|
||||
serverUrl: string;
|
||||
userId: string;
|
||||
apiKey?: string;
|
||||
}
|
||||
|
||||
function loadConfig(): XFlowConfig {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
return {
|
||||
serverUrl: process.env.XFLOW_SERVER_URL || 'http://localhost:3000',
|
||||
userId: process.env.XFLOW_USER_ID || 'default-user'
|
||||
};
|
||||
}
|
||||
|
||||
function saveConfig(config: XFlowConfig) {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
||||
console.log(`[XFlow] Настройки сохранены в ${CONFIG_FILE}`);
|
||||
}
|
||||
|
||||
async function addNote(title: string, listTitle: string = 'Фильмы', category: string = 'movies') {
|
||||
const config = loadConfig();
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists/items`;
|
||||
|
||||
console.log(`[XFlow Desktop] Отправка заметки: "${title}" в список "${listTitle}"...`);
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
listTitle,
|
||||
category,
|
||||
ownerId: config.userId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json() as any;
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\x1b[32m✔ Успешно добавлено!\x1b[0m ID: ${data.item.id}, Статус: ${data.item.status}`);
|
||||
console.log(` Синхронизировано с сервером и отправлено на мобильный клиент.`);
|
||||
} else {
|
||||
console.error(`\x1b[31m✖ Ошибка сервера:\x1b[0m`, data.message || data);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\x1b[31m✖ Не удалось связаться с сервером XFlow (${config.serverUrl}):\x1b[0m ${err.message}`);
|
||||
console.log(` Убедитесь, что сервер XFlow запущен на ${config.serverUrl}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function listNotes(category?: string) {
|
||||
const config = loadConfig();
|
||||
const query = category ? `?category=${encodeURIComponent(category)}` : '';
|
||||
const url = `${config.serverUrl.replace(/\/$/, '')}/api/v1/lists${query}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json() as any;
|
||||
if (res.ok && data.success) {
|
||||
console.log(`\n\x1b[1m=== Списки XFlow ===\x1b[0m`);
|
||||
for (const list of data.lists) {
|
||||
console.log(`\n📂 \x1b[36m${list.title}\x1b[0m (${list.category}) [${list.items?.length || 0} элементов]:`);
|
||||
for (const item of list.items || []) {
|
||||
const check = item.isCompleted ? '☑' : '☐';
|
||||
console.log(` ${check} ${item.title} \x1b[90m(статус: ${item.status})\x1b[0m`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
} else {
|
||||
console.error(`Ошибка:`, data.message);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Ошибка связи с сервером: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (!command || command === '--help' || command === '-h') {
|
||||
console.log(`
|
||||
\x1b[1m🚀 XFlow CLI — Native Desktop & Terminal Agent Bridge\x1b[0m
|
||||
|
||||
Использование:
|
||||
xflow movie "<название фильма>" Быстро добавить фильм в список на просмотр
|
||||
xflow add "<заметка>" [--list <список>] Добавить элемент в указанный список
|
||||
xflow list [категория] Показать списки и элементы
|
||||
xflow config --url <url> --user <id> Настроить подключение к серверу XFlow
|
||||
|
||||
Примеры:
|
||||
xflow movie "Интерстеллар"
|
||||
xflow add "Купить молоко" --list "Покупки"
|
||||
xflow list
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'movie') {
|
||||
const title = args.slice(1).join(' ');
|
||||
if (!title) {
|
||||
console.error('Укажите название фильма: xflow movie "Интерстеллар"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, 'Фильмы на просмотр', 'movies');
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'add') {
|
||||
const title = args[1];
|
||||
let listTitle = 'Общий список';
|
||||
let category = 'general';
|
||||
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
if (args[i] === '--list' && args[i + 1]) {
|
||||
listTitle = args[i + 1];
|
||||
if (listTitle.toLowerCase().includes('фильм')) category = 'movies';
|
||||
if (listTitle.toLowerCase().includes('покупк')) category = 'shopping';
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
console.error('Укажите текст заметки: xflow add "Заметка"');
|
||||
return;
|
||||
}
|
||||
await addNote(title, listTitle, category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'list') {
|
||||
const category = args[1];
|
||||
await listNotes(category);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'config') {
|
||||
const config = loadConfig();
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i] === '--url' && args[i + 1]) config.serverUrl = args[i + 1];
|
||||
if (args[i] === '--user' && args[i + 1]) config.userId = args[i + 1];
|
||||
}
|
||||
saveConfig(config);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(`Неизвестная команда: ${command}. Используйте --help`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,13 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
local.properties
|
||||
/.idea/
|
||||
.idea/
|
||||
.DS_Store
|
||||
/build
|
||||
build/
|
||||
app/build/
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
@@ -0,0 +1,91 @@
|
||||
# Avtex — Smart Sleep & Motion Engine for Home Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="app/src/main/res/drawable/app_logo.png" width="160" alt="Avtex Logo" style="border-radius: 28px;" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<b>Легковесное Android-приложение для мгновенной фиксации сна и пробуждения с интеграцией в Home Assistant (HAC)</b><br>
|
||||
<i>Оптимизировано для OnePlus (OxygenOS / ColorOS) и устройств с ненормированным рабочим графиком.</i>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Ключевые возможности
|
||||
|
||||
* **Мгновенная фиксация пробуждения (0% расхода батареи)**:
|
||||
Использует аппаратный сопроцессор `Sensor.TYPE_SIGNIFICANT_MOTION`. Как только вы берете телефон в руки после сна, сигнал пробуждения моментально передается в умный дом.
|
||||
* **Детекция сна при ненормированном графике**:
|
||||
Связка таймера покоя (по умолчанию 20 мин при выключенном экране) и нейросетевых классификаций **Google Play Services Sleep API** (`SleepClassifyEvent`). Никаких жестких привязок к часам (23:00–07:00).
|
||||
* **Полная совместимость с Home Assistant (HAC)**:
|
||||
* **Webhook URL**: отправка JSON-событий `{"event": "sleep_started" | "sleep_ended", "state": "asleep" | "awake", ...}`.
|
||||
* **REST State API**: прямое обновление сенсора `binary_sensor.phone_sleep` через Long-Lived Access Token.
|
||||
* **Local Broadcast Intent**: `com.example.avtex.SLEEP_STATE_CHANGED` для Tasker / Home Assistant Companion App прямо на телефоне.
|
||||
* **Устойчивость к убийцам процессов OnePlus**:
|
||||
* Реализован как Foreground Service с типом `specialUse`.
|
||||
* Интерактивный экран со вспомогательными кнопками перехода в системные настройки OxygenOS (отключение оптимизации батареи, включение автозапуска).
|
||||
* **Премиальный дизайн в стиле Cyber Obsidian / Glassmorphism**:
|
||||
* Jetpack Compose & Material 3.
|
||||
* Тонкие неоновые контуры, стеклянные карточки, динамический пульсирующий баннер состояния.
|
||||
* Интерактивная лента аудита событий сенсоров в реальном времени.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### 1. Сборка APK
|
||||
|
||||
Для сборки отладочной версии:
|
||||
```bash
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
Для сборки оптимизированного подписанного Release APK:
|
||||
```bash
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
Файл будет создан в `app/build/outputs/apk/release/app-release.apk`.
|
||||
|
||||
### 2. Установка через ADB
|
||||
```bash
|
||||
adb install -r app/build/outputs/apk/release/app-release.apk
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Настройка Home Assistant (Пример Webhook автоматизации)
|
||||
|
||||
В файле `automations.yaml` или в визуальном редакторе Home Assistant:
|
||||
|
||||
```yaml
|
||||
alias: "Avtex: Автоматизация сна и пробуждения"
|
||||
trigger:
|
||||
- platform: webhook
|
||||
webhook_id: avtex_sleep
|
||||
action:
|
||||
- choose:
|
||||
- conditions: "{{ trigger.json.state == 'asleep' }}"
|
||||
sequence:
|
||||
- service: light.turn_off
|
||||
target:
|
||||
entity_id: all
|
||||
- service: climate.set_preset_mode
|
||||
data:
|
||||
preset_mode: sleep
|
||||
target:
|
||||
entity_id: climate.bedroom
|
||||
- conditions: "{{ trigger.json.state == 'awake' }}"
|
||||
sequence:
|
||||
- service: scene.turn_on
|
||||
target:
|
||||
entity_id: scene.morning_lights
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Стек технологий
|
||||
* **Язык**: Kotlin 2.3+
|
||||
* **UI**: Jetpack Compose, Material 3, Navigation
|
||||
* **Сенсоры**: Android Sensor Hub (`TYPE_SIGNIFICANT_MOTION`), Google Play Services Location / Sleep API
|
||||
* **Сеть**: OkHttp 4.12
|
||||
* **Хранилище настроек**: Jetpack DataStore Preferences
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,106 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.avtex"
|
||||
compileSdk = 36
|
||||
defaultConfig {
|
||||
applicationId = "com.example.avtex"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "1.0"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("release.keystore")
|
||||
storePassword = "avtex_release_key"
|
||||
keyAlias = "avtex"
|
||||
keyPassword = "avtex_release_key"
|
||||
enableV1Signing = true
|
||||
enableV2Signing = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
aidl = false
|
||||
buildConfig = true
|
||||
shaders = false
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val composeBom = platform(libs.androidx.compose.bom)
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
|
||||
// Core Android dependencies
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
|
||||
// Arch Components
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||
|
||||
// Compose
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.material.icons.extended)
|
||||
// Tooling
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
// Instrumented tests
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
||||
// Local tests: jUnit, coroutines, Android runner
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
// Instrumented tests: jUnit rules and runners
|
||||
androidTestImplementation(libs.androidx.test.core)
|
||||
androidTestImplementation(libs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.test.runner)
|
||||
androidTestImplementation(libs.androidx.test.espresso.core)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
|
||||
// Sleep API & Location
|
||||
implementation(libs.play.services.location)
|
||||
|
||||
// Home Assistant HTTP client
|
||||
implementation(libs.okhttp)
|
||||
|
||||
// DataStore for settings
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Proguard rules for Avtex
|
||||
-keep class com.google.android.gms.location.** { *; }
|
||||
-dontwarn com.google.android.gms.location.**
|
||||
|
||||
-keep class okhttp3.** { *; }
|
||||
-dontwarn okhttp3.**
|
||||
|
||||
-keep class com.example.avtex.data.** { *; }
|
||||
-keep class com.example.avtex.hac.** { *; }
|
||||
-keep class com.example.avtex.engine.** { *; }
|
||||
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".AvtexApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.Avtex">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="xflow"
|
||||
android:host="lists" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
|
||||
<service
|
||||
android:name=".engine.SleepTrackingService"
|
||||
android:enabled="true"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Detects sleep and wake states using motion sensors and Sleep API for Home Assistant smart home automations." />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".engine.SleepReceiver"
|
||||
android:exported="true"
|
||||
android:permission="com.google.android.gms.permission.ACTIVITY_RECOGNITION">
|
||||
<intent-filter>
|
||||
<action android:name="com.example.avtex.ACTION_SLEEP_API_EVENT" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".engine.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.example.avtex
|
||||
|
||||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.example.avtex.data.SettingsRepository
|
||||
import com.example.avtex.notes.NotesRepository
|
||||
import com.example.avtex.notes.XFlowSyncClient
|
||||
|
||||
class AvtexApplication : Application() {
|
||||
|
||||
lateinit var settingsRepository: SettingsRepository
|
||||
private set
|
||||
|
||||
lateinit var notesRepository: NotesRepository
|
||||
private set
|
||||
|
||||
lateinit var xflowSyncClient: XFlowSyncClient
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
instance = this
|
||||
settingsRepository = SettingsRepository(this)
|
||||
notesRepository = NotesRepository(this)
|
||||
xflowSyncClient = XFlowSyncClient(this)
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
private fun createNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID_TRACKING,
|
||||
"Мониторинг XFlow",
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
).apply {
|
||||
description = "Отображает текущий статус мониторинга и синхронизации"
|
||||
setShowBadge(false)
|
||||
}
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL_ID_TRACKING = "xflow_tracking_channel"
|
||||
lateinit var instance: AvtexApplication
|
||||
private set
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.example.avtex
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.example.avtex.theme.AvtexTheme
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIncomingIntent(intent)
|
||||
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AvtexTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
MainNavigation()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
if (intent == null) return
|
||||
|
||||
// 1. Обработка App Actions через Deep Link xflow://lists/add
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == "xflow") {
|
||||
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
|
||||
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
|
||||
|
||||
if (!itemName.isNullOrBlank()) {
|
||||
saveItemFromAssistant(itemName, listName)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
|
||||
val extras = intent.extras
|
||||
if (extras != null) {
|
||||
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
|
||||
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
|
||||
|
||||
if (!itemName.isNullOrBlank()) {
|
||||
saveItemFromAssistant(itemName, listName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveItemFromAssistant(itemName: String, listName: String) {
|
||||
lifecycleScope.launch {
|
||||
val repo = AvtexApplication.instance.notesRepository
|
||||
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
|
||||
|
||||
repo.addNoteItem(
|
||||
title = itemName,
|
||||
listTitle = listName,
|
||||
category = category
|
||||
)
|
||||
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"✅ Записано в XFlow: $itemName ($listName)",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.example.avtex
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.ui.DashboardScreen
|
||||
import com.example.avtex.ui.ListsScreen
|
||||
import com.example.avtex.ui.OnePlusGuideScreen
|
||||
import com.example.avtex.ui.SettingsScreen
|
||||
|
||||
@Composable
|
||||
fun MainNavigation() {
|
||||
var selectedTab by rememberSaveable { mutableIntStateOf(0) }
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
containerColor = ObsidianBg,
|
||||
bottomBar = {
|
||||
NavigationBar(
|
||||
containerColor = ObsidianSurfaceElevated,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.border(1.dp, GlassBorder)
|
||||
) {
|
||||
val navColors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = Color.White,
|
||||
selectedTextColor = NeonViolet,
|
||||
indicatorColor = NeonViolet.copy(alpha = 0.35f),
|
||||
unselectedIconColor = TextMuted,
|
||||
unselectedTextColor = TextMuted
|
||||
)
|
||||
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 0,
|
||||
onClick = { selectedTab = 0 },
|
||||
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
|
||||
label = {
|
||||
Text(
|
||||
"Сон",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = navColors
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 1,
|
||||
onClick = { selectedTab = 1 },
|
||||
icon = { Icon(Icons.AutoMirrored.Filled.FormatListBulleted, contentDescription = "Списки") },
|
||||
label = {
|
||||
Text(
|
||||
"Списки",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 1) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = navColors
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 2,
|
||||
onClick = { selectedTab = 2 },
|
||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
|
||||
label = {
|
||||
Text(
|
||||
"Настройки",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = navColors
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = selectedTab == 3,
|
||||
onClick = { selectedTab = 3 },
|
||||
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
|
||||
label = {
|
||||
Text(
|
||||
"OnePlus",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
},
|
||||
colors = navColors
|
||||
)
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
when (selectedTab) {
|
||||
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
|
||||
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
|
||||
2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
|
||||
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.example.avtex.data
|
||||
|
||||
enum class HacMode {
|
||||
WEBHOOK,
|
||||
REST
|
||||
}
|
||||
|
||||
data class AppSettings(
|
||||
val isTrackingActive: Boolean = false,
|
||||
val currentState: String = "AWAKE",
|
||||
val lastStateChangeMillis: Long = System.currentTimeMillis(),
|
||||
val hacMode: HacMode = HacMode.WEBHOOK,
|
||||
val hacWebhookUrl: String = "",
|
||||
val hacRestUrl: String = "",
|
||||
val hacToken: String = "",
|
||||
val inactivityMinutes: Int = 20,
|
||||
val confidenceThreshold: Int = 70,
|
||||
val broadcastIntents: Boolean = true,
|
||||
val lastHacStatus: String = "Готов к работе"
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.avtex.data
|
||||
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
data class LogEntry(
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
val tag: String,
|
||||
val message: String,
|
||||
val isImportant: Boolean = false
|
||||
) {
|
||||
val formattedTime: String
|
||||
get() = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp))
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.example.avtex.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "avtex_settings")
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
||||
private object PreferencesKeys {
|
||||
val IS_TRACKING_ACTIVE = booleanPreferencesKey("is_tracking_active")
|
||||
val CURRENT_STATE = stringPreferencesKey("current_state")
|
||||
val LAST_STATE_CHANGE = longPreferencesKey("last_state_change")
|
||||
val HAC_MODE = stringPreferencesKey("hac_mode")
|
||||
val HAC_WEBHOOK_URL = stringPreferencesKey("hac_webhook_url")
|
||||
val HAC_REST_URL = stringPreferencesKey("hac_rest_url")
|
||||
val HAC_TOKEN = stringPreferencesKey("hac_token")
|
||||
val INACTIVITY_MINUTES = intPreferencesKey("inactivity_minutes")
|
||||
val CONFIDENCE_THRESHOLD = intPreferencesKey("confidence_threshold")
|
||||
val BROADCAST_INTENTS = booleanPreferencesKey("broadcast_intents")
|
||||
val LAST_HAC_STATUS = stringPreferencesKey("last_hac_status")
|
||||
}
|
||||
|
||||
private val _logsFlow = MutableStateFlow<List<LogEntry>>(emptyList())
|
||||
val logsFlow: StateFlow<List<LogEntry>> = _logsFlow.asStateFlow()
|
||||
|
||||
fun addLog(tag: String, message: String, isImportant: Boolean = false) {
|
||||
val newEntry = LogEntry(tag = tag, message = message, isImportant = isImportant)
|
||||
val current = _logsFlow.value.toMutableList()
|
||||
current.add(0, newEntry)
|
||||
if (current.size > 100) {
|
||||
current.removeAt(current.size - 1)
|
||||
}
|
||||
_logsFlow.value = current
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<AppSettings> = context.dataStore.data.map { prefs ->
|
||||
val modeStr = prefs[PreferencesKeys.HAC_MODE] ?: HacMode.WEBHOOK.name
|
||||
val mode = try {
|
||||
HacMode.valueOf(modeStr)
|
||||
} catch (_: Exception) {
|
||||
HacMode.WEBHOOK
|
||||
}
|
||||
|
||||
AppSettings(
|
||||
isTrackingActive = prefs[PreferencesKeys.IS_TRACKING_ACTIVE] ?: false,
|
||||
currentState = prefs[PreferencesKeys.CURRENT_STATE] ?: "AWAKE",
|
||||
lastStateChangeMillis = prefs[PreferencesKeys.LAST_STATE_CHANGE] ?: System.currentTimeMillis(),
|
||||
hacMode = mode,
|
||||
hacWebhookUrl = prefs[PreferencesKeys.HAC_WEBHOOK_URL] ?: "",
|
||||
hacRestUrl = prefs[PreferencesKeys.HAC_REST_URL] ?: "",
|
||||
hacToken = prefs[PreferencesKeys.HAC_TOKEN] ?: "",
|
||||
inactivityMinutes = prefs[PreferencesKeys.INACTIVITY_MINUTES] ?: 20,
|
||||
confidenceThreshold = prefs[PreferencesKeys.CONFIDENCE_THRESHOLD] ?: 70,
|
||||
broadcastIntents = prefs[PreferencesKeys.BROADCAST_INTENTS] ?: true,
|
||||
lastHacStatus = prefs[PreferencesKeys.LAST_HAC_STATUS] ?: "Готов к работе"
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getSettings(): AppSettings = settingsFlow.first()
|
||||
|
||||
suspend fun updateTrackingActive(active: Boolean) {
|
||||
context.dataStore.edit { it[PreferencesKeys.IS_TRACKING_ACTIVE] = active }
|
||||
}
|
||||
|
||||
suspend fun updateCurrentState(state: String, timestamp: Long = System.currentTimeMillis()) {
|
||||
context.dataStore.edit {
|
||||
it[PreferencesKeys.CURRENT_STATE] = state
|
||||
it[PreferencesKeys.LAST_STATE_CHANGE] = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateHacSettings(
|
||||
mode: HacMode,
|
||||
webhookUrl: String,
|
||||
restUrl: String,
|
||||
token: String
|
||||
) {
|
||||
context.dataStore.edit {
|
||||
it[PreferencesKeys.HAC_MODE] = mode.name
|
||||
it[PreferencesKeys.HAC_WEBHOOK_URL] = webhookUrl
|
||||
it[PreferencesKeys.HAC_REST_URL] = restUrl
|
||||
it[PreferencesKeys.HAC_TOKEN] = token
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateDetectionSensitivity(
|
||||
inactivityMinutes: Int,
|
||||
confidenceThreshold: Int,
|
||||
broadcastIntents: Boolean
|
||||
) {
|
||||
context.dataStore.edit {
|
||||
it[PreferencesKeys.INACTIVITY_MINUTES] = inactivityMinutes
|
||||
it[PreferencesKeys.CONFIDENCE_THRESHOLD] = confidenceThreshold
|
||||
it[PreferencesKeys.BROADCAST_INTENTS] = broadcastIntents
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateHacStatus(status: String) {
|
||||
context.dataStore.edit {
|
||||
it[PreferencesKeys.LAST_HAC_STATUS] = status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.example.avtex.engine
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.example.avtex.AvtexApplication
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
Log.d(TAG, "Boot completed event received")
|
||||
val repo = AvtexApplication.instance.settingsRepository
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val settings = repo.getSettings()
|
||||
if (settings.isTrackingActive) {
|
||||
Log.d(TAG, "Restarting SleepTrackingService after reboot")
|
||||
val serviceIntent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = SleepTrackingService.ACTION_START_TRACKING
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(serviceIntent)
|
||||
} else {
|
||||
context.startService(serviceIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BootReceiver"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.avtex.engine
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.hardware.TriggerEvent
|
||||
import android.hardware.TriggerEventListener
|
||||
import android.util.Log
|
||||
|
||||
class SignificantMotionTrigger(
|
||||
private val context: Context,
|
||||
private val onMotionDetected: () -> Unit
|
||||
) {
|
||||
private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
|
||||
private val motionSensor: Sensor? = sensorManager.getDefaultSensor(Sensor.TYPE_SIGNIFICANT_MOTION)
|
||||
|
||||
private var isArmed = false
|
||||
|
||||
private val triggerListener = object : TriggerEventListener() {
|
||||
override fun onTrigger(event: TriggerEvent?) {
|
||||
Log.d(TAG, "Hardware Significant Motion Triggered!")
|
||||
isArmed = false
|
||||
onMotionDetected()
|
||||
}
|
||||
}
|
||||
|
||||
val isAvailable: Boolean
|
||||
get() = motionSensor != null
|
||||
|
||||
fun arm(): Boolean {
|
||||
if (motionSensor == null) {
|
||||
Log.w(TAG, "Significant motion sensor not available")
|
||||
return false
|
||||
}
|
||||
if (isArmed) {
|
||||
sensorManager.cancelTriggerSensor(triggerListener, motionSensor)
|
||||
}
|
||||
isArmed = sensorManager.requestTriggerSensor(triggerListener, motionSensor)
|
||||
Log.d(TAG, "Significant motion sensor armed: $isArmed")
|
||||
return isArmed
|
||||
}
|
||||
|
||||
fun disarm() {
|
||||
if (motionSensor != null && isArmed) {
|
||||
sensorManager.cancelTriggerSensor(triggerListener, motionSensor)
|
||||
isArmed = false
|
||||
Log.d(TAG, "Significant motion sensor disarmed")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SignificantMotion"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.example.avtex.engine
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.google.android.gms.location.SleepClassifyEvent
|
||||
import com.google.android.gms.location.SleepSegmentEvent
|
||||
|
||||
class SleepReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (SleepClassifyEvent.hasEvents(intent)) {
|
||||
val events = SleepClassifyEvent.extractEvents(intent)
|
||||
for (event in events) {
|
||||
val confidence = event.confidence
|
||||
val motion = event.motion
|
||||
val light = event.light
|
||||
Log.d(TAG, "SleepClassifyEvent: confidence=$confidence, motion=$motion, light=$light")
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastClassifyLogTimestamp >= MIN_CLASSIFY_LOG_INTERVAL_MS) {
|
||||
lastClassifyLogTimestamp = now
|
||||
AvtexApplication.instance.settingsRepository.addLog(
|
||||
tag = "SleepAPI",
|
||||
message = "Классификация (раз в час): уверенность $confidence%, движение $motion/6, свет $light/6"
|
||||
)
|
||||
}
|
||||
SleepTrackingService.onSleepClassifyReceived(context, confidence, motion, light)
|
||||
}
|
||||
}
|
||||
|
||||
if (SleepSegmentEvent.hasEvents(intent)) {
|
||||
val events = SleepSegmentEvent.extractEvents(intent)
|
||||
for (event in events) {
|
||||
Log.d(TAG, "SleepSegmentEvent: ${event.startTimeMillis} to ${event.endTimeMillis} (status=${event.status})")
|
||||
AvtexApplication.instance.settingsRepository.addLog(
|
||||
tag = "SleepSegment",
|
||||
message = "Сегмент сна завершен: ${event.startTimeMillis} - ${event.endTimeMillis}",
|
||||
isImportant = true
|
||||
)
|
||||
SleepTrackingService.onSleepSegmentReceived(context, event.startTimeMillis, event.endTimeMillis, event.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SleepReceiver"
|
||||
const val ACTION_SLEEP_API_EVENT = "com.example.avtex.ACTION_SLEEP_API_EVENT"
|
||||
private const val MIN_CLASSIFY_LOG_INTERVAL_MS = 60 * 60 * 1000L // Минимум 1 раз в час
|
||||
@Volatile
|
||||
private var lastClassifyLogTimestamp: Long = 0L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.avtex.engine
|
||||
|
||||
enum class SleepState {
|
||||
AWAKE,
|
||||
ASLEEP
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package com.example.avtex.engine
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Notification
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.MainActivity
|
||||
import com.example.avtex.hac.HacClient
|
||||
import com.google.android.gms.location.ActivityRecognition
|
||||
import com.google.android.gms.location.SleepSegmentRequest
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class SleepTrackingService : Service() {
|
||||
|
||||
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private lateinit var significantMotionTrigger: SignificantMotionTrigger
|
||||
private lateinit var hacClient: HacClient
|
||||
|
||||
private var screenReceiver: BroadcastReceiver? = null
|
||||
private var inactivityJob: Job? = null
|
||||
private var sleepApiPendingIntent: PendingIntent? = null
|
||||
|
||||
private var currentState: SleepState = SleepState.AWAKE
|
||||
private var stateChangeTimestamp: Long = System.currentTimeMillis()
|
||||
|
||||
private val settingsRepository get() = AvtexApplication.instance.settingsRepository
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.d(TAG, "SleepTrackingService onCreate")
|
||||
hacClient = HacClient(this)
|
||||
|
||||
significantMotionTrigger = SignificantMotionTrigger(this) {
|
||||
handleWakeupDetected(source = "significant_motion")
|
||||
}
|
||||
|
||||
registerScreenReceiver()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action ?: ACTION_START_TRACKING
|
||||
Log.d(TAG, "onStartCommand action: $action")
|
||||
|
||||
when (action) {
|
||||
ACTION_START_TRACKING -> {
|
||||
startForegroundServiceWithNotification()
|
||||
initializeTracking()
|
||||
}
|
||||
ACTION_STOP_TRACKING -> {
|
||||
stopTracking()
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
ACTION_MANUAL_TOGGLE -> {
|
||||
val newState = if (currentState == SleepState.AWAKE) SleepState.ASLEEP else SleepState.AWAKE
|
||||
transitionToState(newState, source = "manual_toggle", confidence = 100)
|
||||
}
|
||||
ACTION_TEST_HAC -> {
|
||||
serviceScope.launch {
|
||||
val settings = settingsRepository.getSettings()
|
||||
val isAsleep = currentState == SleepState.ASLEEP
|
||||
settingsRepository.addLog("HAC_TEST", "Отправка тестового запроса в Home Assistant...")
|
||||
val result = hacClient.sendSleepEvent(settings, isAsleep, source = "manual_test")
|
||||
result.onSuccess { msg ->
|
||||
settingsRepository.addLog("HAC_TEST", "Тест успешен: $msg", isImportant = true)
|
||||
settingsRepository.updateHacStatus("Тест успешен: $msg")
|
||||
}.onFailure { err ->
|
||||
settingsRepository.addLog("HAC_TEST", "Ошибка теста: ${err.localizedMessage}", isImportant = true)
|
||||
settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun startForegroundServiceWithNotification() {
|
||||
val notification = buildNotification(
|
||||
title = "Avtex: Мониторинг активности",
|
||||
text = "Статус: ${if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"}"
|
||||
)
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun initializeTracking() {
|
||||
serviceScope.launch {
|
||||
val settings = settingsRepository.getSettings()
|
||||
currentState = if (settings.currentState == "ASLEEP") SleepState.ASLEEP else SleepState.AWAKE
|
||||
stateChangeTimestamp = settings.lastStateChangeMillis
|
||||
settingsRepository.updateTrackingActive(true)
|
||||
settingsRepository.addLog("Engine", "Служба мониторинга сна запущена", isImportant = true)
|
||||
|
||||
updateNotification()
|
||||
subscribeToSleepApi()
|
||||
|
||||
if (currentState == SleepState.ASLEEP) {
|
||||
significantMotionTrigger.arm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTracking() {
|
||||
serviceScope.launch {
|
||||
settingsRepository.updateTrackingActive(false)
|
||||
settingsRepository.addLog("Engine", "Служба мониторинга остановлена", isImportant = true)
|
||||
significantMotionTrigger.disarm()
|
||||
unsubscribeFromSleepApi()
|
||||
inactivityJob?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerScreenReceiver() {
|
||||
screenReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Log.d(TAG, "Screen OFF event received")
|
||||
handleScreenOff()
|
||||
}
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.d(TAG, "Screen ON event received")
|
||||
}
|
||||
Intent.ACTION_USER_PRESENT -> {
|
||||
Log.d(TAG, "User unlocked device")
|
||||
handleWakeupDetected(source = "screen_unlocked")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_USER_PRESENT)
|
||||
}
|
||||
registerReceiver(screenReceiver, filter)
|
||||
}
|
||||
|
||||
private fun handleScreenOff() {
|
||||
if (currentState == SleepState.AWAKE) {
|
||||
inactivityJob?.cancel()
|
||||
inactivityJob = serviceScope.launch {
|
||||
val settings = settingsRepository.getSettings()
|
||||
val minutes = settings.inactivityMinutes
|
||||
Log.d(TAG, "Starting inactivity countdown: $minutes minutes")
|
||||
settingsRepository.addLog("Engine", "Экран выключен. Ожидание сна ($minutes мин)...")
|
||||
delay(minutes * 60 * 1000L)
|
||||
|
||||
// If still awake and not interrupted, assume sleep
|
||||
if (currentState == SleepState.AWAKE) {
|
||||
settingsRepository.addLog("Engine", "Время покоя истекло ($minutes мин). Переход в режим сна.", isImportant = true)
|
||||
transitionToState(SleepState.ASLEEP, source = "inactivity_timer", confidence = 90)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWakeupDetected(source: String) {
|
||||
inactivityJob?.cancel()
|
||||
if (currentState == SleepState.ASLEEP) {
|
||||
Log.d(TAG, "Wakeup detected from source: $source")
|
||||
transitionToState(SleepState.AWAKE, source = source, confidence = 100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun transitionToState(newState: SleepState, source: String, confidence: Int) {
|
||||
if (currentState == newState) return
|
||||
|
||||
currentState = newState
|
||||
stateChangeTimestamp = System.currentTimeMillis()
|
||||
|
||||
serviceScope.launch {
|
||||
val isAsleep = (newState == SleepState.ASLEEP)
|
||||
val stateName = if (isAsleep) "ASLEEP" else "AWAKE"
|
||||
settingsRepository.updateCurrentState(stateName, stateChangeTimestamp)
|
||||
|
||||
if (isAsleep) {
|
||||
// When falling asleep, arm significant motion trigger for instant wake detection
|
||||
val armed = significantMotionTrigger.arm()
|
||||
settingsRepository.addLog(
|
||||
"Engine",
|
||||
"Зафиксировано засыпание ($source). Датчик движения взведен: $armed",
|
||||
isImportant = true
|
||||
)
|
||||
} else {
|
||||
// When waking up, disarm trigger
|
||||
significantMotionTrigger.disarm()
|
||||
settingsRepository.addLog(
|
||||
"Engine",
|
||||
"Зафиксировано пробуждение ($source).",
|
||||
isImportant = true
|
||||
)
|
||||
}
|
||||
|
||||
updateNotification()
|
||||
|
||||
// Acquire temporary WakeLock to ensure HTTP request reaches HAC on OnePlus
|
||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
val wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "Avtex:HacTransmissionWakeLock")
|
||||
wakeLock.acquire(15000L) // max 15 seconds
|
||||
|
||||
try {
|
||||
val settings = settingsRepository.getSettings()
|
||||
val result = hacClient.sendSleepEvent(settings, isAsleep, source, confidence)
|
||||
result.onSuccess { msg ->
|
||||
Log.d(TAG, "HAC send success: $msg")
|
||||
settingsRepository.addLog("HAC", "Отправлено в Home Assistant: $msg")
|
||||
settingsRepository.updateHacStatus("Отправлено в ${formatTime(stateChangeTimestamp)}: $msg")
|
||||
}.onFailure { err ->
|
||||
Log.e(TAG, "HAC send error", err)
|
||||
settingsRepository.addLog("HAC", "Ошибка отправки в Home Assistant: ${err.localizedMessage}", isImportant = true)
|
||||
settingsRepository.updateHacStatus("Ошибка: ${err.localizedMessage}")
|
||||
}
|
||||
} finally {
|
||||
if (wakeLock.isHeld) {
|
||||
wakeLock.release()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun subscribeToSleepApi() {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
|
||||
Log.w(TAG, "ACTIVITY_RECOGNITION permission not granted for Sleep API")
|
||||
settingsRepository.addLog("SleepAPI", "Нет разрешения на активность. Используются аппаратные датчики движения.")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val intent = Intent(this, SleepReceiver::class.java).apply {
|
||||
action = SleepReceiver.ACTION_SLEEP_API_EVENT
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
sleepApiPendingIntent = PendingIntent.getBroadcast(this, REQUEST_CODE_SLEEP_API, intent, flags)
|
||||
|
||||
val request = SleepSegmentRequest.getDefaultSleepSegmentRequest()
|
||||
val task = ActivityRecognition.getClient(this).requestSleepSegmentUpdates(sleepApiPendingIntent!!, request)
|
||||
task.addOnSuccessListener {
|
||||
Log.d(TAG, "Successfully subscribed to Google Sleep API")
|
||||
settingsRepository.addLog("SleepAPI", "Google Sleep API успешно подключен")
|
||||
}
|
||||
task.addOnFailureListener { e ->
|
||||
Log.e(TAG, "Failed to subscribe to Google Sleep API", e)
|
||||
settingsRepository.addLog("SleepAPI", "Не удалось подключить Google Sleep API: ${e.localizedMessage}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Exception subscribing to Sleep API", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun unsubscribeFromSleepApi() {
|
||||
sleepApiPendingIntent?.let { pi ->
|
||||
try {
|
||||
ActivityRecognition.getClient(this).removeSleepSegmentUpdates(pi)
|
||||
Log.d(TAG, "Unsubscribed from Sleep API")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error unsubscribing from Sleep API", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildNotification(title: String, text: String): Notification {
|
||||
val openAppIntent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(this, 0, openAppIntent, flags)
|
||||
|
||||
return NotificationCompat.Builder(this, AvtexApplication.CHANNEL_ID_TRACKING)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_charging)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun updateNotification() {
|
||||
val stateText = if (currentState == SleepState.ASLEEP) "Спит 🌙" else "Бодрствует ☀️"
|
||||
val timeFormatted = formatTime(stateChangeTimestamp)
|
||||
val notification = buildNotification(
|
||||
title = "Avtex: $stateText",
|
||||
text = "Состояние с $timeFormatted"
|
||||
)
|
||||
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long): String {
|
||||
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(millis))
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Log.d(TAG, "SleepTrackingService onDestroy")
|
||||
screenReceiver?.let {
|
||||
unregisterReceiver(it)
|
||||
screenReceiver = null
|
||||
}
|
||||
significantMotionTrigger.disarm()
|
||||
unsubscribeFromSleepApi()
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SleepTrackingService"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
private const val REQUEST_CODE_SLEEP_API = 2001
|
||||
|
||||
const val ACTION_START_TRACKING = "com.example.avtex.ACTION_START_TRACKING"
|
||||
const val ACTION_STOP_TRACKING = "com.example.avtex.ACTION_STOP_TRACKING"
|
||||
const val ACTION_MANUAL_TOGGLE = "com.example.avtex.ACTION_MANUAL_TOGGLE"
|
||||
const val ACTION_TEST_HAC = "com.example.avtex.ACTION_TEST_HAC"
|
||||
|
||||
@Volatile
|
||||
private var instance: SleepTrackingService? = null
|
||||
|
||||
fun onSleepClassifyReceived(context: Context, confidence: Int, motion: Int, light: Int) {
|
||||
// If confidence is high and device is in stationary motion
|
||||
val repo = AvtexApplication.instance.settingsRepository
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
val settings = repo.getSettings()
|
||||
if (settings.isTrackingActive && settings.currentState == "AWAKE") {
|
||||
if (confidence >= settings.confidenceThreshold && motion <= 2) {
|
||||
Log.d(TAG, "SleepClassify indicates sleep: confidence=$confidence, motion=$motion")
|
||||
// Send intent to service to transition state
|
||||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = ACTION_MANUAL_TOGGLE // or specific intent
|
||||
}
|
||||
// Only trigger if actually confident
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onSleepSegmentReceived(context: Context, start: Long, end: Long, status: Int) {
|
||||
Log.d(TAG, "SleepSegment completed: start=$start, end=$end, status=$status")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.example.avtex.hac
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.example.avtex.data.AppSettings
|
||||
import com.example.avtex.data.HacMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class HacClient(private val context: Context) {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun sendSleepEvent(
|
||||
settings: AppSettings,
|
||||
isAsleep: Boolean,
|
||||
source: String,
|
||||
confidence: Int = 100
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
val stateStr = if (isAsleep) "asleep" else "awake"
|
||||
val eventStr = if (isAsleep) "sleep_started" else "sleep_ended"
|
||||
val timestamp = System.currentTimeMillis()
|
||||
|
||||
// 1. Send Local Broadcast if enabled
|
||||
if (settings.broadcastIntents) {
|
||||
val broadcastIntent = Intent(ACTION_SLEEP_STATE_CHANGED).apply {
|
||||
putExtra("state", stateStr)
|
||||
putExtra("event", eventStr)
|
||||
putExtra("is_asleep", isAsleep)
|
||||
putExtra("source", source)
|
||||
putExtra("confidence", confidence)
|
||||
putExtra("timestamp", timestamp)
|
||||
setPackage(context.packageName)
|
||||
}
|
||||
context.sendBroadcast(broadcastIntent)
|
||||
}
|
||||
|
||||
// 2. Send HTTP Request based on configured mode
|
||||
when (settings.hacMode) {
|
||||
HacMode.WEBHOOK -> {
|
||||
if (settings.hacWebhookUrl.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("Webhook URL не задан в настройках"))
|
||||
}
|
||||
sendWebhook(
|
||||
url = settings.hacWebhookUrl.trim(),
|
||||
event = eventStr,
|
||||
state = stateStr,
|
||||
confidence = confidence,
|
||||
source = source,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
HacMode.REST -> {
|
||||
if (settings.hacRestUrl.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("REST API URL не задан в настройках"))
|
||||
}
|
||||
sendRest(
|
||||
url = settings.hacRestUrl.trim(),
|
||||
token = settings.hacToken.trim(),
|
||||
isAsleep = isAsleep,
|
||||
confidence = confidence,
|
||||
source = source,
|
||||
timestamp = timestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendWebhook(
|
||||
url: String,
|
||||
event: String,
|
||||
state: String,
|
||||
confidence: Int,
|
||||
source: String,
|
||||
timestamp: Long
|
||||
): Result<String> {
|
||||
return try {
|
||||
val json = JSONObject().apply {
|
||||
put("event", event)
|
||||
put("state", state)
|
||||
put("confidence", confidence)
|
||||
put("source", source)
|
||||
put("timestamp", timestamp)
|
||||
put("time_iso", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).format(Date(timestamp)))
|
||||
}
|
||||
|
||||
val body = json.toString().toRequestBody(jsonMediaType)
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
Result.success("Webhook OK (${it.code})")
|
||||
} else {
|
||||
Result.failure(Exception("Ошибка сервера: HTTP ${it.code} ${it.message}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendRest(
|
||||
url: String,
|
||||
token: String,
|
||||
isAsleep: Boolean,
|
||||
confidence: Int,
|
||||
source: String,
|
||||
timestamp: Long
|
||||
): Result<String> {
|
||||
return try {
|
||||
val binaryState = if (isAsleep) "on" else "off"
|
||||
val attributes = JSONObject().apply {
|
||||
put("device_class", "occupancy")
|
||||
put("friendly_name", "Сон пользователя (Avtex)")
|
||||
put("confidence", confidence)
|
||||
put("source", source)
|
||||
put("last_updated", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).format(Date(timestamp)))
|
||||
}
|
||||
val payload = JSONObject().apply {
|
||||
put("state", binaryState)
|
||||
put("attributes", attributes)
|
||||
}
|
||||
|
||||
val body = payload.toString().toRequestBody(jsonMediaType)
|
||||
val requestBuilder = Request.Builder()
|
||||
.url(url)
|
||||
.post(body)
|
||||
|
||||
if (token.isNotBlank()) {
|
||||
requestBuilder.header("Authorization", "Bearer $token")
|
||||
}
|
||||
|
||||
val response = httpClient.newCall(requestBuilder.build()).execute()
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
Result.success("REST OK (${it.code})")
|
||||
} else {
|
||||
Result.failure(Exception("Ошибка REST API: HTTP ${it.code} ${it.message}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SLEEP_STATE_CHANGED = "com.example.avtex.SLEEP_STATE_CHANGED"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
data class NoteItem(
|
||||
val id: String,
|
||||
val listId: String,
|
||||
val title: String,
|
||||
val description: String = "",
|
||||
val isCompleted: Boolean = false,
|
||||
val status: String = "todo", // "want_to_watch", "watched", "todo", "completed"
|
||||
val priority: String = "normal",
|
||||
val metadata: String = "{}",
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
|
||||
data class NoteList(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val category: String = "general", // "movies", "shopping", "tasks", "general"
|
||||
val description: String = "",
|
||||
val color: String? = null,
|
||||
val icon: String? = null,
|
||||
val items: List<NoteItem> = emptyList(),
|
||||
val createdAt: Long = System.currentTimeMillis()
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
class NotesRepository(context: Context) {
|
||||
|
||||
private val dbHelper = XFlowDatabaseHelper(context)
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
|
||||
val listsFlow: StateFlow<List<NoteList>> = _listsFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
reload()
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scope.launch {
|
||||
val lists = loadListsFromDb()
|
||||
_listsFlow.value = lists
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadListsFromDb(): List<NoteList> = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.readableDatabase
|
||||
val lists = mutableListOf<NoteList>()
|
||||
|
||||
val cursorLists = db.query(
|
||||
XFlowDatabaseHelper.TABLE_LISTS,
|
||||
null, null, null, null, null, "created_at DESC"
|
||||
)
|
||||
|
||||
cursorLists.use { cLists ->
|
||||
while (cLists.moveToNext()) {
|
||||
val listId = cLists.getString(cLists.getColumnIndexOrThrow("id"))
|
||||
val title = cLists.getString(cLists.getColumnIndexOrThrow("title"))
|
||||
val category = cLists.getString(cLists.getColumnIndexOrThrow("category"))
|
||||
val description = cLists.getString(cLists.getColumnIndexOrThrow("description")) ?: ""
|
||||
val color = cLists.getString(cLists.getColumnIndexOrThrow("color"))
|
||||
val icon = cLists.getString(cLists.getColumnIndexOrThrow("icon"))
|
||||
val createdAt = cLists.getLong(cLists.getColumnIndexOrThrow("created_at"))
|
||||
|
||||
// Загружаем элементы списка
|
||||
val items = mutableListOf<NoteItem>()
|
||||
val cursorItems = db.query(
|
||||
XFlowDatabaseHelper.TABLE_ITEMS,
|
||||
null, "list_id = ?", arrayOf(listId), null, null, "created_at DESC"
|
||||
)
|
||||
|
||||
cursorItems.use { cItems ->
|
||||
while (cItems.moveToNext()) {
|
||||
val itemId = cItems.getString(cItems.getColumnIndexOrThrow("id"))
|
||||
val itemTitle = cItems.getString(cItems.getColumnIndexOrThrow("title"))
|
||||
val itemDesc = cItems.getString(cItems.getColumnIndexOrThrow("description")) ?: ""
|
||||
val isCompleted = cItems.getInt(cItems.getColumnIndexOrThrow("is_completed")) == 1
|
||||
val status = cItems.getString(cItems.getColumnIndexOrThrow("status"))
|
||||
val priority = cItems.getString(cItems.getColumnIndexOrThrow("priority"))
|
||||
val metadata = cItems.getString(cItems.getColumnIndexOrThrow("metadata")) ?: "{}"
|
||||
val itemCreatedAt = cItems.getLong(cItems.getColumnIndexOrThrow("created_at"))
|
||||
|
||||
items.add(
|
||||
NoteItem(
|
||||
id = itemId,
|
||||
listId = listId,
|
||||
title = itemTitle,
|
||||
description = itemDesc,
|
||||
isCompleted = isCompleted,
|
||||
status = status,
|
||||
priority = priority,
|
||||
metadata = metadata,
|
||||
createdAt = itemCreatedAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lists.add(
|
||||
NoteList(
|
||||
id = listId,
|
||||
title = title,
|
||||
category = category,
|
||||
description = description,
|
||||
color = color,
|
||||
icon = icon,
|
||||
items = items,
|
||||
createdAt = createdAt
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
lists
|
||||
}
|
||||
|
||||
suspend fun addNoteItem(
|
||||
title: String,
|
||||
listTitle: String = "Фильмы на просмотр",
|
||||
category: String = "movies",
|
||||
description: String = ""
|
||||
): NoteItem = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.writableDatabase
|
||||
|
||||
// Ищем список по названию
|
||||
var targetListId: String? = null
|
||||
val c = db.query(
|
||||
XFlowDatabaseHelper.TABLE_LISTS,
|
||||
arrayOf("id"), "LOWER(title) = LOWER(?)", arrayOf(listTitle.trim()),
|
||||
null, null, null
|
||||
)
|
||||
c.use {
|
||||
if (it.moveToFirst()) {
|
||||
targetListId = it.getString(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Если список не найден, создаем его
|
||||
if (targetListId == null) {
|
||||
val newListId = "list-" + UUID.randomUUID().toString()
|
||||
val cvList = ContentValues().apply {
|
||||
put("id", newListId)
|
||||
put("title", listTitle.trim())
|
||||
put("category", category)
|
||||
put("description", "Список $listTitle")
|
||||
put("created_at", System.currentTimeMillis())
|
||||
}
|
||||
db.insert(XFlowDatabaseHelper.TABLE_LISTS, null, cvList)
|
||||
targetListId = newListId
|
||||
}
|
||||
|
||||
val newItemId = "item-" + UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
val cvItem = ContentValues().apply {
|
||||
put("id", newItemId)
|
||||
put("list_id", targetListId)
|
||||
put("title", title.trim())
|
||||
put("description", description)
|
||||
put("is_completed", 0)
|
||||
put("status", if (category == "movies") "want_to_watch" else "todo")
|
||||
put("priority", "normal")
|
||||
put("metadata", "{}")
|
||||
put("created_at", now)
|
||||
}
|
||||
db.insert(XFlowDatabaseHelper.TABLE_ITEMS, null, cvItem)
|
||||
|
||||
reload()
|
||||
|
||||
NoteItem(
|
||||
id = newItemId,
|
||||
listId = targetListId,
|
||||
title = title.trim(),
|
||||
description = description,
|
||||
isCompleted = false,
|
||||
status = if (category == "movies") "want_to_watch" else "todo",
|
||||
createdAt = now
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun toggleItem(item: NoteItem) = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.writableDatabase
|
||||
val newCompleted = !item.isCompleted
|
||||
val newStatus = if (newCompleted) {
|
||||
if (item.status == "want_to_watch") "watched" else "completed"
|
||||
} else {
|
||||
if (item.status == "watched") "want_to_watch" else "todo"
|
||||
}
|
||||
|
||||
val cv = ContentValues().apply {
|
||||
put("is_completed", if (newCompleted) 1 else 0)
|
||||
put("status", newStatus)
|
||||
}
|
||||
db.update(XFlowDatabaseHelper.TABLE_ITEMS, cv, "id = ?", arrayOf(item.id))
|
||||
reload()
|
||||
}
|
||||
|
||||
suspend fun deleteItem(itemId: String) = withContext(Dispatchers.IO) {
|
||||
val db = dbHelper.writableDatabase
|
||||
db.delete(XFlowDatabaseHelper.TABLE_ITEMS, "id = ?", arrayOf(itemId))
|
||||
reload()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
|
||||
class XFlowDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL("""
|
||||
CREATE TABLE $TABLE_LISTS (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE $TABLE_ITEMS (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_completed INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
priority TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY(list_id) REFERENCES $TABLE_LISTS(id) ON DELETE CASCADE
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
// Создаем дефолтные списки для удобства пользователя
|
||||
val now = System.currentTimeMillis()
|
||||
val cvMovies = ContentValues().apply {
|
||||
put("id", "list-movies-default")
|
||||
put("title", "Фильмы на просмотр")
|
||||
put("category", "movies")
|
||||
put("description", "Список фильмов и сериалов")
|
||||
put("created_at", now)
|
||||
}
|
||||
db.insert(TABLE_LISTS, null, cvMovies)
|
||||
|
||||
val cvShopping = ContentValues().apply {
|
||||
put("id", "list-shopping-default")
|
||||
put("title", "Покупки")
|
||||
put("category", "shopping")
|
||||
put("description", "Список покупок")
|
||||
put("created_at", now)
|
||||
}
|
||||
db.insert(TABLE_LISTS, null, cvShopping)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_ITEMS")
|
||||
db.execSQL("DROP TABLE IF EXISTS $TABLE_LISTS")
|
||||
onCreate(db)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "xflow_notes.db"
|
||||
private const val DATABASE_VERSION = 1
|
||||
|
||||
const val TABLE_LISTS = "lists"
|
||||
const val TABLE_ITEMS = "items"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.example.avtex.notes
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class XFlowSyncClient(private val context: Context) {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
|
||||
suspend fun syncItemToServer(
|
||||
serverUrl: String,
|
||||
title: String,
|
||||
listTitle: String,
|
||||
category: String,
|
||||
ownerId: String = "default-user"
|
||||
): Result<String> = withContext(Dispatchers.IO) {
|
||||
if (serverUrl.isBlank()) {
|
||||
return@withContext Result.failure(IllegalArgumentException("URL сервера XFlow не указан"))
|
||||
}
|
||||
|
||||
try {
|
||||
val endpoint = "${serverUrl.trimEnd('/')}/api/v1/lists/items"
|
||||
val json = JSONObject().apply {
|
||||
put("title", title)
|
||||
put("listTitle", listTitle)
|
||||
put("category", category)
|
||||
put("ownerId", ownerId)
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(endpoint)
|
||||
.post(json.toString().toRequestBody(jsonMediaType))
|
||||
.build()
|
||||
|
||||
val response = httpClient.newCall(request).execute()
|
||||
response.use {
|
||||
if (it.isSuccessful) {
|
||||
Result.success("Синхронизировано с XFlow")
|
||||
} else {
|
||||
Result.failure(Exception("HTTP ${it.code}: ${it.message}"))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Sync to XFlow server failed", e)
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "XFlowSyncClient"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.avtex.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Obsidian & Midnight Backgrounds
|
||||
val ObsidianBg = Color(0xFF090A0F)
|
||||
val ObsidianSurface = Color(0xFF11131E)
|
||||
val ObsidianSurfaceElevated = Color(0xFF181B2A)
|
||||
val ObsidianSurfaceHigh = Color(0xFF21253A)
|
||||
val GlassBorder = Color(0x1FFFFFFF)
|
||||
|
||||
// Sleep Neon Accents (Night / Sleep)
|
||||
val NeonViolet = Color(0xFFA855F7)
|
||||
val NeonPurple = Color(0xFF8B5CF6)
|
||||
val ElectricIndigo = Color(0xFF6366F1)
|
||||
val DeepIndigo = Color(0xFF1E1B4B)
|
||||
val LavenderGlow = Color(0xFFE9D5FF)
|
||||
|
||||
// Wake Solar Accents (Day / Awake)
|
||||
val SolarAmber = Color(0xFFF59E0B)
|
||||
val SunsetOrange = Color(0xFFF97316)
|
||||
val MorningGold = Color(0xFFFCD34D)
|
||||
val DeepSolar = Color(0xFF451A03)
|
||||
|
||||
// Status & Indicators
|
||||
val EmeraldMint = Color(0xFF10B981)
|
||||
val EmeraldGlow = Color(0xFF34D399)
|
||||
val CoralRed = Color(0xFFEF4444)
|
||||
val CyanAccent = Color(0xFF06B6D4)
|
||||
|
||||
// Typography
|
||||
val TextPrimary = Color(0xFFF8FAFC)
|
||||
val TextSecondary = Color(0xFF94A3B8)
|
||||
val TextMuted = Color(0xFF64748B)
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.example.avtex.theme
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
private val AvtexDarkColorScheme = darkColorScheme(
|
||||
primary = NeonViolet,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = DeepIndigo,
|
||||
onPrimaryContainer = LavenderGlow,
|
||||
secondary = SolarAmber,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = DeepSolar,
|
||||
onSecondaryContainer = MorningGold,
|
||||
tertiary = CyanAccent,
|
||||
background = ObsidianBg,
|
||||
onBackground = TextPrimary,
|
||||
surface = ObsidianSurface,
|
||||
onSurface = TextPrimary,
|
||||
surfaceVariant = ObsidianSurfaceElevated,
|
||||
onSurfaceVariant = TextSecondary,
|
||||
surfaceContainerHighest = ObsidianSurfaceHigh,
|
||||
outline = TextMuted,
|
||||
outlineVariant = GlassBorder
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun AvtexTheme(
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = AvtexDarkColorScheme,
|
||||
typography = Typography,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.example.avtex.theme
|
||||
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
val Typography =
|
||||
Typography(
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
)
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -0,0 +1,728 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Bedtime
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Hub
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Psychology
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Sensors
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
import androidx.compose.material.icons.filled.WbSunny
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.R
|
||||
import com.example.avtex.data.AppSettings
|
||||
import com.example.avtex.data.LogEntry
|
||||
import com.example.avtex.engine.SleepTrackingService
|
||||
import com.example.avtex.theme.CyanAccent
|
||||
import com.example.avtex.theme.DeepIndigo
|
||||
import com.example.avtex.theme.DeepSolar
|
||||
import com.example.avtex.theme.ElectricIndigo
|
||||
import com.example.avtex.theme.EmeraldGlow
|
||||
import com.example.avtex.theme.EmeraldMint
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.LavenderGlow
|
||||
import com.example.avtex.theme.MorningGold
|
||||
import com.example.avtex.theme.NeonPurple
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurface
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.ObsidianSurfaceHigh
|
||||
import com.example.avtex.theme.SolarAmber
|
||||
import com.example.avtex.theme.SunsetOrange
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.theme.TextPrimary
|
||||
import com.example.avtex.theme.TextSecondary
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Composable
|
||||
fun DashboardScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val repository = remember { AvtexApplication.instance.settingsRepository }
|
||||
val settings by repository.settingsFlow.collectAsState(initial = AppSettings())
|
||||
val logs by repository.logsFlow.collectAsState(initial = emptyList())
|
||||
|
||||
val isAsleep = settings.currentState == "ASLEEP"
|
||||
|
||||
// Permission launcher for Notifications and Activity Recognition
|
||||
val permissionsLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||
) { permissions ->
|
||||
val notifGranted = permissions[Manifest.permission.POST_NOTIFICATIONS] ?: true
|
||||
val actGranted = permissions[Manifest.permission.ACTIVITY_RECOGNITION] ?: true
|
||||
if (notifGranted && actGranted) {
|
||||
startTrackingService(context)
|
||||
}
|
||||
}
|
||||
|
||||
fun onToggleTracking(enable: Boolean) {
|
||||
if (enable) {
|
||||
val neededPermissions = mutableListOf<String>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||
neededPermissions.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) != PackageManager.PERMISSION_GRANTED) {
|
||||
neededPermissions.add(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
}
|
||||
|
||||
if (neededPermissions.isNotEmpty()) {
|
||||
permissionsLauncher.launch(neededPermissions.toTypedArray())
|
||||
} else {
|
||||
startTrackingService(context)
|
||||
}
|
||||
} else {
|
||||
stopTrackingService(context)
|
||||
}
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBg),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// 1. Futuristic Brand Header
|
||||
item {
|
||||
BrandHeader(isTrackingActive = settings.isTrackingActive)
|
||||
}
|
||||
|
||||
// 2. Dynamic Ambient Hero State Card
|
||||
item {
|
||||
DynamicHeroCard(
|
||||
isAsleep = isAsleep,
|
||||
isTrackingActive = settings.isTrackingActive,
|
||||
lastStateChangeMillis = settings.lastStateChangeMillis
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Engine Metrics Grid
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
MetricGlassCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Default.Sensors,
|
||||
title = "Датчик движения",
|
||||
value = if (isAsleep) "Взведен ⚡" else "Активен",
|
||||
subtitle = "Significant Motion",
|
||||
accentColor = if (isAsleep) NeonViolet else SolarAmber
|
||||
)
|
||||
MetricGlassCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Default.Psychology,
|
||||
title = "Google Sleep API",
|
||||
value = "${settings.confidenceThreshold}%",
|
||||
subtitle = "Порог нейросети",
|
||||
accentColor = CyanAccent
|
||||
)
|
||||
MetricGlassCard(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Default.Hub,
|
||||
title = "Home Assistant",
|
||||
value = settings.hacMode.name,
|
||||
subtitle = "HAC протокол",
|
||||
accentColor = EmeraldMint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Master Monitoring Glass Card
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(20.dp))
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (settings.isTrackingActive) EmeraldGlow else TextMuted)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Фоновый мониторинг сна",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (settings.isTrackingActive) "Служба активна в фоне • нулевой расход батареи"
|
||||
else "Служба остановлена • нажмите для запуска",
|
||||
fontSize = 12.sp,
|
||||
color = if (settings.isTrackingActive) EmeraldMint else TextMuted
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = settings.isTrackingActive,
|
||||
onCheckedChange = { onToggleTracking(it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = NeonViolet,
|
||||
uncheckedThumbColor = TextMuted,
|
||||
uncheckedTrackColor = ObsidianSurfaceHigh
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Quick Action Control Buttons
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = SleepTrackingService.ACTION_MANUAL_TOGGLE
|
||||
}
|
||||
context.startService(intent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
containerColor = ObsidianSurfaceElevated,
|
||||
contentColor = TextPrimary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = if (isAsleep) SolarAmber else NeonViolet
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "Переключить", fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = SleepTrackingService.ACTION_TEST_HAC
|
||||
}
|
||||
context.startService(intent)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(48.dp),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = NeonViolet,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "Тест в HAC", fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. HAC Live Connection Banner
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(14.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(EmeraldMint.copy(alpha = 0.15f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Bolt,
|
||||
contentDescription = null,
|
||||
tint = EmeraldMint,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Home Assistant Status",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextSecondary
|
||||
)
|
||||
Text(
|
||||
text = settings.lastHacStatus,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Audit Log Header
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "ТЕЛЕМЕТРИЯ И СОБЫТИЯ",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.2.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
Text(
|
||||
text = "${logs.size} событий",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (logs.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(ObsidianSurface)
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Событий пока нет. Включите мониторинг для сбора данных.",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(logs) { log ->
|
||||
FuturisticLogRow(log)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BrandHeader(isTrackingActive: Boolean) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo),
|
||||
contentDescription = "Avtex Logo",
|
||||
modifier = Modifier
|
||||
.size(42.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(12.dp))
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "AVTEX",
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 2.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "SMART SLEEP ENGINE",
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.5.sp,
|
||||
color = NeonViolet
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(if (isTrackingActive) EmeraldMint.copy(alpha = 0.15f) else ObsidianSurfaceElevated)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isTrackingActive) EmeraldMint.copy(alpha = 0.3f) else GlassBorder,
|
||||
RoundedCornerShape(20.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(6.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (isTrackingActive) EmeraldGlow else TextMuted)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = if (isTrackingActive) "ONLINE" else "STANDBY",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.sp,
|
||||
color = if (isTrackingActive) EmeraldMint else TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DynamicHeroCard(
|
||||
isAsleep: Boolean,
|
||||
isTrackingActive: Boolean,
|
||||
lastStateChangeMillis: Long
|
||||
) {
|
||||
val durationText = remember(lastStateChangeMillis) {
|
||||
val diff = System.currentTimeMillis() - lastStateChangeMillis
|
||||
val hours = TimeUnit.MILLISECONDS.toHours(diff)
|
||||
val minutes = TimeUnit.MILLISECONDS.toMinutes(diff) % 60
|
||||
if (hours > 0) "$hours ч $minutes мин" else "$minutes мин"
|
||||
}
|
||||
|
||||
// Breathing glow animation
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val glowScale by infiniteTransition.animateFloat(
|
||||
initialValue = 0.99f,
|
||||
targetValue = 1.01f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(2500),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "glowScale"
|
||||
)
|
||||
|
||||
val gradientColors = if (isAsleep) {
|
||||
listOf(
|
||||
Color(0xFF0F0B24),
|
||||
Color(0xFF1E1446),
|
||||
Color(0xFF2A1560),
|
||||
Color(0xFF4C1D95)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFF2E1005),
|
||||
Color(0xFF4A1A05),
|
||||
Color(0xFF7C2D12),
|
||||
Color(0xFFB45309)
|
||||
)
|
||||
}
|
||||
|
||||
val accentBorderColor = if (isAsleep) NeonViolet.copy(alpha = 0.4f) else SolarAmber.copy(alpha = 0.4f)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(210.dp)
|
||||
.scale(glowScale)
|
||||
.border(1.dp, accentBorderColor, RoundedCornerShape(26.dp)),
|
||||
shape = RoundedCornerShape(26.dp),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Brush.linearGradient(gradientColors))
|
||||
.padding(22.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.12f))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.2f), CircleShape)
|
||||
.padding(14.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isAsleep) Icons.Default.Bedtime else Icons.Default.WbSunny,
|
||||
contentDescription = null,
|
||||
tint = if (isAsleep) LavenderGlow else MorningGold,
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(
|
||||
if (isTrackingActive) EmeraldMint.copy(alpha = 0.25f)
|
||||
else Color.White.copy(alpha = 0.1f)
|
||||
)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isTrackingActive) EmeraldMint.copy(alpha = 0.5f)
|
||||
else Color.White.copy(alpha = 0.15f),
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (isTrackingActive) "МОНИТОРИНГ АКТИВЕН" else "ПАУЗА",
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = if (isAsleep) "Спит" else "Бодрствует",
|
||||
color = Color.White,
|
||||
fontSize = 36.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = (-0.5).sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (isAsleep) "Датчик движения взведен • $durationText в покое"
|
||||
else "Активность зафиксирована • $durationText с пробуждения",
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetricGlassCard(
|
||||
modifier: Modifier = Modifier,
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
value: String,
|
||||
subtitle: String,
|
||||
accentColor: Color
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.SpaceBetween) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(accentColor.copy(alpha = 0.15f))
|
||||
.padding(6.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = value,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextMuted,
|
||||
lineHeight = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FuturisticLogRow(log: LogEntry) {
|
||||
val tagColor = when (log.tag) {
|
||||
"Engine" -> NeonViolet
|
||||
"SleepAPI" -> CyanAccent
|
||||
"SleepSegment" -> ElectricIndigo
|
||||
"SignificantMotion" -> SolarAmber
|
||||
"HAC", "HAC_TEST" -> EmeraldMint
|
||||
else -> TextSecondary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (log.isImportant) ObsidianSurfaceHigh else ObsidianSurface)
|
||||
.border(
|
||||
1.dp,
|
||||
if (log.isImportant) tagColor.copy(alpha = 0.4f) else GlassBorder,
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = log.formattedTime,
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(tagColor.copy(alpha = 0.15f))
|
||||
.border(1.dp, tagColor.copy(alpha = 0.3f), RoundedCornerShape(6.dp))
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = log.tag,
|
||||
fontSize = 10.sp,
|
||||
color = tagColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = log.message,
|
||||
fontSize = 12.sp,
|
||||
color = if (log.isImportant) TextPrimary else TextSecondary,
|
||||
fontWeight = if (log.isImportant) FontWeight.Medium else FontWeight.Normal,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTrackingService(context: Context) {
|
||||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = SleepTrackingService.ACTION_START_TRACKING
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopTrackingService(context: Context) {
|
||||
val intent = Intent(context, SleepTrackingService::class.java).apply {
|
||||
action = SleepTrackingService.ACTION_STOP_TRACKING
|
||||
}
|
||||
context.startService(intent)
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.notes.NoteItem
|
||||
import com.example.avtex.notes.NoteList
|
||||
import com.example.avtex.theme.CyanAccent
|
||||
import com.example.avtex.theme.ElectricIndigo
|
||||
import com.example.avtex.theme.EmeraldMint
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurface
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.ObsidianSurfaceHigh
|
||||
import com.example.avtex.theme.SolarAmber
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.theme.TextPrimary
|
||||
import com.example.avtex.theme.TextSecondary
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
val repository = remember { AvtexApplication.instance.notesRepository }
|
||||
val lists by repository.listsFlow.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var selectedCategory by remember { mutableStateOf("all") }
|
||||
var newItemText by remember { mutableStateOf("") }
|
||||
var isAddingActive by remember { mutableStateOf(false) }
|
||||
|
||||
val categories = listOf(
|
||||
"all" to "Все",
|
||||
"movies" to "🎬 Фильмы",
|
||||
"shopping" to "🛒 Покупки",
|
||||
"tasks" to "⚡ Дела"
|
||||
)
|
||||
|
||||
val filteredLists = remember(lists, selectedCategory) {
|
||||
if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBg)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
// Header
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "XFlow Заметки & Списки",
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { repository.reload() },
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, CircleShape)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Обновить",
|
||||
tint = CyanAccent,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Category Filter Chips
|
||||
item {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
items(categories) { (key, label) ->
|
||||
val isSelected = selectedCategory == key
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isSelected) NeonViolet else GlassBorder,
|
||||
RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable { selectedCategory = key }
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) TextPrimary else TextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Add Item Bar
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newItemText,
|
||||
onValueChange = { newItemText = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
},
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NeonViolet,
|
||||
unfocusedBorderColor = GlassBorder,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
cursorColor = NeonViolet
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (newItemText.isNotBlank()) {
|
||||
coroutineScope.launch {
|
||||
val targetCategory = if (selectedCategory == "all") "movies" else selectedCategory
|
||||
val listTitle = when (targetCategory) {
|
||||
"movies" -> "Фильмы на просмотр"
|
||||
"shopping" -> "Покупки"
|
||||
else -> "Общий список"
|
||||
}
|
||||
repository.addNoteItem(
|
||||
title = newItemText.trim(),
|
||||
listTitle = listTitle,
|
||||
category = targetCategory
|
||||
)
|
||||
newItemText = ""
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.height(52.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Добавить", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lists and Items
|
||||
if (filteredLists.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 40.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
"Нет записей. Добавьте первую заметку или скажите Gemini!",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filteredLists, key = { it.id }) { list ->
|
||||
ListCard(
|
||||
list = list,
|
||||
onToggleItem = { item ->
|
||||
coroutineScope.launch { repository.toggleItem(item) }
|
||||
},
|
||||
onDeleteItem = { item ->
|
||||
coroutineScope.launch { repository.deleteItem(item.id) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListCard(
|
||||
list: NoteList,
|
||||
onToggleItem: (NoteItem) -> Unit,
|
||||
onDeleteItem: (NoteItem) -> Unit
|
||||
) {
|
||||
val accentColor = when (list.category) {
|
||||
"movies" -> CyanAccent
|
||||
"shopping" -> EmeraldMint
|
||||
"tasks" -> SolarAmber
|
||||
else -> NeonViolet
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
// List Title Header
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor)
|
||||
)
|
||||
Text(
|
||||
text = list.title,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${list.items.count { !it.isCompleted }} активных",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
|
||||
// Items list
|
||||
if (list.items.isEmpty()) {
|
||||
Text(
|
||||
text = "Список пуст",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
)
|
||||
} else {
|
||||
list.items.forEach { item ->
|
||||
ItemRow(
|
||||
item = item,
|
||||
accentColor = accentColor,
|
||||
onToggle = { onToggleItem(item) },
|
||||
onDelete = { onDeleteItem(item) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemRow(
|
||||
item: NoteItem,
|
||||
accentColor: Color,
|
||||
onToggle: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||
.clickable { onToggle() }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
checked = item.isCompleted,
|
||||
onCheckedChange = { onToggle() },
|
||||
colors = CheckboxDefaults.colors(
|
||||
checkedColor = accentColor,
|
||||
uncheckedColor = TextMuted,
|
||||
checkmarkColor = ObsidianBg
|
||||
),
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.title,
|
||||
fontSize = 14.sp,
|
||||
color = if (item.isCompleted) TextMuted else TextPrimary,
|
||||
textDecoration = if (item.isCompleted) TextDecoration.LineThrough else TextDecoration.None
|
||||
)
|
||||
if (item.status == "want_to_watch") {
|
||||
Text(
|
||||
text = "🎬 Буду смотреть",
|
||||
fontSize = 10.sp,
|
||||
color = CyanAccent
|
||||
)
|
||||
} else if (item.status == "watched") {
|
||||
Text(
|
||||
text = "✔ Просмотрено",
|
||||
fontSize = 10.sp,
|
||||
color = EmeraldMint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(28.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Удалить",
|
||||
tint = TextMuted,
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.BatteryAlert
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.PowerSettingsNew
|
||||
import androidx.compose.material.icons.filled.Shield
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.avtex.theme.CyanAccent
|
||||
import com.example.avtex.theme.EmeraldGlow
|
||||
import com.example.avtex.theme.EmeraldMint
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurface
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.SolarAmber
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.theme.TextPrimary
|
||||
import com.example.avtex.theme.TextSecondary
|
||||
|
||||
@Composable
|
||||
fun OnePlusGuideScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val powerManager = remember { context.getSystemService(Context.POWER_SERVICE) as PowerManager }
|
||||
val isIgnoringBatteryOptimizations = remember {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
powerManager.isIgnoringBatteryOptimizations(context.packageName)
|
||||
} else true
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBg),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
item {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = "Оптимизация для OnePlus",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Настройка OxygenOS / ColorOS для работы в фоновом режиме",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Battery Optimization Step
|
||||
item {
|
||||
OnePlusStepCard(
|
||||
step = "01",
|
||||
icon = Icons.Default.BatteryAlert,
|
||||
title = "Отключить оптимизацию батареи",
|
||||
status = if (isIgnoringBatteryOptimizations) "АКТИВНО" else "ТРЕБУЕТСЯ",
|
||||
statusColor = if (isIgnoringBatteryOptimizations) EmeraldMint else SolarAmber,
|
||||
description = "OxygenOS замораживает фоновые службы при выключении экрана. Для бесперебойной работы установите режим «Не оптимизировать» (Unrestricted).",
|
||||
actionLabel = "Открыть настройки батареи",
|
||||
onAction = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
val intent = Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Auto-Launch & Background Activity Step
|
||||
item {
|
||||
OnePlusStepCard(
|
||||
step = "02",
|
||||
icon = Icons.Default.PowerSettingsNew,
|
||||
title = "Автозапуск и фоновая активность",
|
||||
status = "РЕКОМЕНДУЕТСЯ",
|
||||
statusColor = CyanAccent,
|
||||
description = "В свойствах приложения перейдите в «Использование батареи» и включите пункты:\n• «Разрешить работу в фоновом режиме»\n• «Разрешить автозапуск» (Auto-launch).",
|
||||
actionLabel = "Свойства приложения в системе",
|
||||
onAction = {
|
||||
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.parse("package:${context.packageName}")
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Lock in Recents Step
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NeonViolet.copy(alpha = 0.15f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Lock,
|
||||
contentDescription = null,
|
||||
tint = NeonViolet,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = "Закрепить в недавних",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "ШАГ 03",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = NeonViolet
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "Откройте экран недавних приложений (свайп снизу вверх с удержанием), нажмите три точки над карточкой Avtex и выберите «Закрепить» (иконка замочка 🔒). Это предотвратит выгрузку приложения при очистке памяти.",
|
||||
fontSize = 13.sp,
|
||||
color = TextSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hardware Sensor Hub Assurance
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(20.dp))
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(EmeraldMint.copy(alpha = 0.15f))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Shield,
|
||||
contentDescription = null,
|
||||
tint = EmeraldMint,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Аппаратная энергоэффективность",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Датчик Significant Motion работает на отдельном микроконтроллере (Sensor Hub). Батарея OnePlus не разряжается даже при круглосуточном отслеживании.",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OnePlusStepCard(
|
||||
step: String,
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
status: String,
|
||||
statusColor: Color,
|
||||
description: String,
|
||||
actionLabel: String,
|
||||
onAction: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(statusColor.copy(alpha = 0.15f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = statusColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(
|
||||
text = title,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 15.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(statusColor.copy(alpha = 0.15f))
|
||||
.border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(8.dp))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp)
|
||||
) {
|
||||
Text(
|
||||
text = status,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = statusColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = description,
|
||||
fontSize = 13.sp,
|
||||
color = TextSecondary,
|
||||
lineHeight = 18.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
OutlinedButton(
|
||||
onClick = onAction,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, GlassBorder),
|
||||
colors = ButtonDefaults.outlinedButtonColors(
|
||||
containerColor = ObsidianSurface,
|
||||
contentColor = TextPrimary
|
||||
)
|
||||
) {
|
||||
Text(text = actionLabel, fontSize = 13.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Dns
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.data.AppSettings
|
||||
import com.example.avtex.data.HacMode
|
||||
import com.example.avtex.theme.CyanAccent
|
||||
import com.example.avtex.theme.EmeraldMint
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
import com.example.avtex.theme.ObsidianBg
|
||||
import com.example.avtex.theme.ObsidianSurface
|
||||
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||
import com.example.avtex.theme.ObsidianSurfaceHigh
|
||||
import com.example.avtex.theme.TextMuted
|
||||
import com.example.avtex.theme.TextPrimary
|
||||
import com.example.avtex.theme.TextSecondary
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SettingsScreen(modifier: Modifier = Modifier) {
|
||||
val repository = remember { AvtexApplication.instance.settingsRepository }
|
||||
val settings by repository.settingsFlow.collectAsState(initial = AppSettings())
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var selectedMode by remember(settings.hacMode) { mutableStateOf(settings.hacMode) }
|
||||
var webhookUrl by remember(settings.hacWebhookUrl) { mutableStateOf(settings.hacWebhookUrl) }
|
||||
var restUrl by remember(settings.hacRestUrl) { mutableStateOf(settings.hacRestUrl) }
|
||||
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
|
||||
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
|
||||
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) }
|
||||
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
|
||||
|
||||
var isSaved by remember { mutableStateOf(false) }
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBg),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp)
|
||||
) {
|
||||
// 1. Header
|
||||
item {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Text(
|
||||
text = "Конфигурация",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Интеграция с Home Assistant и калибровка сенсоров",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. HAC Connection Glass Card
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NeonViolet.copy(alpha = 0.15f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Dns,
|
||||
contentDescription = null,
|
||||
tint = NeonViolet,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Связь с Home Assistant (HAC)",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Отправка событий сна в умный дом",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Mode Selector Tabs
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(14.dp))
|
||||
.padding(4.dp)
|
||||
) {
|
||||
TabSelectorItem(
|
||||
modifier = Modifier.weight(1f),
|
||||
title = "Webhook URL",
|
||||
isSelected = selectedMode == HacMode.WEBHOOK,
|
||||
onClick = { selectedMode = HacMode.WEBHOOK }
|
||||
)
|
||||
TabSelectorItem(
|
||||
modifier = Modifier.weight(1f),
|
||||
title = "REST State API",
|
||||
isSelected = selectedMode == HacMode.REST,
|
||||
onClick = { selectedMode = HacMode.REST }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (selectedMode == HacMode.WEBHOOK) {
|
||||
OutlinedTextField(
|
||||
value = webhookUrl,
|
||||
onValueChange = { webhookUrl = it; isSaved = false },
|
||||
label = { Text("Webhook URL") },
|
||||
placeholder = { Text("http://192.168.1.50:8123/api/webhook/avtex_sleep") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
singleLine = true,
|
||||
colors = customTextFieldColors()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Работает как с локальным адресом, так и через Nabu Casa / VPN.",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
} else {
|
||||
OutlinedTextField(
|
||||
value = restUrl,
|
||||
onValueChange = { restUrl = it; isSaved = false },
|
||||
label = { Text("REST State URL") },
|
||||
placeholder = { Text("http://192.168.1.50:8123/api/states/binary_sensor.phone_sleep") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
singleLine = true,
|
||||
colors = customTextFieldColors()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
OutlinedTextField(
|
||||
value = token,
|
||||
onValueChange = { token = it; isSaved = false },
|
||||
label = { Text("Long-Lived Access Token") },
|
||||
placeholder = { Text("Токен доступа Home Assistant") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
singleLine = true,
|
||||
colors = customTextFieldColors()
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Broadcast toggle
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(14.dp))
|
||||
.background(ObsidianSurface)
|
||||
.padding(12.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NotificationsActive,
|
||||
contentDescription = null,
|
||||
tint = CyanAccent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Локальный Broadcast Intent",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Отправка интента для Tasker / Companion App",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
Switch(
|
||||
checked = broadcastIntents,
|
||||
onCheckedChange = { broadcastIntents = it; isSaved = false },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = CyanAccent
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sensor Tuning Glass Card
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(22.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
|
||||
.padding(18.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(CyanAccent.copy(alpha = 0.15f))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Tune,
|
||||
contentDescription = null,
|
||||
tint = CyanAccent,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "Параметры детектора сна",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Калибровка порогов перехода в сон",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Inactivity Slider
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Таймаут покоя до сна",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextSecondary
|
||||
)
|
||||
Text(
|
||||
text = "${inactivityMinutes.roundToInt()} мин",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NeonViolet
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Если экран выключен и нет движения указанное время, фиксируется сон.",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
Slider(
|
||||
value = inactivityMinutes,
|
||||
onValueChange = { inactivityMinutes = it; isSaved = false },
|
||||
valueRange = 5f..60f,
|
||||
steps = 10,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = NeonViolet,
|
||||
activeTrackColor = NeonViolet,
|
||||
inactiveTrackColor = ObsidianSurfaceHigh
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Confidence Slider
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = "Порог уверенности Sleep API",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextSecondary
|
||||
)
|
||||
Text(
|
||||
text = "${confidenceThreshold.roundToInt()}%",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CyanAccent
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "Минимальная оценка уверенности сна от алгоритма Google Play Services.",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
Slider(
|
||||
value = confidenceThreshold,
|
||||
onValueChange = { confidenceThreshold = it; isSaved = false },
|
||||
valueRange = 50f..95f,
|
||||
steps = 8,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = CyanAccent,
|
||||
activeTrackColor = CyanAccent,
|
||||
inactiveTrackColor = ObsidianSurfaceHigh
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Save Button
|
||||
item {
|
||||
Button(
|
||||
onClick = {
|
||||
coroutineScope.launch {
|
||||
repository.updateHacSettings(
|
||||
mode = selectedMode,
|
||||
webhookUrl = webhookUrl,
|
||||
restUrl = restUrl,
|
||||
token = token
|
||||
)
|
||||
repository.updateDetectionSensitivity(
|
||||
inactivityMinutes = inactivityMinutes.roundToInt(),
|
||||
confidenceThreshold = confidenceThreshold.roundToInt(),
|
||||
broadcastIntents = broadcastIntents
|
||||
)
|
||||
isSaved = true
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = if (isSaved) EmeraldMint else NeonViolet,
|
||||
contentColor = Color.White
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isSaved) Icons.Default.Check else Icons.Default.Save,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = if (isSaved) "Настройки применены!" else "Сохранить конфигурацию",
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TabSelectorItem(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else Color.Transparent)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isSelected) NeonViolet.copy(alpha = 0.5f) else Color.Transparent,
|
||||
RoundedCornerShape(10.dp)
|
||||
)
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isSelected) Color.White else TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun customTextFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = ObsidianSurface,
|
||||
unfocusedContainerColor = ObsidianSurface,
|
||||
focusedBorderColor = NeonViolet,
|
||||
unfocusedBorderColor = GlassBorder,
|
||||
focusedLabelColor = NeonViolet,
|
||||
unfocusedLabelColor = TextMuted,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary,
|
||||
cursorColor = NeonViolet
|
||||
)
|
||||
|
After Width: | Height: | Size: 266 KiB |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#0D0E1A"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 688 B |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 434 B |
|
After Width: | Height: | Size: 570 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">XFlow</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Avtex" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample backup rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/guide/topics/data/autobackup
|
||||
for details.
|
||||
Note: This file is ignored for devices older than API 31
|
||||
See https://developer.android.com/about/versions/12/backup-restore
|
||||
-->
|
||||
<full-backup-content>
|
||||
<!--
|
||||
<include domain="sharedpref" path="."/>
|
||||
<exclude domain="sharedpref" path="device.xml"/>
|
||||
-->
|
||||
</full-backup-content>
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
Sample data extraction rules file; uncomment and customize as necessary.
|
||||
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
|
||||
for details.
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
</device-transfer>
|
||||
-->
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Добавление элемента в список через Gemini / Google Assistant -->
|
||||
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://lists/add">
|
||||
<parameter
|
||||
android:name="itemList.name"
|
||||
android:key="list_name" />
|
||||
<parameter
|
||||
android:name="itemListElement.name"
|
||||
android:key="item_name" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
<!-- Создание списка через Gemini / Google Assistant -->
|
||||
<capability android:name="actions.intent.CREATE_ITEM_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://lists/create">
|
||||
<parameter
|
||||
android:name="itemList.name"
|
||||
android:key="list_name" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
<!-- Просмотр списка -->
|
||||
<capability android:name="actions.intent.GET_ITEM_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://lists/view">
|
||||
<parameter
|
||||
android:name="itemList.name"
|
||||
android:key="list_name" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
</shortcuts>
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.example.avtex
|
||||
|
||||
import com.example.avtex.data.AppSettings
|
||||
import com.example.avtex.data.HacMode
|
||||
import com.example.avtex.data.LogEntry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AppSettingsTest {
|
||||
|
||||
@Test
|
||||
fun defaultSettings_areCorrect() {
|
||||
val settings = AppSettings()
|
||||
assertEquals(false, settings.isTrackingActive)
|
||||
assertEquals("AWAKE", settings.currentState)
|
||||
assertEquals(HacMode.WEBHOOK, settings.hacMode)
|
||||
assertEquals(20, settings.inactivityMinutes)
|
||||
assertEquals(70, settings.confidenceThreshold)
|
||||
assertEquals(true, settings.broadcastIntents)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logEntry_formatsTimeProperly() {
|
||||
val entry = LogEntry(
|
||||
timestamp = 1726830000000L,
|
||||
tag = "TestTag",
|
||||
message = "Test message"
|
||||
)
|
||||
assertTrue(entry.formattedTime.matches(Regex("\\d{2}:\\d{2}:\\d{2}")))
|
||||
assertEquals("TestTag", entry.tag)
|
||||
assertEquals("Test message", entry.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# Enables Gradle Build Cache.
|
||||
# See https://docs.gradle.org/current/userguide/build_cache.html
|
||||
org.gradle.caching=true
|
||||
# Enables Gradle Configuration Cache, the preferred Gradle execution mode.
|
||||
# See https://docs.gradle.org/current/userguide/configuration_cache.html
|
||||
org.gradle.configuration-cache=true
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
@@ -0,0 +1,50 @@
|
||||
[versions]
|
||||
androidGradlePlugin = "9.0.1"
|
||||
androidxCore = "1.18.0"
|
||||
androidxLifecycle = "2.10.0"
|
||||
androidxActivity = "1.13.0"
|
||||
androidxComposeBom = "2026.03.01"
|
||||
androidxTest = "1.7.0"
|
||||
androidxTestExt = "1.3.0"
|
||||
androidxTestRunner = "1.7.0"
|
||||
androidxTestEspresso = "3.7.0"
|
||||
coroutines = "1.10.2"
|
||||
junit = "4.13.2"
|
||||
kotlin = "2.3.20"
|
||||
nav3Core = "1.0.1"
|
||||
lifecycleViewmodelNav3 = "2.10.0"
|
||||
playServicesLocation = "21.3.0"
|
||||
okhttp = "4.12.0"
|
||||
datastore = "1.1.3"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" }
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidxComposeBom" }
|
||||
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3"}
|
||||
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui"}
|
||||
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"}
|
||||
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4"}
|
||||
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling"}
|
||||
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest"}
|
||||
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
|
||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" }
|
||||
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidxLifecycle" }
|
||||
androidx-test-core = { module = "androidx.test:core", version.ref = "androidxTest" }
|
||||
androidx-test-ext-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestExt" }
|
||||
androidx-test-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "androidxTestEspresso" }
|
||||
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
|
||||
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
|
||||
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }
|
||||
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
|
||||
play-services-location = { module = "com.google.android.gms:play-services-location", version.ref = "playServicesLocation" }
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" }
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
@@ -0,0 +1,8 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
||||
distributionSha256Sum=a17ddd85a26b6a7f5ddb71ff8b05fc5104c0202c6e64782429790c933686c806
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=$(save "$@")
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||
cd "$(dirname "$0")"
|
||||
fi
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,84 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windows variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,33 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("androidx.*")
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("androidx.*")
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||
}
|
||||
|
||||
rootProject.name = "Avtex"
|
||||
include(":app")
|
||||
@@ -0,0 +1,47 @@
|
||||
# API Keys Configuration
|
||||
# Copy this file to .env and fill in your actual keys
|
||||
|
||||
|
||||
|
||||
# DeepSeek API (recommended - cost effective)
|
||||
# Get from: https://platform.deepseek.com/api_keys
|
||||
DEEPSEEK_API_KEY=sk_your_deepseek_api_key_here
|
||||
|
||||
# OpenAI API (alternative)
|
||||
# Get from: https://platform.openai.com/api-keys
|
||||
OPENAI_API_KEY=sk_your_openai_api_key_here
|
||||
|
||||
# Anthropic API (alternative)
|
||||
# Get from: https://console.anthropic.com/
|
||||
ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
||||
|
||||
# Firebase Configuration (for Nuxt runtimeConfig)
|
||||
# Get from: https://console.firebase.google.com -> Project Settings -> General
|
||||
NUXT_PUBLIC_FIREBASE_API_KEY=your_firebase_api_key
|
||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
|
||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your_firebase_project_id
|
||||
NUXT_PUBLIC_FIREBASE_APP_ID=your_firebase_app_id
|
||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=your_firebase_sender_id
|
||||
|
||||
# Server-side Firebase API key (same as public, used for token verification)
|
||||
NUXT_FIREBASE_API_KEY=your_firebase_api_key
|
||||
|
||||
# Google Calendar API
|
||||
# Enable in Google Cloud Console
|
||||
GOOGLE_CLOUD_CLIENT_ID=your_google_cloud_client_id
|
||||
GOOGLE_CLOUD_CLIENT_SECRET=your_google_cloud_client_secret
|
||||
|
||||
# Database (PostgreSQL)
|
||||
# Production: postgresql://xflow:password@postgres:5432/xflow
|
||||
# Dev via SSH tunnel: postgresql://xflow:password@localhost:5432/xflow
|
||||
DATABASE_URL=postgresql://xflow:xflow_pass@localhost:5432/xflow
|
||||
|
||||
# Gitea Integration
|
||||
GITEA_URL=http://git.a-crm.controlsystem.kz
|
||||
GITEA_TOKEN=your_gitea_token
|
||||
GITEA_ORG=AVTEX
|
||||
GITEA_MINDMAP_REPO=MMAVTEX
|
||||
GITEA_PROJECT_REPO=Projects
|
||||
|
||||
# JWT Secret for session tokens
|
||||
NUXT_JWT_SECRET=your_random_secret_here
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { initAuth } = useAuth()
|
||||
initAuth()
|
||||
|
||||
useHead({
|
||||
title: 'XFlow',
|
||||
meta: [
|
||||
{ charset: 'utf-8' },
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' },
|
||||
{ name: 'theme-color', content: '#0f172a' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-100;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.vue-flow {
|
||||
@apply bg-slate-900;
|
||||
}
|
||||
|
||||
.vue-flow__node {
|
||||
@apply bg-slate-800 border border-slate-700 rounded-lg shadow-lg;
|
||||
}
|
||||
|
||||
.vue-flow__node.selected {
|
||||
@apply border-blue-500 ring-2 ring-blue-500/50;
|
||||
}
|
||||
|
||||
.vue-flow__edge-path {
|
||||
stroke: #475569;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.vue-flow__controls {
|
||||
@apply bg-slate-800 border-slate-700;
|
||||
}
|
||||
|
||||
.vue-flow__controls-button {
|
||||
@apply bg-slate-700 text-slate-200 hover:bg-slate-600;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionGroup
|
||||
name="achievement"
|
||||
tag="div"
|
||||
class="fixed bottom-4 right-4 z-50 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-for="ach in visibleAchievements"
|
||||
:key="ach.id"
|
||||
class="bg-slate-800 border border-yellow-500/50 rounded-xl p-4 shadow-2xl max-w-sm flex items-start gap-3"
|
||||
>
|
||||
<div class="text-3xl">{{ ach.icon || '🏆' }}</div>
|
||||
<div>
|
||||
<h4 class="font-bold text-yellow-400 text-sm">{{ ach.title }}</h4>
|
||||
<p class="text-xs text-slate-400 mt-0.5">{{ ach.description }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="dismiss(ach.id)"
|
||||
class="text-slate-500 hover:text-slate-300 ml-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
achievements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const visibleAchievements = ref([])
|
||||
|
||||
watch(() => props.achievements, (newAchievements) => {
|
||||
const incoming = newAchievements.filter(a => !visibleAchievements.value.find(v => v.id === a.id))
|
||||
incoming.forEach(ach => {
|
||||
visibleAchievements.value.push(ach)
|
||||
setTimeout(() => dismiss(ach.id), 5000)
|
||||
})
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const dismiss = (id) => {
|
||||
visibleAchievements.value = visibleAchievements.value.filter(a => a.id !== id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-enter-active,
|
||||
.achievement-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.achievement-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
|
||||
.achievement-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(100%) scale(0.9);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<nav class="md:hidden fixed bottom-0 left-0 right-0 bg-slate-900/80 backdrop-blur-xl border-t border-slate-800 pb-safe z-40">
|
||||
<div class="flex justify-around items-center h-16">
|
||||
<NuxtLink to="/" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">🏠</span>
|
||||
<span class="text-[10px] font-medium">Главная</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/canvases" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">🗺️</span>
|
||||
<span class="text-[10px] font-medium">Канвасы</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/tasks" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">✅</span>
|
||||
<span class="text-[10px] font-medium">Задачи</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink v-if="isAdmin" to="/admin" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-red-500">
|
||||
<span class="text-xl">🛡️</span>
|
||||
<span class="text-[10px] font-medium">Админ</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/settings" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">⚙️</span>
|
||||
<span class="text-[10px] font-medium">Опции</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink to="/profile" class="flex flex-col items-center gap-1 text-slate-400 transition-colors" active-class="text-blue-500">
|
||||
<span class="text-xl">👤</span>
|
||||
<span class="text-[10px] font-medium">Профиль</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const auth = useAuth()
|
||||
const isAdmin = computed(() => auth.user.value?.role === 'admin')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pb-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="h-screen w-full relative">
|
||||
<VueFlow
|
||||
v-model="elements"
|
||||
:default-zoom="0.8"
|
||||
:min-zoom="0.2"
|
||||
:max-zoom="2"
|
||||
fit-view-on-init
|
||||
@node-double-click="onNodeDoubleClick"
|
||||
>
|
||||
<Background pattern-color="#334155" :gap="20" />
|
||||
<Controls />
|
||||
<MiniMap pannable zoomable class="bg-slate-800 border-slate-700" />
|
||||
|
||||
<template #node-text="{ data, id }">
|
||||
<MarkdownNode
|
||||
:id="id"
|
||||
:data="data"
|
||||
@update-state="updateNodeState"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #node-file="{ data, id }">
|
||||
<MarkdownNode
|
||||
:id="id"
|
||||
:data="data"
|
||||
:is-file="true"
|
||||
@update-state="updateNodeState"
|
||||
/>
|
||||
</template>
|
||||
</VueFlow>
|
||||
|
||||
<div class="absolute top-4 left-4 z-10 bg-slate-800/90 backdrop-blur border border-slate-700 rounded-lg p-3">
|
||||
<h2 class="text-sm font-semibold text-slate-200">{{ canvasName }}</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">Double click to edit state</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VueFlow } from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import '@vue-flow/core/dist/style.css'
|
||||
import '@vue-flow/core/dist/theme-default.css'
|
||||
|
||||
const props = defineProps({
|
||||
canvasId: { type: String, default: 'demo' }
|
||||
})
|
||||
|
||||
const { loadCanvas, updateNodeState: apiUpdateNodeState } = useCanvas()
|
||||
const canvasName = computed(() => props.canvasId)
|
||||
const elements = ref([])
|
||||
|
||||
const transformToFlowElements = (canvasData) => {
|
||||
const nodes = (canvasData.nodes || []).map(node => ({
|
||||
id: node.id,
|
||||
type: node.type === 'file' ? 'file' : 'text',
|
||||
position: { x: node.x, y: node.y },
|
||||
data: {
|
||||
...node,
|
||||
width: node.width,
|
||||
height: node.height
|
||||
},
|
||||
style: {
|
||||
width: `${node.width}px`,
|
||||
height: `${node.height}px`,
|
||||
background: node.style?.background || '#1e293b',
|
||||
border: `1px solid ${node.style?.border || '#334155'}`,
|
||||
borderRadius: '8px'
|
||||
}
|
||||
}))
|
||||
|
||||
const edges = (canvasData.edges || []).map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.fromNode,
|
||||
target: edge.toNode,
|
||||
sourceHandle: edge.fromSide,
|
||||
targetHandle: edge.toSide,
|
||||
animated: true,
|
||||
style: { stroke: '#475569', strokeWidth: 2 }
|
||||
}))
|
||||
|
||||
return [...nodes, ...edges]
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
const data = await loadCanvas(props.canvasId)
|
||||
elements.value = transformToFlowElements(data)
|
||||
}
|
||||
|
||||
const updateNodeState = async ({ nodeId, state }) => {
|
||||
await apiUpdateNodeState(nodeId, state)
|
||||
await loadData()
|
||||
}
|
||||
|
||||
const onNodeDoubleClick = (event) => {
|
||||
const node = event.node
|
||||
const currentState = node.data?.state || 'pending'
|
||||
const nextState = currentState === 'pending' ? 'in_progress' : currentState === 'in_progress' ? 'completed' : 'pending'
|
||||
updateNodeState({ nodeId: node.id, state: nextState })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
:class="[
|
||||
'bg-slate-800 border rounded-lg shadow-lg p-4 text-sm font-sans flex flex-col h-full w-full',
|
||||
selected ? 'ring-2 ring-blue-500' : '',
|
||||
nodeStateClass
|
||||
]"
|
||||
:style="nodeStyle"
|
||||
>
|
||||
<!-- Header with icon and label -->
|
||||
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-slate-600">
|
||||
<span class="text-lg">{{ nodeIcon }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-white truncate">{{ data.label || getNodeTitle() }}</div>
|
||||
<div class="text-xs text-slate-400 uppercase tracking-wider">{{ data.type || type }}</div>
|
||||
</div>
|
||||
<div v-if="type === 'task' && data.state" :class="stateBadgeClass">
|
||||
{{ stateLabel }}
|
||||
</div>
|
||||
<div v-if="type === 'task' && data.priority" :class="priorityBadgeClass">
|
||||
{{ priorityLabel }}
|
||||
</div>
|
||||
<!-- Кнопка запуска для спец-узлов -->
|
||||
<button
|
||||
v-if="['ai', 'webhook'].includes(type)"
|
||||
@click.stop="handleExecute"
|
||||
:disabled="isExecuting"
|
||||
class="ml-auto p-1.5 hover:bg-slate-700 rounded-md transition-colors group relative"
|
||||
title="Запустить выполнение"
|
||||
>
|
||||
<span v-if="isExecuting" class="block animate-spin text-blue-400">🌀</span>
|
||||
<span v-else class="block group-hover:scale-110 transition-transform">▶️</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content preview for measurement -->
|
||||
<div class="text-slate-100 space-y-2 flex-1">
|
||||
<div v-if="data.text" class="text-slate-300 text-xs leading-relaxed whitespace-pre-wrap mb-3" v-html="formattedText"></div>
|
||||
<div v-if="data.file" class="flex items-center gap-2">
|
||||
<span class="text-sm">📄</span>
|
||||
<span class="font-mono text-sm text-blue-400">{{ data.file }}</span>
|
||||
</div>
|
||||
<div v-if="data.prompt" class="text-sm text-purple-300">
|
||||
<div class="font-medium mb-1">AI Prompt:</div>
|
||||
<div>{{ data.prompt }}</div>
|
||||
</div>
|
||||
<div v-if="data.url" class="flex items-center gap-2">
|
||||
<span class="text-sm">🔗</span>
|
||||
<a :href="data.url" target="_blank" class="text-blue-400 hover:text-blue-300 text-sm break-all">{{ data.url }}</a>
|
||||
</div>
|
||||
<div v-if="data.script_id" class="flex items-center gap-2">
|
||||
<span class="text-sm">🎭</span>
|
||||
<span class="font-mono text-sm text-green-400">{{ data.script_id }}</span>
|
||||
</div>
|
||||
<div v-if="data.assigned_to" class="flex items-center gap-2">
|
||||
<span class="text-sm">👤</span>
|
||||
<span class="text-sm text-slate-300">{{ data.assigned_to }}</span>
|
||||
</div>
|
||||
<div v-if="data.alarm" class="flex items-center gap-2">
|
||||
<span class="text-sm">⏰</span>
|
||||
<span class="text-sm text-orange-400">{{ formatAlarm(data.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection handles -->
|
||||
<Handle type="target" :position="Position.Left" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
||||
<Handle type="source" :position="Position.Right" class="w-3 h-3 bg-slate-600 border-2 border-slate-700" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||
import { computed, ref, onMounted, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
type: { type: String, default: 'text' },
|
||||
selected: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'execute'])
|
||||
const isExecuting = ref(false)
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (isExecuting.value) return
|
||||
isExecuting.value = true
|
||||
try {
|
||||
emit('execute', { id: props.id, type: props.type })
|
||||
} finally {
|
||||
// Состояние загрузки сбрасывается внешним событием или по таймауту для безопасности
|
||||
setTimeout(() => { isExecuting.value = false }, 10000)
|
||||
}
|
||||
}
|
||||
|
||||
// Root element ref
|
||||
const rootEl = ref(null)
|
||||
|
||||
// Access Vue Flow API to update node dimensions
|
||||
const { updateNode } = useVueFlow()
|
||||
|
||||
// Measure and apply real height to Vue Flow node wrapper
|
||||
const applyNodeSize = () => {
|
||||
if (!rootEl.value) return
|
||||
const h = rootEl.value.scrollHeight
|
||||
if (h > 0) {
|
||||
updateNode(props.id, {
|
||||
style: { height: h + 'px' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => setTimeout(applyNodeSize, 50))
|
||||
setTimeout(applyNodeSize, 200)
|
||||
})
|
||||
|
||||
// Node style: fixed width from data, height auto to fit content
|
||||
const nodeStyle = computed(() => {
|
||||
const baseStyle = {
|
||||
backgroundColor: props.data.style?.background || '#1e293b',
|
||||
borderColor: props.data.style?.border || '#475569',
|
||||
width: `${props.data.width || 320}px`
|
||||
}
|
||||
|
||||
if (props.data.state === 'completed') {
|
||||
baseStyle.borderColor = '#10b981' // Green
|
||||
} else if (props.data.state === 'in_progress') {
|
||||
baseStyle.borderColor = '#3b82f6' // Blue
|
||||
} else if (props.data.state === 'review') {
|
||||
baseStyle.borderColor = '#a855f7' // Purple
|
||||
} else if (props.data.state === 'blocked') {
|
||||
baseStyle.borderColor = '#ef4444' // Red
|
||||
} else if (props.data.state === 'todo') {
|
||||
baseStyle.borderColor = '#64748b' // Slate
|
||||
}
|
||||
|
||||
return baseStyle
|
||||
})
|
||||
|
||||
// Node type icon
|
||||
const nodeIcon = computed(() => {
|
||||
const icons = {
|
||||
text: '📝',
|
||||
file: '📄',
|
||||
task: '✅',
|
||||
ai: '🤖',
|
||||
webhook: '🔗',
|
||||
git: '🔄'
|
||||
}
|
||||
return icons[props.type] || '📝'
|
||||
})
|
||||
|
||||
// State handling
|
||||
const nodeStateClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'border-emerald-600 bg-emerald-950/20'
|
||||
if (state === 'in_progress') return 'border-blue-600 bg-blue-950/20'
|
||||
if (state === 'review') return 'border-purple-600 bg-purple-950/20'
|
||||
if (state === 'blocked') return 'border-red-600 bg-red-950/20'
|
||||
if (state === 'todo') return 'border-slate-500 bg-slate-900/50'
|
||||
return ''
|
||||
})
|
||||
|
||||
const stateBadgeClass = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return 'px-1.5 py-0.5 bg-emerald-800/50 text-emerald-300 rounded text-[10px] font-medium'
|
||||
if (state === 'in_progress') return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium'
|
||||
if (state === 'review') return 'px-1.5 py-0.5 bg-purple-800/50 text-purple-300 rounded text-[10px] font-medium'
|
||||
if (state === 'blocked') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-slate-700 text-slate-300 rounded text-[10px] font-medium'
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
const state = props.data.state
|
||||
if (state === 'completed') return '✅ Done'
|
||||
if (state === 'in_progress') return '⏳ In Progress'
|
||||
if (state === 'review') return '👀 Review'
|
||||
if (state === 'blocked') return '⛔ Blocked'
|
||||
return '📋 To Do'
|
||||
})
|
||||
|
||||
// Priority handling
|
||||
const priorityBadgeClass = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return 'px-1.5 py-0.5 bg-red-800/50 text-red-300 rounded text-[10px] font-bold'
|
||||
if (prio === 'low') return 'px-1.5 py-0.5 bg-slate-800/50 text-slate-400 rounded text-[10px] font-medium'
|
||||
return 'px-1.5 py-0.5 bg-blue-800/50 text-blue-300 rounded text-[10px] font-medium' // medium
|
||||
})
|
||||
|
||||
const priorityLabel = computed(() => {
|
||||
const prio = props.data.priority
|
||||
if (prio === 'high') return '🔥 High'
|
||||
if (prio === 'low') return '🧊 Low'
|
||||
return '⚡ Med'
|
||||
})
|
||||
|
||||
// Get node title from text content
|
||||
const getNodeTitle = () => {
|
||||
if (props.data.text) {
|
||||
const lines = props.data.text.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('# ')) {
|
||||
return trimmed.replace('# ', '')
|
||||
}
|
||||
}
|
||||
// If no h1 found, use first non-empty line
|
||||
const firstLine = lines.find(l => l.trim())
|
||||
return firstLine ? firstLine.trim().slice(0, 30) : 'Node'
|
||||
}
|
||||
if (props.data.file) return props.data.file
|
||||
return 'Node'
|
||||
}
|
||||
|
||||
// Format text with markdown support
|
||||
const formattedText = computed(() => {
|
||||
if (!props.data.text) return ''
|
||||
|
||||
let text = props.data.text
|
||||
|
||||
// Convert markdown headers
|
||||
text = text.replace(/^# (.*$)/gim, '<h1 class="text-lg font-bold text-white mb-2">$1</h1>')
|
||||
text = text.replace(/^## (.*$)/gim, '<h2 class="text-base font-semibold text-white mb-2">$1</h2>')
|
||||
text = text.replace(/^### (.*$)/gim, '<h3 class="text-sm font-medium text-white mb-1">$1</h3>')
|
||||
|
||||
// Convert bold text
|
||||
text = text.replace(/\*\*(.*?)\*\*/g, '<strong class="font-semibold text-white">$1</strong>')
|
||||
|
||||
// Convert italic text
|
||||
text = text.replace(/\*(.*?)\*/g, '<em class="italic text-slate-200">$1</em>')
|
||||
|
||||
// Convert code blocks
|
||||
text = text.replace(/```(.*?)```/gs, '<pre class="bg-slate-900 p-2 rounded text-xs font-mono text-slate-300 mb-2 overflow-x-auto"><code>$1</code></pre>')
|
||||
|
||||
// Convert inline code
|
||||
text = text.replace(/`(.*?)`/g, '<code class="bg-slate-700 px-1 rounded text-xs font-mono text-slate-300">$1</code>')
|
||||
|
||||
// Convert lists
|
||||
text = text.replace(/^- (.*$)/gim, '<li class="ml-4 text-slate-200">• $1</li>')
|
||||
text = text.replace(/^ - (.*$)/gim, '<li class="ml-8 text-slate-300">◦ $1</li>')
|
||||
|
||||
// Convert line breaks
|
||||
text = text.replace(/\n\n/g, '</p><p class="mb-2 text-slate-200">')
|
||||
text = text.replace(/\n/g, '<br>')
|
||||
|
||||
// Wrap in paragraphs
|
||||
if (!text.includes('<p>')) {
|
||||
text = '<p class="mb-2 text-slate-200">' + text + '</p>'
|
||||
}
|
||||
|
||||
return text
|
||||
})
|
||||
|
||||
// Format alarm time
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
const date = new Date(alarm)
|
||||
return date.toLocaleDateString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div v-if="deferredPrompt && !isStandalone" class="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
@click="installApp"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-3 rounded-2xl font-bold shadow-2xl flex items-center gap-3 transition-all transform hover:scale-105 active:scale-95 group"
|
||||
>
|
||||
<span class="text-xl group-hover:rotate-12 transition-transform">📲</span>
|
||||
<span>Установить XFlow</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const deferredPrompt = ref(null)
|
||||
const isStandalone = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt.value = e
|
||||
})
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
deferredPrompt.value = null
|
||||
})
|
||||
})
|
||||
|
||||
const installApp = async () => {
|
||||
if (!deferredPrompt.value) {
|
||||
alert('Браузер еще не подготовил установку. Пожалуйста, подождите 10-20 секунд или проверьте вкладку Application в консоли.')
|
||||
return
|
||||
}
|
||||
|
||||
deferredPrompt.value.prompt()
|
||||
const { outcome } = await deferredPrompt.value.userChoice
|
||||
console.log(`PWA: user choice outcome: ${outcome}`)
|
||||
deferredPrompt.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes bounce-subtle {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-5px); }
|
||||
}
|
||||
.animate-bounce-subtle {
|
||||
animation: bounce-subtle 3s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div
|
||||
class="w-full h-full overflow-auto p-3 rounded-lg cursor-pointer"
|
||||
:class="stateClass"
|
||||
@click="$emit('update-state', { nodeId: id, state: nextState })"
|
||||
>
|
||||
<div v-if="data.frontmatter?.alarm" class="text-xs text-yellow-400 mb-1 flex items-center gap-1">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatAlarm(data.frontmatter.alarm) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.frontmatter?.assigned_to" class="text-xs text-slate-400 mb-1">
|
||||
👤 {{ data.frontmatter.assigned_to }}
|
||||
</div>
|
||||
|
||||
<div v-if="data.text" class="prose prose-invert prose-sm max-w-none" v-html="renderedHtml" />
|
||||
|
||||
<div v-else-if="data.file" class="text-sm text-blue-400">
|
||||
📄 {{ data.file }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full"
|
||||
:class="badgeClass"
|
||||
>
|
||||
{{ stateLabel }}
|
||||
</span>
|
||||
<span v-if="data.frontmatter?.script_id" class="text-xs text-purple-400">
|
||||
⚡ {{ data.frontmatter.script_id }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
id: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
isFile: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-state'])
|
||||
|
||||
const { renderMarkdown } = useCanvas()
|
||||
|
||||
const renderedHtml = computed(() => {
|
||||
return renderMarkdown(props.data.text || '')
|
||||
})
|
||||
|
||||
const currentState = computed(() => props.data.state || 'pending')
|
||||
|
||||
const nextState = computed(() => {
|
||||
const s = currentState.value
|
||||
return s === 'pending' ? 'in_progress' : s === 'in_progress' ? 'completed' : 'pending'
|
||||
})
|
||||
|
||||
const stateClass = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return 'bg-emerald-900/40 border-emerald-700'
|
||||
case 'in_progress': return 'bg-blue-900/40 border-blue-700'
|
||||
default: return 'bg-slate-800 border-slate-700'
|
||||
}
|
||||
})
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return 'bg-emerald-700 text-emerald-100'
|
||||
case 'in_progress': return 'bg-blue-700 text-blue-100'
|
||||
default: return 'bg-slate-700 text-slate-300'
|
||||
}
|
||||
})
|
||||
|
||||
const stateLabel = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case 'completed': return '✅ Выполнено'
|
||||
case 'in_progress': return '🔧 В работе'
|
||||
default: return '📋 Ожидает'
|
||||
}
|
||||
})
|
||||
|
||||
const formatAlarm = (alarm) => {
|
||||
try {
|
||||
return new Date(alarm).toLocaleString('ru-RU')
|
||||
} catch {
|
||||
return alarm
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 backdrop-blur-sm p-4 font-sans">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
|
||||
<!-- Modal Header -->
|
||||
<div class="px-6 py-4 border-b border-slate-800 flex justify-between items-center bg-slate-900/50">
|
||||
<h2 class="text-base font-bold text-slate-200 flex items-center gap-2">
|
||||
<span>🧙♂️</span> Коуч-Ассистент: Конструктор SMART & GROW
|
||||
</h2>
|
||||
<button @click="$emit('close')" class="text-slate-400 hover:text-white transition">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-800 bg-slate-950/40">
|
||||
<button
|
||||
@click="wizardType = 'smart'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'smart' ? 'text-indigo-400 border-indigo-500 bg-indigo-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
🎯 SMART Цель
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'grow'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'grow' ? 'text-teal-400 border-teal-500 bg-teal-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
🌱 GROW Сессия
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'dilts'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'dilts' ? 'text-violet-400 border-violet-500 bg-violet-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
📐 Пирамида Дилтса
|
||||
</button>
|
||||
<button
|
||||
@click="wizardType = 'eisenhower'"
|
||||
:class="['flex-1 py-3 text-xs font-bold uppercase tracking-wider transition border-b-2', wizardType === 'eisenhower' ? 'text-amber-400 border-amber-500 bg-amber-950/10' : 'text-slate-400 border-transparent hover:bg-slate-800/40']"
|
||||
>
|
||||
📊 Матрица Эйзенхауэра
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body (Forms) -->
|
||||
<div class="p-6 overflow-y-auto flex-1 space-y-4">
|
||||
<!-- S.M.A.R.T Form -->
|
||||
<div v-if="wizardType === 'smart'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">S (Specific) — Какая конкретная задача?</label>
|
||||
<textarea v-model="smartForm.specific" rows="2" placeholder="Что именно вы хотите сделать? (например: Разработать модуль экспорта canvas)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">M (Measurable) — Как вы измерите успех?</label>
|
||||
<textarea v-model="smartForm.measurable" rows="2" placeholder="Укажите критерии готовности (каждую метрику с новой строки)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">A (Achievable) — За счет чего цель достижима?</label>
|
||||
<textarea v-model="smartForm.achievable" rows="2" placeholder="Какими ресурсами и навыками вы располагаете?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">R (Relevant) — Почему эта цель важна?</label>
|
||||
<textarea v-model="smartForm.relevant" rows="2" placeholder="Какую пользу принесет выполнение этой цели общему проекту?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">T (Time-bound) — Каков дедлайн?</label>
|
||||
<input v-model="smartForm.deadline" type="datetime-local" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- G.R.O.W Form -->
|
||||
<div v-if="wizardType === 'grow'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">G (Goal) — Цель</label>
|
||||
<textarea v-model="growForm.goal" rows="3" placeholder="Какой именно цели я хочу достичь? Какой результат я хочу получить? Почему достижение этой цели важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">O (Options) — Варианты</label>
|
||||
<textarea v-model="growForm.options" rows="4" placeholder="Что именно я могу изменить? Какие ресурсы мне нужны? Кто мне может помочь? А что еще?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">R (Reality) — Реальность</label>
|
||||
<textarea v-model="growForm.reality" rows="4" placeholder="Какова текущая ситуация? Какие препятствия я вижу? Что мне мешает начать? Какие у меня уже есть ресурсы?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">W (Way Forward) — План</label>
|
||||
<textarea v-model="growForm.wayForward" rows="3" placeholder="Какие самые важные действия необходимо предпринять? Что я сделаю в первую очередь? Какие сроки?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-teal-500 focus:ring-1 focus:ring-teal-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dilts Form -->
|
||||
<div v-if="wizardType === 'dilts'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🌟 Миссия — Ради кого? Кому еще это важно? (БЫТЬ)</label>
|
||||
<textarea v-model="diltsForm.mission" rows="2" placeholder="Ради кого? Кому еще это важно? (БЫТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">👤 Я — Кто я?</label>
|
||||
<textarea v-model="diltsForm.identity" rows="2" placeholder="Кто я?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">💎 Ценности — Почему это важно?</label>
|
||||
<textarea v-model="diltsForm.values" rows="2" placeholder="Почему это важно?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🧠 Навыки — Как? Каким образом?</label>
|
||||
<textarea v-model="diltsForm.skills" rows="2" placeholder="Как? Каким образом?" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🎬 Действия — Что делать? (ДЕЛАТЬ)</label>
|
||||
<textarea v-model="diltsForm.actions" rows="2" placeholder="Что делать? (ДЕЛАТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🌍 Окружение — Где? Когда? С кем? (ИМЕТЬ)</label>
|
||||
<textarea v-model="diltsForm.environment" rows="2" placeholder="Где? Когда? С кем? (ИМЕТЬ)" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Eisenhower Form -->
|
||||
<div v-if="wizardType === 'eisenhower'" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🔥 Квадрант I: Важно и Срочно (Сделать сейчас)</label>
|
||||
<textarea v-model="eisenhowerForm.q1" rows="2" placeholder="Кризисы, горящие дедлайны, важные неотложные задачи" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">⚡ Квадрант II: Важно, но Несрочно (Запланировать)</label>
|
||||
<textarea v-model="eisenhowerForm.q2" rows="2" placeholder="Долгосрочное планирование, здоровье, обучение, стратегические цели" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">🧊 Квадрант III: Неважно, но Срочно (Делегировать)</label>
|
||||
<textarea v-model="eisenhowerForm.q3" rows="2" placeholder="Входящие рутинные звонки/письма, мелкие поручения, задачи, которые можно перепоручить" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-400 block mb-1">💤 Квадрант IV: Неважно и Несрочно (Отложить/Удалить)</label>
|
||||
<textarea v-model="eisenhowerForm.q4" rows="2" placeholder="Пожиратели времени, мелкие развлечения, неважная рутина" class="w-full bg-slate-950 border border-slate-800 rounded-lg p-2.5 text-xs text-slate-200 focus:border-amber-500 focus:ring-1 focus:ring-amber-500 outline-none resize-none"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="px-6 py-4 border-t border-slate-800 bg-slate-900/50 flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="px-4 py-2 border border-slate-700 hover:bg-slate-800 text-slate-300 rounded-lg text-xs transition">
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
@click="submitManual"
|
||||
:disabled="isSubmitting"
|
||||
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 border border-slate-700 text-white rounded-lg text-xs transition"
|
||||
>
|
||||
Создать вручную
|
||||
</button>
|
||||
<button
|
||||
@click="submitAI"
|
||||
:disabled="isSubmitting"
|
||||
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-xs font-bold transition flex items-center gap-1.5"
|
||||
>
|
||||
<span v-if="isSubmitting" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>✨</span>
|
||||
Оптимизировать через AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
isSubmitting: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'close',
|
||||
'submit-smart-manual',
|
||||
'submit-smart-ai',
|
||||
'submit-grow-manual',
|
||||
'submit-grow-ai',
|
||||
'submit-dilts-manual',
|
||||
'submit-dilts-ai',
|
||||
'submit-eisenhower-manual',
|
||||
'submit-eisenhower-ai'
|
||||
])
|
||||
|
||||
const wizardType = ref('smart')
|
||||
|
||||
const smartForm = ref({
|
||||
specific: '',
|
||||
measurable: '',
|
||||
achievable: '',
|
||||
relevant: '',
|
||||
deadline: ''
|
||||
})
|
||||
|
||||
const growForm = ref({
|
||||
goal: '',
|
||||
reality: '',
|
||||
options: '',
|
||||
wayForward: ''
|
||||
})
|
||||
|
||||
const diltsForm = ref({
|
||||
mission: '',
|
||||
identity: '',
|
||||
values: '',
|
||||
skills: '',
|
||||
actions: '',
|
||||
environment: ''
|
||||
})
|
||||
|
||||
const eisenhowerForm = ref({
|
||||
q1: '',
|
||||
q2: '',
|
||||
q3: '',
|
||||
q4: ''
|
||||
})
|
||||
|
||||
const resetForms = () => {
|
||||
smartForm.value = { specific: '', measurable: '', achievable: '', relevant: '', deadline: '' }
|
||||
growForm.value = { goal: '', reality: '', options: '', wayForward: '' }
|
||||
diltsForm.value = { mission: '', identity: '', values: '', skills: '', actions: '', environment: '' }
|
||||
eisenhowerForm.value = { q1: '', q2: '', q3: '', q4: '' }
|
||||
}
|
||||
|
||||
const submitManual = () => {
|
||||
if (wizardType.value === 'smart') {
|
||||
emit('submit-smart-manual', { ...smartForm.value })
|
||||
} else if (wizardType.value === 'grow') {
|
||||
emit('submit-grow-manual', { ...growForm.value })
|
||||
} else if (wizardType.value === 'dilts') {
|
||||
emit('submit-dilts-manual', { ...diltsForm.value })
|
||||
} else if (wizardType.value === 'eisenhower') {
|
||||
emit('submit-eisenhower-manual', { ...eisenhowerForm.value })
|
||||
}
|
||||
resetForms()
|
||||
}
|
||||
|
||||
const submitAI = () => {
|
||||
if (wizardType.value === 'smart') {
|
||||
if (!smartForm.value.specific.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы описание цели!')
|
||||
return
|
||||
}
|
||||
emit('submit-smart-ai', { ...smartForm.value })
|
||||
} else if (wizardType.value === 'grow') {
|
||||
if (!growForm.value.goal.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы цель (Goal)!')
|
||||
return
|
||||
}
|
||||
emit('submit-grow-ai', { ...growForm.value })
|
||||
} else if (wizardType.value === 'dilts') {
|
||||
if (!diltsForm.value.mission.trim()) {
|
||||
alert('Пожалуйста, введите хотя бы описание миссии!')
|
||||
return
|
||||
}
|
||||
emit('submit-dilts-ai', { ...diltsForm.value })
|
||||
} else if (wizardType.value === 'eisenhower') {
|
||||
if (!eisenhowerForm.value.q1.trim() && !eisenhowerForm.value.q2.trim() && !eisenhowerForm.value.q3.trim() && !eisenhowerForm.value.q4.trim()) {
|
||||
alert('Пожалуйста, введите задачи хотя бы в один из квадрантов!')
|
||||
return
|
||||
}
|
||||
emit('submit-eisenhower-ai', { ...eisenhowerForm.value })
|
||||
}
|
||||
}
|
||||
|
||||
// Expose reset in case parent wants to reset from outside
|
||||
defineExpose({ resetForms })
|
||||
</script>
|
||||
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed bottom-0 left-0 right-0 z-30 transition-all duration-300 ease-in-out font-sans flex flex-col"
|
||||
:class="[isOpen ? 'h-80' : 'h-10']"
|
||||
>
|
||||
<!-- Handle / Toggle Bar -->
|
||||
<div
|
||||
@click="isOpen = !isOpen"
|
||||
class="h-10 bg-slate-900 border-t border-slate-700/80 hover:bg-slate-800 cursor-pointer flex items-center px-4 justify-between select-none transition"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-slate-200 font-bold text-xs">
|
||||
<span>📅</span>
|
||||
<span>Диаграмма Ганта</span>
|
||||
<span class="px-1.5 py-0.5 bg-slate-800 text-[10px] text-slate-400 rounded-full border border-slate-700 font-medium">
|
||||
Задач: {{ tasks.length }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-slate-400 text-xs">
|
||||
{{ isOpen ? '▼ Свернуть' : '▲ Развернуть' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Body -->
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="flex-1 bg-slate-950/95 backdrop-blur-md border-t border-slate-800 flex overflow-hidden min-w-0"
|
||||
>
|
||||
<!-- Task Titles Sidebar (Left) -->
|
||||
<div class="w-64 border-r border-slate-800 flex flex-col shrink-0">
|
||||
<div class="h-8 border-b border-slate-800 bg-slate-900/40 px-3 flex items-center text-[10px] font-bold text-slate-500 uppercase tracking-wider">
|
||||
Задача
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto divide-y divide-slate-900/60 scrollbar-thin">
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
@click="$emit('select-node', task.id)"
|
||||
class="h-10 px-3 flex items-center text-xs text-slate-300 hover:text-white hover:bg-slate-800/30 cursor-pointer transition select-none truncate font-medium"
|
||||
title="Кликните для фокусировки на холсте"
|
||||
>
|
||||
<span class="mr-1.5">{{ getStatusIcon(task.data.state) }}</span>
|
||||
<span class="truncate">{{ task.data.label || 'Без названия' }}</span>
|
||||
</div>
|
||||
<div v-if="tasks.length === 0" class="p-6 text-center text-xs text-slate-600">
|
||||
Нет задач на холсте
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Chart (Right) -->
|
||||
<div class="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
<!-- Date Columns Header -->
|
||||
<div
|
||||
class="h-8 border-b border-slate-800 bg-slate-900/40 flex overflow-x-auto overflow-y-hidden select-none scrollbar-none"
|
||||
ref="timelineHeader"
|
||||
@scroll="syncScrollLeft"
|
||||
>
|
||||
<div
|
||||
v-for="(day, idx) in timelineDays"
|
||||
:key="idx"
|
||||
class="h-full border-r border-slate-900/40 shrink-0 flex flex-col items-center justify-center text-[9px] font-semibold text-slate-400"
|
||||
:style="{ width: `${dayWidth}px` }"
|
||||
>
|
||||
<span>{{ day.dayOfWeek }}</span>
|
||||
<span class="text-slate-500 font-normal">{{ day.dateStr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Bars Body -->
|
||||
<div
|
||||
class="flex-1 overflow-auto divide-y divide-slate-900/60 scrollbar-thin"
|
||||
ref="timelineBody"
|
||||
@scroll="syncScrollRight"
|
||||
>
|
||||
<div
|
||||
v-for="task in tasks"
|
||||
:key="task.id"
|
||||
class="h-10 relative flex items-center bg-slate-950/20 hover:bg-slate-900/30 transition group"
|
||||
>
|
||||
<!-- Timeline Grid Background Lines -->
|
||||
<div class="absolute inset-0 flex pointer-events-none">
|
||||
<div
|
||||
v-for="idx in timelineDays.length"
|
||||
:key="idx"
|
||||
class="h-full border-r border-slate-900/40 shrink-0"
|
||||
:style="{ width: `${dayWidth}px` }"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Task Gantt Bar -->
|
||||
<div
|
||||
v-if="hasValidDates(task)"
|
||||
@click="$emit('select-node', task.id)"
|
||||
class="absolute h-5 rounded-md border text-[10px] font-bold text-white px-2 flex items-center truncate cursor-pointer transition shadow-md hover:scale-[1.01] z-10"
|
||||
:class="getBarClasses(task)"
|
||||
:style="getBarStyles(task)"
|
||||
>
|
||||
<span class="truncate">{{ task.data.label }}</span>
|
||||
<!-- Tooltip on hover -->
|
||||
<div class="absolute bottom-6 left-0 hidden group-hover:block bg-slate-900 border border-slate-700 text-[10px] rounded p-2 text-slate-300 w-48 shadow-xl pointer-events-none whitespace-normal leading-relaxed z-20">
|
||||
<div class="font-bold text-white truncate mb-1">{{ task.data.label }}</div>
|
||||
<div>Статус: <span class="font-medium text-slate-200">{{ getStatusLabel(task.data.state) }}</span></div>
|
||||
<div>Приоритет: <span class="font-medium text-slate-200">{{ getPriorityLabel(task.data.priority) }}</span></div>
|
||||
<div v-if="task.data.assigned_to">Исполнитель: <span class="font-medium text-slate-200">{{ task.data.assigned_to }}</span></div>
|
||||
<div class="text-[9px] text-slate-500 mt-1 border-t border-slate-800 pt-1">
|
||||
С: {{ formatDate(task.data.start_date) }}<br/>
|
||||
По: {{ formatDate(task.data.alarm) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tasks.length === 0" class="h-full flex items-center justify-center text-xs text-slate-600">
|
||||
Создайте задачи и задайте им даты начала/дедлайны
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
elements: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
defineEmits(['select-node'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dayWidth = 60 // Width of one day column in pixels
|
||||
|
||||
const timelineHeader = ref(null)
|
||||
const timelineBody = ref(null)
|
||||
|
||||
// Scroll synchronization between header and body
|
||||
const syncScrollLeft = (e) => {
|
||||
if (timelineBody.value) {
|
||||
timelineBody.value.scrollLeft = e.target.scrollLeft
|
||||
}
|
||||
}
|
||||
const syncScrollRight = (e) => {
|
||||
if (timelineHeader.value) {
|
||||
timelineHeader.value.scrollLeft = e.target.scrollLeft
|
||||
}
|
||||
}
|
||||
|
||||
// Filter tasks
|
||||
const tasks = computed(() => {
|
||||
return props.elements.filter(el => el.type === 'task')
|
||||
})
|
||||
|
||||
// Calculate overall min/max dates
|
||||
const dateRange = computed(() => {
|
||||
let minDate = new Date()
|
||||
let maxDate = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000) // Default 14 days from now
|
||||
|
||||
const taskDates = []
|
||||
tasks.value.forEach(t => {
|
||||
if (t.data.start_date) taskDates.push(new Date(t.data.start_date))
|
||||
if (t.data.alarm) taskDates.push(new Date(t.data.alarm))
|
||||
})
|
||||
|
||||
if (taskDates.length > 0) {
|
||||
minDate = new Date(Math.min(...taskDates))
|
||||
maxDate = new Date(Math.max(...taskDates))
|
||||
}
|
||||
|
||||
// Add 2 days padding to sides
|
||||
minDate.setDate(minDate.getDate() - 2)
|
||||
maxDate.setDate(maxDate.getDate() + 5)
|
||||
|
||||
// Cap timeline length at 30 days to prevent rendering overflow
|
||||
const diffTime = Math.abs(maxDate - minDate)
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
if (diffDays > 30) {
|
||||
maxDate = new Date(minDate.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
|
||||
// Reset to midnight
|
||||
minDate.setHours(0, 0, 0, 0)
|
||||
maxDate.setHours(23, 59, 59, 999)
|
||||
|
||||
return { minDate, maxDate }
|
||||
})
|
||||
|
||||
// Generate list of days for timeline header
|
||||
const timelineDays = computed(() => {
|
||||
const days = []
|
||||
const current = new Date(dateRange.value.minDate)
|
||||
const end = dateRange.value.maxDate
|
||||
const ruDays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']
|
||||
|
||||
while (current <= end) {
|
||||
days.push({
|
||||
date: new Date(current),
|
||||
dateStr: `${current.getDate().toString().padStart(2, '0')}.${(current.getMonth() + 1).toString().padStart(2, '0')}`,
|
||||
dayOfWeek: ruDays[current.getDay()]
|
||||
})
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return days
|
||||
})
|
||||
|
||||
// Check if task has valid start and end date
|
||||
const hasValidDates = (task) => {
|
||||
return !!(task.data.start_date || task.data.alarm)
|
||||
}
|
||||
|
||||
// Format single date for tooltips
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return 'Не указано'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
// Calculate absolute left offset and width in percent or pixels for Gantt Bar
|
||||
const getBarStyles = (task) => {
|
||||
const minTime = dateRange.value.minDate.getTime()
|
||||
const maxTime = dateRange.value.maxDate.getTime()
|
||||
const totalDuration = maxTime - minTime
|
||||
|
||||
let start = task.data.start_date ? new Date(task.data.start_date).getTime() : Date.now()
|
||||
let end = task.data.alarm ? new Date(task.data.alarm).getTime() : start + 2 * 24 * 60 * 60 * 1000 // 2 days default
|
||||
|
||||
if (start > end) {
|
||||
// Correct order if user reversed dates
|
||||
const tmp = start
|
||||
start = end
|
||||
end = tmp
|
||||
}
|
||||
|
||||
// Boundary checks
|
||||
start = Math.max(start, minTime)
|
||||
end = Math.min(end, maxTime)
|
||||
|
||||
const leftPercent = ((start - minTime) / totalDuration) * 100
|
||||
const widthPercent = ((end - start) / totalDuration) * 100
|
||||
|
||||
// Calculate actual pixel offsets based on timeline width
|
||||
const totalDays = timelineDays.value.length
|
||||
const totalPixels = totalDays * dayWidth
|
||||
|
||||
const leftPx = (leftPercent / 100) * totalPixels
|
||||
const widthPx = Math.max((widthPercent / 100) * totalPixels, 20) // min 20px width
|
||||
|
||||
return {
|
||||
left: `${leftPx}px`,
|
||||
width: `${widthPx}px`
|
||||
}
|
||||
}
|
||||
|
||||
// Style colors based on task status
|
||||
const getBarClasses = (task) => {
|
||||
const state = task.data.state || 'todo'
|
||||
switch (state) {
|
||||
case 'completed':
|
||||
return 'bg-emerald-600/30 border-emerald-500 hover:bg-emerald-500/40 text-emerald-300'
|
||||
case 'in_progress':
|
||||
return 'bg-amber-600/30 border-amber-500 hover:bg-amber-500/40 text-amber-300'
|
||||
case 'review':
|
||||
return 'bg-indigo-600/30 border-indigo-500 hover:bg-indigo-500/40 text-indigo-300'
|
||||
case 'blocked':
|
||||
return 'bg-red-600/30 border-red-500 hover:bg-red-500/40 text-red-300'
|
||||
default:
|
||||
return 'bg-blue-600/30 border-blue-500 hover:bg-blue-500/40 text-blue-300'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (state) => {
|
||||
switch (state) {
|
||||
case 'completed': return '✅'
|
||||
case 'in_progress': return '⏳'
|
||||
case 'review': return '👀'
|
||||
case 'blocked': return '⛔'
|
||||
default: return '📋'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusLabel = (state) => {
|
||||
switch (state) {
|
||||
case 'completed': return 'Завершено'
|
||||
case 'in_progress': return 'В работе'
|
||||
case 'review': return 'На проверке'
|
||||
case 'blocked': return 'Заблокировано'
|
||||
default: return 'К выполнению'
|
||||
}
|
||||
}
|
||||
|
||||
const getPriorityLabel = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'Высокий'
|
||||
case 'low': return 'Низкий'
|
||||
default: return 'Средний'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background: #334155;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-if="userRole !== 'viewer'" class="w-56 bg-slate-900 border-r border-slate-700 flex flex-col shrink-0 z-20 font-sans">
|
||||
<div class="p-3 border-b border-slate-700">
|
||||
<h3 class="text-sm font-bold text-slate-200">Узлы</h3>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
<div v-for="nt in nodeTypes" :key="nt.type" draggable="true" @dragstart="onDragStart($event, nt)"
|
||||
class="p-2 bg-slate-800 rounded cursor-move hover:bg-slate-700 border border-slate-600 text-xs select-none">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ nt.icon }}</span>
|
||||
<div>
|
||||
<div class="font-medium text-slate-200">{{ nt.name }}</div>
|
||||
<div class="text-[10px] text-slate-500">{{ nt.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userRole: { type: String, default: 'viewer' }
|
||||
})
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝', description: 'Markdown заметка' },
|
||||
{ type: 'file', name: 'Файл', icon: '📄', description: 'Ссылка на файл' },
|
||||
{ type: 'task', name: 'Задача', icon: '✅', description: 'Задача с состоянием' },
|
||||
{ type: 'ai', name: 'AI Узел', icon: '🤖', description: 'LLM запрос' },
|
||||
{ type: 'webhook', name: 'Webhook', icon: '🔗', description: 'HTTP триггер' },
|
||||
{ type: 'git', name: 'Git Синхр', icon: '🔄', description: 'Git синхронизация' }
|
||||
]
|
||||
|
||||
const onDragStart = (event, nodeType) => {
|
||||
event.dataTransfer.setData('application/vueflow', JSON.stringify(nodeType))
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="w-60 bg-slate-900 border-l border-slate-700 flex flex-col shrink-0 z-20 font-sans">
|
||||
<div class="p-3 border-b border-slate-700">
|
||||
<h3 class="text-sm font-bold text-slate-300">Свойства</h3>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div v-if="node" class="space-y-3">
|
||||
<!-- SMART & GROW AI Coaching tools -->
|
||||
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-indigo-500/30 rounded-lg space-y-2 mb-2">
|
||||
<div class="text-[10px] uppercase font-bold text-indigo-400 flex items-center gap-1">
|
||||
<span>✨</span> AI Коуч-Ассистент
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
@click="$emit('apply-smart-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Преобразовать цель в формат SMART при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'smart'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>🎯</span>
|
||||
<span>SMART</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-grow-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-teal-650 hover:bg-teal-600 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Построить цепочку GROW для проблемы при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'grow'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>🌱</span>
|
||||
<span>GROW</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-dilts-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-violet-600 hover:bg-violet-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Разложить проблему по логическим уровням Дилтса при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'dilts'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>📐</span>
|
||||
<span>Дилтс</span>
|
||||
</button>
|
||||
<button
|
||||
@click="$emit('apply-eisenhower-ai')"
|
||||
:disabled="isCoaching"
|
||||
class="py-1.5 px-2 bg-amber-600 hover:bg-amber-500 disabled:bg-slate-800 disabled:text-slate-600 text-white rounded text-[10px] font-bold flex flex-col items-center justify-center gap-1 transition text-center"
|
||||
title="Разложить задачи по матрице Эйзенхауэра при помощи ИИ"
|
||||
>
|
||||
<span v-if="isCoaching && currentCoachAction === 'eisenhower'" class="animate-spin text-xs">🌀</span>
|
||||
<span v-else>📊</span>
|
||||
<span>Эйзенхауэр</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Goal Test Section -->
|
||||
<div v-if="['text', 'task'].includes(node.type)" class="p-2.5 bg-slate-950/60 border border-emerald-500/30 rounded-lg space-y-2 mb-2">
|
||||
<div class="text-[10px] uppercase font-bold text-emerald-400 flex items-center justify-between cursor-pointer select-none" @click="showGoalTest = !showGoalTest">
|
||||
<span class="flex items-center gap-1">⚖️ Тест истинности цели</span>
|
||||
<span>{{ showGoalTest ? '▼' : '▲' }}</span>
|
||||
</div>
|
||||
<div v-if="showGoalTest" class="space-y-2 pt-1.5 border-t border-slate-800">
|
||||
<div v-for="(q, key) in goalTestQuestions" :key="key" class="space-y-1">
|
||||
<div class="flex justify-between items-center text-[9px] text-slate-400 font-medium leading-tight">
|
||||
<span :title="q.full" class="cursor-help border-b border-dashed border-slate-600">{{ q.short }}</span>
|
||||
<span class="text-emerald-400 font-bold shrink-0 ml-1">{{ goalTestScores[key] }}/10</span>
|
||||
</div>
|
||||
<input v-model.number="goalTestScores[key]" type="range" min="1" max="10" class="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-emerald-500" />
|
||||
</div>
|
||||
|
||||
<div class="pt-2 border-t border-slate-800 flex flex-col gap-1.5">
|
||||
<div class="flex justify-between items-center text-xs">
|
||||
<span class="text-slate-400 font-semibold">Итоговый балл:</span>
|
||||
<span class="font-bold" :class="goalTestTotalScore >= 60 ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ goalTestTotalScore }} / 80
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[9px] p-1.5 rounded leading-normal border" :class="goalTestTotalScore >= 60 ? 'bg-emerald-950/40 text-emerald-300 border-emerald-800/30' : 'bg-red-950/40 text-red-300 border-red-800/30'">
|
||||
{{ goalTestTotalScore >= 60 ? '🟢 Цель истинно Ваша! Отличная мотивация.' : '🔴 Внимание! Возможны проблемы с мотивацией (менее 60 баллов).' }}
|
||||
</div>
|
||||
<button @click="writeGoalTestToNode" class="w-full py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-[10px] font-bold transition">
|
||||
✍️ Записать результат в узел
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Тип узла</label>
|
||||
<select v-model="node.type" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option v-for="nt in nodeTypes" :key="nt.type" :value="nt.type">
|
||||
{{ nt.icon }} {{ nt.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Название</label>
|
||||
<input v-model="node.data.label" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.data.text !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Текст</label>
|
||||
<textarea v-model="node.data.text" @blur="handleChange" rows="4" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="node.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Файл</label>
|
||||
<input v-model="node.data.file" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Статус Задачи</label>
|
||||
<select v-model="node.data.state" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="todo">📋 К выполнению (To Do)</option>
|
||||
<option value="in_progress">⏳ В работе (In Progress)</option>
|
||||
<option value="review">👀 На проверке (Review)</option>
|
||||
<option value="completed">✅ Завершено (Done)</option>
|
||||
<option value="blocked">⛔ Заблокировано (Blocked)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Приоритет</label>
|
||||
<select v-model="node.data.priority" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="low">🧊 Низкий</option>
|
||||
<option value="medium">⚡ Средний</option>
|
||||
<option value="high">🔥 Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Исполнитель</label>
|
||||
<select v-model="node.data.assigned_to" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="">Не назначено</option>
|
||||
<option v-for="u in usersList" :key="u.username" :value="u.username">
|
||||
{{ u.displayName || u.username }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дата начала</label>
|
||||
<input v-model="node.data.start_date" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'task'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Дедлайн (Срок)</label>
|
||||
<input v-model="node.data.alarm" @blur="handleChange" type="datetime-local" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Webhook URL</label>
|
||||
<input v-model="node.data.url" @blur="handleChange" placeholder="https://api.example.com/..." class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
<div v-if="node.type === 'webhook'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Метод</label>
|
||||
<select v-model="node.data.method" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200">
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.type === 'ai'">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Промпт (Инструкция)</label>
|
||||
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" placeholder="Что должен сделать ИИ? (например: Отредактируй этот текст...)" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div v-if="node.data.script_id !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Скрипт</label>
|
||||
<input v-model="node.data.script_id" @blur="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none font-mono" />
|
||||
</div>
|
||||
<div v-if="node.type === 'file' || node.data.file !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ссылка на Карту</label>
|
||||
<select v-model="node.data.file" @change="handleChange" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none">
|
||||
<option value="">Не выбрано</option>
|
||||
<option v-for="c in canvasesList" :key="c.id" :value="c.id">
|
||||
🗺️ {{ c.name || c.id }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="node.data.prompt !== undefined">
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">AI Запрос</label>
|
||||
<textarea v-model="node.data.prompt" @blur="handleChange" rows="3" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 focus:border-blue-500 outline-none resize-none font-mono"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Ширина</label>
|
||||
<input v-model.number="node.data.width" @blur="handleChange" type="number" min="100" max="800" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] uppercase tracking-wider text-slate-500 block mb-1">Высота</label>
|
||||
<input v-model.number="node.data.height" @blur="handleChange" type="number" min="50" max="600" class="w-full bg-slate-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-xs text-slate-500 text-center mt-8">
|
||||
Выберите узел для редактирования
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
node: { type: Object, default: null },
|
||||
usersList: { type: Array, default: () => [] },
|
||||
canvasesList: { type: Array, default: () => [] },
|
||||
isCoaching: { type: Boolean, default: false },
|
||||
currentCoachAction: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change', 'apply-smart-ai', 'apply-grow-ai', 'apply-dilts-ai', 'apply-eisenhower-ai'])
|
||||
|
||||
const nodeTypes = [
|
||||
{ type: 'text', name: 'Текст', icon: '📝' },
|
||||
{ type: 'file', name: 'Файл', icon: '📄' },
|
||||
{ type: 'task', name: 'Задача', icon: '✅' },
|
||||
{ type: 'ai', name: 'AI Узел', icon: '🤖' },
|
||||
{ type: 'webhook', name: 'Webhook', icon: '🔗' },
|
||||
{ type: 'git', name: 'Git Синхр', icon: '🔄' }
|
||||
]
|
||||
|
||||
const handleChange = () => {
|
||||
emit('change')
|
||||
}
|
||||
|
||||
// Goal Validation Test logic
|
||||
const showGoalTest = ref(false)
|
||||
const goalTestScores = ref({
|
||||
q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5
|
||||
})
|
||||
|
||||
const goalTestQuestions = {
|
||||
q1: { short: '1. Видение успеха', full: 'Насколько легко и с удовольствием я вижу себя, достигнувшим цель?' },
|
||||
q2: { short: '2. Страсть к процессу', full: 'Эта цель вызывает у меня страсть сама по себе? Мне нравится процесс ее достижения? (Без учета внешних наград)' },
|
||||
q3: { short: '3. Доп. польза', full: 'Достижение этой цели принесет мне дополнительную пользу в жизни/других сферах?' },
|
||||
q4: { short: '4. Баланс жизни', full: 'Смогу ли я достигнуть эту цель, не обделяя другие сферы жизни (время, силы, здоровье)?' },
|
||||
q5: { short: '5. Радость сразу', full: 'Я смогу получать радость и пользу от достижения цели сразу, по ходу процесса?' },
|
||||
q6: { short: '6. Зона контроля', full: 'Действия по достижению этой цели находятся в рамках моей личной собственной деятельности?' },
|
||||
q7: { short: '7. Поддержка окружения', full: 'Мое окружение будет поддерживать меня и мои усилия?' },
|
||||
q8: { short: '8. Ресурсы и автономия', full: 'У меня есть достаточно ресурсов для достижения? Достижима ли она без посторонней помощи?' }
|
||||
}
|
||||
|
||||
const goalTestTotalScore = computed(() => {
|
||||
return Object.values(goalTestScores.value).reduce((a, b) => a + b, 0)
|
||||
})
|
||||
|
||||
// Watch selected node and try to parse existing test scores if they exist
|
||||
watch(() => props.node?.id, () => {
|
||||
showGoalTest.value = false
|
||||
// Reset scores to default
|
||||
goalTestScores.value = { q1: 5, q2: 5, q3: 5, q4: 5, q5: 5, q6: 5, q7: 5, q8: 5 }
|
||||
|
||||
// Try to parse existing score from node text if present
|
||||
if (props.node?.data?.text) {
|
||||
const text = props.node.data.text
|
||||
const match = text.match(/Результат теста цели: (\d+)\/80/)
|
||||
if (match) {
|
||||
const qMatches = {
|
||||
q1: text.match(/Видение успеха:\s*(\d+)/),
|
||||
q2: text.match(/Страсть к процессу:\s*(\d+)/),
|
||||
q3: text.match(/Доп. польза:\s*(\d+)/) || text.match(/Дополнительная польза:\s*(\d+)/),
|
||||
q4: text.match(/Баланс жизни:\s*(\d+)/),
|
||||
q5: text.match(/Радость сразу:\s*(\d+)/),
|
||||
q6: text.match(/Зона контроля:\s*(\d+)/) || text.match(/Личная зона контроля:\s*(\d+)/),
|
||||
q7: text.match(/Поддержка окружения:\s*(\d+)/),
|
||||
q8: text.match(/Ресурсы и автономия:\s*(\d+)/) || text.match(/Хватает ресурсов:\s*(\d+)/)
|
||||
}
|
||||
for (const [key, m] of Object.entries(qMatches)) {
|
||||
if (m) goalTestScores.value[key] = parseInt(m[1])
|
||||
}
|
||||
showGoalTest.value = true
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const writeGoalTestToNode = () => {
|
||||
if (!props.node) return
|
||||
|
||||
const text = props.node.data.text || props.node.data.label || ''
|
||||
|
||||
// Strip previous test results if present
|
||||
let cleanText = text.split('\n\n---\n### ⚖️ Результат теста цели:')[0]
|
||||
cleanText = cleanText.split('\n---\n### ⚖️ Результат теста цели:')[0]
|
||||
|
||||
const verdict = goalTestTotalScore.value >= 60 ? 'Ваша цель' : 'Чужая цель / Риск потери мотивации'
|
||||
const testMarkdown = `\n\n---\n### ⚖️ Результат теста цели: ${goalTestTotalScore.value}/80 (${verdict})\n` +
|
||||
`* **Видение успеха:** ${goalTestScores.value.q1}/10\n` +
|
||||
`* **Страсть к процессу:** ${goalTestScores.value.q2}/10\n` +
|
||||
`* **Доп. польза:** ${goalTestScores.value.q3}/10\n` +
|
||||
`* **Баланс жизни:** ${goalTestScores.value.q4}/10\n` +
|
||||
`* **Радость сразу:** ${goalTestScores.value.q5}/10\n` +
|
||||
`* **Зона контроля:** ${goalTestScores.value.q6}/10\n` +
|
||||
`* **Поддержка окружения:** ${goalTestScores.value.q7}/10\n` +
|
||||
`* **Ресурсы и автономия:** ${goalTestScores.value.q8}/10`
|
||||
|
||||
props.node.data.text = cleanText + testMarkdown
|
||||
handleChange()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface Achievement {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
icon?: string
|
||||
unlockedAt?: string
|
||||
}
|
||||
|
||||
export const useAchievements = () => {
|
||||
const achievements = ref<Achievement[]>([])
|
||||
const stats = ref({ total: 0, completedTasks: 0 })
|
||||
const pending = ref(false)
|
||||
|
||||
const loadAchievements = async () => {
|
||||
pending.value = true
|
||||
try {
|
||||
const auth = useAuth()
|
||||
const data = await $fetch<{ achievements: Achievement[], stats: { total: number, completedTasks: number } }>('/api/achievements', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
achievements.value = data.achievements || []
|
||||
stats.value = data.stats || { total: 0, completedTasks: 0 }
|
||||
} catch (e) {
|
||||
console.error('Failed to load achievements:', e)
|
||||
} finally {
|
||||
pending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
achievements,
|
||||
stats,
|
||||
pending,
|
||||
loadAchievements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { jwtDecode } from 'jwt-decode'
|
||||
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
username: string
|
||||
displayName?: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const token = useCookie<string | null>('xflow_token', {
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
sameSite: 'lax'
|
||||
})
|
||||
|
||||
const isInitializing = useState('auth_initializing', () => true)
|
||||
|
||||
const user = computed<User | null>(() => {
|
||||
if (!token.value) return null
|
||||
try {
|
||||
return jwtDecode<User>(token.value)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value && !!user.value)
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
const googleSignIn = async () => {
|
||||
const auth = getAuth()
|
||||
const provider = new GoogleAuthProvider()
|
||||
const result = await signInWithPopup(auth, provider)
|
||||
const idToken = await result.user.getIdToken()
|
||||
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
|
||||
token.value = res.token
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const auth = getAuth()
|
||||
await signOut(auth)
|
||||
} catch (e) {
|
||||
console.warn('[Firebase] Sign out failed or not initialized', e)
|
||||
} finally {
|
||||
token.value = null
|
||||
navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
const initAuth = () => {
|
||||
if (process.server) return
|
||||
|
||||
const auth = getAuth()
|
||||
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
||||
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
||||
if (firebaseUser && !token.value) {
|
||||
try {
|
||||
const idToken = await firebaseUser.getIdToken()
|
||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||
method: 'POST',
|
||||
body: { idToken }
|
||||
})
|
||||
token.value = res.token
|
||||
|
||||
if (useRoute().path === '/login') {
|
||||
await navigateTo('/')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to restore backend auth from Firebase', e)
|
||||
}
|
||||
}
|
||||
isInitializing.value = false
|
||||
})
|
||||
return unsubscribe
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
isInitializing,
|
||||
googleSignIn,
|
||||
logout,
|
||||
initAuth
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import MarkdownIt from 'markdown-it'
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
text?: string
|
||||
file?: string
|
||||
frontmatter?: Record<string, any>
|
||||
state?: 'pending' | 'in_progress' | 'completed'
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface CanvasEdge {
|
||||
id: string
|
||||
fromNode: string
|
||||
fromSide: string
|
||||
toNode: string
|
||||
toSide: string
|
||||
}
|
||||
|
||||
export interface CanvasData {
|
||||
id?: string
|
||||
name?: string
|
||||
nodes: CanvasNode[]
|
||||
edges: CanvasEdge[]
|
||||
}
|
||||
|
||||
export const useCanvas = () => {
|
||||
const md = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
typographer: true
|
||||
})
|
||||
|
||||
const renderMarkdown = (text: string): string => {
|
||||
return md.render(text || '')
|
||||
}
|
||||
|
||||
const parseCanvasJson = (json: any): CanvasData => {
|
||||
if (!json) return { nodes: [], edges: [] }
|
||||
const nodes = (json.nodes || []).map((n: any) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
width: n.width || 300,
|
||||
height: n.height || 200,
|
||||
text: n.text,
|
||||
label: n.label,
|
||||
file: n.file,
|
||||
frontmatter: n.frontmatter,
|
||||
state: n.state || 'pending',
|
||||
priority: n.priority,
|
||||
assigned_to: n.assigned_to,
|
||||
alarm: n.alarm,
|
||||
script_id: n.script_id,
|
||||
prompt: n.prompt,
|
||||
url: n.url,
|
||||
method: n.method,
|
||||
repo: n.repo,
|
||||
branch: n.branch,
|
||||
message: n.message,
|
||||
style: n.style || {}
|
||||
}))
|
||||
|
||||
const edges = (json.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
fromNode: e.fromNode || e.source,
|
||||
fromSide: e.fromSide || 'right',
|
||||
toNode: e.toNode || e.target,
|
||||
toSide: e.toSide || 'left'
|
||||
}))
|
||||
|
||||
return {
|
||||
id: json.id,
|
||||
name: json.name,
|
||||
nodes,
|
||||
edges
|
||||
}
|
||||
}
|
||||
|
||||
const getAuthHeaders = (): Record<string, string> => {
|
||||
const auth = useAuth()
|
||||
const token = auth.token?.value
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
const loadCanvas = async (id: string): Promise<{ canvas: CanvasData, role: string }> => {
|
||||
const data = await $fetch<{ canvas: any, role: string }>(`/api/canvas/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
})
|
||||
return {
|
||||
canvas: parseCanvasJson(data.canvas),
|
||||
role: data.role
|
||||
}
|
||||
}
|
||||
|
||||
const updateNodeState = async (nodeId: string, state: string) => {
|
||||
return await $fetch(`/api/nodes/${nodeId}`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: { state }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
renderMarkdown,
|
||||
parseCanvasJson,
|
||||
loadCanvas,
|
||||
updateNodeState
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Composable for Git synchronization functionality
|
||||
interface GitRepository {
|
||||
id: string
|
||||
name: string
|
||||
owner: string
|
||||
url: string
|
||||
cloneUrl: string
|
||||
isPrivate: boolean
|
||||
type: 'personal' | 'group'
|
||||
ownerId: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const useGitSync = () => {
|
||||
const auth = useAuth()
|
||||
const user = computed(() => auth.user)
|
||||
|
||||
const apiFetch = (url: string, opts: any = {}) => $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
|
||||
const isSyncing = ref(false)
|
||||
const lastSyncTime = ref<string | null>(null)
|
||||
const syncStatus = ref<'idle' | 'syncing' | 'success' | 'error'>('idle')
|
||||
const repository = ref<GitRepository | null>(null)
|
||||
|
||||
// Auto-sync canvas changes
|
||||
const autoSyncCanvas = async (canvasId: string, canvasData: any) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: `Auto-sync: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
|
||||
// Show success notification
|
||||
console.log('Canvas synchronized with Git:', response.message)
|
||||
} else {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Git sync failed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Auto-sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
// Reset status after delay
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Create Git repository for canvas
|
||||
const createRepository = async (canvasId: string, type: 'personal' | 'group' = 'personal') => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'init'
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
repository.value = response.repository
|
||||
console.log('Git repository created:', response.message)
|
||||
return response.repository
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create repository:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Manual sync
|
||||
const manualSync = async (canvasId: string, commitMessage?: string) => {
|
||||
if (!auth.user || !canvasId) return
|
||||
|
||||
try {
|
||||
syncStatus.value = 'syncing'
|
||||
isSyncing.value = true
|
||||
|
||||
const response = await apiFetch('/api/git/real-sync', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
canvasId,
|
||||
action: 'sync',
|
||||
commitMessage: commitMessage || `Ручная синхронизация: ${new Date().toLocaleString('ru-RU')}`
|
||||
}
|
||||
}) as any
|
||||
|
||||
if (response.success) {
|
||||
syncStatus.value = 'success'
|
||||
lastSyncTime.value = new Date().toISOString()
|
||||
console.log('Manual sync completed:', response.message)
|
||||
}
|
||||
} catch (error) {
|
||||
syncStatus.value = 'error'
|
||||
console.error('Manual sync failed:', error)
|
||||
} finally {
|
||||
isSyncing.value = false
|
||||
setTimeout(() => {
|
||||
if (syncStatus.value === 'success') {
|
||||
syncStatus.value = 'idle'
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if canvas has repository
|
||||
const checkRepository = async (canvasId: string) => {
|
||||
if (!canvasId) return null
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`/api/git/repository/${canvasId}`) as any
|
||||
repository.value = response.repository
|
||||
return response.repository
|
||||
} catch (error) {
|
||||
repository.value = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status color
|
||||
const syncStatusColor = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return 'text-yellow-400'
|
||||
case 'success': return 'text-green-400'
|
||||
case 'error': return 'text-red-400'
|
||||
default: return 'text-slate-400'
|
||||
}
|
||||
})
|
||||
|
||||
// Get sync status icon
|
||||
const syncStatusIcon = computed(() => {
|
||||
switch (syncStatus.value) {
|
||||
case 'syncing': return '⟳'
|
||||
case 'success': return '✓'
|
||||
case 'error': return '✗'
|
||||
default: return '⚡'
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isSyncing: readonly(isSyncing),
|
||||
lastSyncTime: readonly(lastSyncTime),
|
||||
syncStatus: readonly(syncStatus),
|
||||
repository: readonly(repository),
|
||||
syncStatusColor,
|
||||
syncStatusIcon,
|
||||
autoSyncCanvas,
|
||||
createRepository,
|
||||
manualSync,
|
||||
checkRepository
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
export const usePush = () => {
|
||||
const isSupported = ref(false)
|
||||
const subscription = ref<PushSubscription | null>(null)
|
||||
|
||||
const checkSupport = () => {
|
||||
isSupported.value = 'serviceWorker' in navigator && 'PushManager' in window
|
||||
}
|
||||
|
||||
const getRegistration = async (): Promise<ServiceWorkerRegistration | null> => {
|
||||
if (!('serviceWorker' in navigator)) return null
|
||||
return navigator.serviceWorker.ready
|
||||
}
|
||||
|
||||
const subscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
try {
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
subscription.value = existing
|
||||
await sendSubscriptionToServer(existing)
|
||||
return true
|
||||
}
|
||||
|
||||
// In production, get VAPID public key from server
|
||||
const vapidKey = await $fetch<string>('/api/push/vapid-public-key').catch(() => null)
|
||||
const applicationServerKey = vapidKey
|
||||
? (urlBase64ToUint8Array(vapidKey) as unknown as ArrayBuffer)
|
||||
: undefined
|
||||
|
||||
const sub = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey
|
||||
})
|
||||
|
||||
subscription.value = sub
|
||||
await sendSubscriptionToServer(sub)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Push subscription failed:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const sendSubscriptionToServer = async (sub: PushSubscription) => {
|
||||
await $fetch('/api/push/subscribe', {
|
||||
method: 'POST',
|
||||
body: { subscription: sub.toJSON() }
|
||||
})
|
||||
}
|
||||
|
||||
const unsubscribe = async (): Promise<boolean> => {
|
||||
const registration = await getRegistration()
|
||||
if (!registration) return false
|
||||
|
||||
const existing = await registration.pushManager.getSubscription()
|
||||
if (existing) {
|
||||
await existing.unsubscribe()
|
||||
subscription.value = null
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const urlBase64ToUint8Array = (base64String: string): Uint8Array => {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const rawData = atob(base64)
|
||||
return Uint8Array.from([...rawData].map(char => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
const showLocalNotification = (title: string, options?: NotificationOptions) => {
|
||||
if ('Notification' in window && Notification.permission === 'granted') {
|
||||
navigator.serviceWorker.ready.then(reg => {
|
||||
reg.showNotification(title, {
|
||||
icon: '/pwa-192x192.svg',
|
||||
badge: '/favicon.svg',
|
||||
...options as any
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const requestPermission = async (): Promise<boolean> => {
|
||||
if (!('Notification' in window)) return false
|
||||
const permission = await Notification.requestPermission()
|
||||
return permission === 'granted'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkSupport()
|
||||
})
|
||||
|
||||
return {
|
||||
isSupported,
|
||||
subscription,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
requestPermission,
|
||||
showLocalNotification
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Composable for tracking unsaved changes and preventing accidental data loss
|
||||
export const useUnsavedChanges = () => {
|
||||
const isDirty = ref(false)
|
||||
const lastSavedData = ref<string | null>(null)
|
||||
const currentData = ref<string | null>(null)
|
||||
|
||||
// Track changes by comparing current data with last saved data
|
||||
const trackChanges = (newData: any) => {
|
||||
currentData.value = JSON.stringify(newData)
|
||||
|
||||
if (lastSavedData.value && currentData.value !== lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because data is different')
|
||||
} else if (!lastSavedData.value) {
|
||||
isDirty.value = true
|
||||
console.log('[DEBUG Unsaved] Changed to dirty because no lastSavedData')
|
||||
} else {
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Not dirty, data is identical')
|
||||
}
|
||||
}
|
||||
|
||||
// Mark data as saved
|
||||
const markAsSaved = (data: any) => {
|
||||
lastSavedData.value = JSON.stringify(data)
|
||||
currentData.value = lastSavedData.value
|
||||
isDirty.value = false
|
||||
console.log('[DEBUG Unsaved] Marked as saved, isDirty = false')
|
||||
}
|
||||
|
||||
// Reset dirty state
|
||||
const resetDirty = () => {
|
||||
isDirty.value = false
|
||||
lastSavedData.value = currentData.value
|
||||
}
|
||||
|
||||
// Warning before leaving page
|
||||
const beforeUnloadHandler = (event: BeforeUnloadEvent) => {
|
||||
if (isDirty.value) {
|
||||
event.preventDefault()
|
||||
event.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?'
|
||||
return event.returnValue
|
||||
}
|
||||
}
|
||||
|
||||
// Setup warning on mount
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeunload', beforeUnloadHandler)
|
||||
})
|
||||
|
||||
return {
|
||||
isDirty: readonly(isDirty),
|
||||
trackChanges,
|
||||
markAsSaved,
|
||||
resetDirty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||||
<nav v-if="isAuthenticated" class="border-b border-slate-800 bg-slate-900/90 backdrop-blur-xl sticky top-0 z-40">
|
||||
<div class="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-10">
|
||||
<NuxtLink to="/" class="text-2xl font-black text-blue-500 tracking-tighter hover:scale-105 transition-transform">XFlow</NuxtLink>
|
||||
|
||||
<!-- Desktop Nav -->
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<NuxtLink to="/canvases" class="text-sm font-medium text-slate-400 hover:text-white transition flex items-center gap-2">
|
||||
<span>🗺️</span> Проекты
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/tasks" class="text-sm font-medium text-slate-400 hover:text-white transition flex items-center gap-2">
|
||||
<span>✅</span> Задачи
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="isAdmin" to="/admin" class="text-sm font-medium text-red-400/80 hover:text-red-400 transition flex items-center gap-2">
|
||||
<span>🛡️</span> Админ
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/settings" class="text-sm font-medium text-slate-400 hover:text-white transition flex items-center gap-2">
|
||||
<span>⚙️</span> Настройки
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<NuxtLink :to="`/profile/${user?.username || user?.id}`" class="flex items-center gap-2 text-sm font-medium text-slate-300 hover:text-blue-400 transition">
|
||||
<div class="w-8 h-8 bg-slate-800 rounded-full flex items-center justify-center border border-slate-700 text-xs">
|
||||
{{ (user?.displayName || user?.username || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="hidden sm:inline">{{ user?.displayName || user?.username }}</span>
|
||||
</NuxtLink>
|
||||
<button @click="logout" class="p-2 text-slate-500 hover:text-red-400 transition" title="Выйти">
|
||||
<span class="text-xl">🚪</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="flex-1 pb-20 md:pb-0">
|
||||
<slot />
|
||||
</div>
|
||||
<BottomNav />
|
||||
<AchievementOverlay :achievements="userAchievements" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { user, isAuthenticated, isAdmin, logout } = useAuth()
|
||||
const { achievements: userAchievements } = useAchievements()
|
||||
</script>
|
||||
@@ -0,0 +1,15 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const token = useCookie('xflow_token')
|
||||
|
||||
if (!token.value) return navigateTo('/login')
|
||||
|
||||
try {
|
||||
const base64Url = token.value.split('.')[1]
|
||||
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const payload = JSON.parse(window.atob(base64))
|
||||
|
||||
if (payload.role !== 'admin') return navigateTo('/')
|
||||
} catch (e) {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export default defineNuxtRouteMiddleware((to, from) => {
|
||||
const token = useCookie('xflow_token')
|
||||
if (!token.value && to.path !== '/login') {
|
||||
return navigateTo('/login')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<NuxtPage />
|
||||
</template>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-4xl mx-auto" v-if="group">
|
||||
<div class="flex items-center gap-4 mb-8 text-sm">
|
||||
<NuxtLink to="/admin/groups" class="text-slate-500 hover:text-white transition">Группы</NuxtLink>
|
||||
<span class="text-slate-700">/</span>
|
||||
<span class="text-slate-300">{{ group.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl p-8 mb-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-3xl font-bold text-white">{{ group.name }}</h1>
|
||||
<button @click="deleteGroup" class="px-4 py-2 bg-red-900/20 text-red-400 hover:bg-red-900/40 rounded-lg text-sm transition">Удалить группу</button>
|
||||
</div>
|
||||
<p class="text-slate-400">{{ group.description || 'Нет описания' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Members Section -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden mb-8">
|
||||
<div class="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Участники ({{ members.length }})</h2>
|
||||
<button @click="showAddMemberModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition">+ Добавить</button>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="m in members" :key="m.id" class="p-4 flex items-center justify-between hover:bg-slate-800/30 transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center font-bold text-slate-400">
|
||||
{{ m.user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-slate-200 font-medium">{{ m.user.username }}</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
<select
|
||||
v-if="group.ownerId === auth.user.value?.id || auth.user.value?.role === 'admin'"
|
||||
:value="m.role"
|
||||
@change="(e) => updateMemberRole(m.userId, e.target.value)"
|
||||
class="bg-transparent border-none p-0 focus:ring-0 text-blue-400 cursor-pointer outline-none"
|
||||
:disabled="m.userId === group.ownerId"
|
||||
>
|
||||
<option value="admin">Админ</option>
|
||||
<option value="editor">Редактор</option>
|
||||
<option value="viewer">Зритель</option>
|
||||
</select>
|
||||
<span v-else>{{ m.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="m.role !== 'admin'" @click="removeMember(m.userId)" class="text-slate-600 hover:text-red-400 transition text-xl">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases Section -->
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-3xl overflow-hidden">
|
||||
<div class="p-6 border-b border-slate-800 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-white">Карты группы ({{ group.canvases?.length || 0 }})</h2>
|
||||
<NuxtLink to="/canvases" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-bold transition">Управлять в Проектах</NuxtLink>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="c in group.canvases" :key="c.id" class="p-4 flex items-center justify-between hover:bg-slate-800/30 transition">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-blue-900/20 rounded-lg flex items-center justify-center text-xl">🗺️</div>
|
||||
<div>
|
||||
<div class="text-slate-200 font-medium">{{ c.name }}</div>
|
||||
<div class="text-[10px] text-slate-500 uppercase tracking-wider">Обновлена: {{ new Date(c.updatedAt).toLocaleDateString() }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button @click="unassignCanvas(c.id)" class="text-slate-600 hover:text-red-400 transition text-sm font-bold">Отвязать</button>
|
||||
</div>
|
||||
<div v-if="!group.canvases?.length" class="p-8 text-center text-slate-500 italic">
|
||||
В этой группе пока нет карт.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add Member Modal -->
|
||||
<div v-if="showAddMemberModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showAddMemberModal = false">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-3xl p-8 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-white mb-6 flex items-center gap-2">
|
||||
<span>👤</span> Добавить участника
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Поиск пользователя</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@input="searchUsers"
|
||||
placeholder="Введите имя или email..."
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white outline-none focus:border-blue-500 transition"
|
||||
/>
|
||||
<div v-if="searching" class="absolute right-4 top-3.5">
|
||||
<div class="animate-spin h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div v-if="searchResults.length > 0" class="max-h-60 overflow-y-auto bg-slate-800/50 rounded-xl border border-slate-700 divide-y divide-slate-700">
|
||||
<div v-for="u in searchResults" :key="u.id" class="p-3 flex items-center justify-between hover:bg-slate-700/50 transition">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-medium text-white">{{ u.username }}</span>
|
||||
<span class="text-[10px] text-slate-500">{{ u.email }}</span>
|
||||
</div>
|
||||
<button
|
||||
@click="addMember(u.id)"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-lg transition"
|
||||
>
|
||||
Выбрать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="searchQuery.length >= 2" class="text-center py-4 text-slate-500 text-sm">
|
||||
Пользователи не найдены
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<button @click="showAddMemberModal = false" class="w-full py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuth()
|
||||
const group = ref(null)
|
||||
const members = ref([])
|
||||
const showAddMemberModal = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const searchResults = ref([])
|
||||
const searching = ref(false)
|
||||
|
||||
const searchUsers = async () => {
|
||||
if (searchQuery.value.length < 2) {
|
||||
searchResults.value = []
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/users/search', {
|
||||
params: { q: searchQuery.value },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
searchResults.value = data.users || []
|
||||
} catch (e) {
|
||||
console.error('Search failed', e)
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const addMember = async (userId) => {
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/add-member`, {
|
||||
method: 'POST',
|
||||
body: { userId, role: 'member' },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showAddMemberModal.value = false
|
||||
searchQuery.value = ''
|
||||
searchResults.value = []
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert(e.data?.message || 'Ошибка при добавлении')
|
||||
}
|
||||
}
|
||||
|
||||
const updateMemberRole = async (userId, role) => {
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/members`, {
|
||||
method: 'PATCH',
|
||||
body: { userId, role },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при смене роли')
|
||||
}
|
||||
}
|
||||
|
||||
const removeMember = async (userId) => {
|
||||
if (!confirm('Удалить участника из группы?')) return
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}/members`, {
|
||||
method: 'DELETE',
|
||||
body: { userId },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при удалении')
|
||||
}
|
||||
}
|
||||
|
||||
const unassignCanvas = async (canvasId) => {
|
||||
if (!confirm('Отвязать карту от группы? Она станет личной картой владельца.')) return
|
||||
try {
|
||||
await $fetch(`/api/canvases/${canvasId}`, {
|
||||
method: 'PATCH',
|
||||
body: { groupId: null },
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
await fetchGroup()
|
||||
} catch (e) {
|
||||
alert('Ошибка при отвязке карты')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchGroup = async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/groups/${route.params.id}`, {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
group.value = data.group
|
||||
members.value = data.group.members || []
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch group', e)
|
||||
navigateTo('/admin/groups')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGroup = async () => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту группу?')) return
|
||||
try {
|
||||
await $fetch(`/api/groups/${route.params.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
navigateTo('/admin/groups')
|
||||
} catch (e) {
|
||||
alert('Ошибка при удалении')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchGroup)
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Управление Группами</h1>
|
||||
<p class="text-slate-400 mt-1">Создание и управление командами, участниками и правами</p>
|
||||
</div>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2 shadow-lg shadow-blue-900/20"
|
||||
>
|
||||
<span class="text-xl">+</span> Новая Группа
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Grid -->
|
||||
<div v-else-if="groups.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.id"
|
||||
class="bg-slate-900 border border-slate-800 rounded-2xl p-6 hover:border-slate-700 transition group"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="w-12 h-12 bg-blue-900/30 rounded-xl flex items-center justify-center text-blue-400 text-xl font-bold">
|
||||
{{ group.name.charAt(0) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span v-if="!group.isPublic" class="px-2 py-1 bg-amber-900/30 text-amber-400 text-[10px] uppercase font-bold rounded-md border border-amber-900/50">Private</span>
|
||||
<span class="px-2 py-1 bg-slate-800 text-slate-400 text-[10px] uppercase font-bold rounded-md">{{ group.memberCount }} members</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl font-semibold text-slate-100 mb-2">{{ group.name }}</h3>
|
||||
<p class="text-slate-400 text-sm mb-6 line-clamp-2 h-10">{{ group.description || 'Нет описания' }}</p>
|
||||
|
||||
<div class="flex items-center justify-between pt-4 border-t border-slate-800">
|
||||
<div class="flex -space-x-2">
|
||||
<div v-for="i in Math.min(group.memberCount, 3)" :key="i" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-800 flex items-center justify-center text-[10px] text-slate-400 font-bold">
|
||||
U{{ i }}
|
||||
</div>
|
||||
<div v-if="group.memberCount > 3" class="w-8 h-8 rounded-full border-2 border-slate-900 bg-slate-700 flex items-center justify-center text-[10px] text-slate-300 font-bold">
|
||||
+{{ group.memberCount - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="openGroupSettings(group)" class="px-4 py-1.5 bg-slate-800 hover:bg-slate-700 text-blue-400 rounded-lg text-sm font-medium transition">Управлять</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="bg-slate-900 border border-slate-800 border-dashed rounded-3xl p-12 text-center">
|
||||
<div class="text-6xl mb-4">👥</div>
|
||||
<h3 class="text-xl font-bold text-slate-200">Групп пока нет</h3>
|
||||
<p class="text-slate-500 mt-2 max-w-sm mx-auto">Создайте свою первую команду, чтобы работать над проектами совместно.</p>
|
||||
<button @click="showCreateModal = true" class="mt-6 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition">Создать группу</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-sm" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-900 border border-slate-800 rounded-2xl w-full max-w-md p-6 shadow-2xl">
|
||||
<h2 class="text-2xl font-bold text-slate-100 mb-4 text-center">Новая Группа</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Название группы</label>
|
||||
<input
|
||||
v-model="newGroup.name"
|
||||
type="text"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition"
|
||||
placeholder="Например: Отдел разработки"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-slate-500 uppercase mb-1">Описание</label>
|
||||
<textarea
|
||||
v-model="newGroup.description"
|
||||
rows="3"
|
||||
class="w-full bg-slate-800 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-100 focus:border-blue-500 outline-none transition resize-none"
|
||||
placeholder="Чем занимается эта группа?"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 bg-slate-800/50 p-3 rounded-lg border border-slate-700/50">
|
||||
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="w-5 h-5 rounded border-slate-700 bg-slate-800 text-blue-600 focus:ring-blue-500 cursor-pointer">
|
||||
<label for="isPublic" class="text-slate-300 select-none cursor-pointer text-sm">Сделать группу публичной (доступной всем)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 mt-8">
|
||||
<button
|
||||
@click="showCreateModal = false"
|
||||
class="flex-1 px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
@click="createGroup"
|
||||
class="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/40"
|
||||
:disabled="creating"
|
||||
>
|
||||
{{ creating ? 'Создание...' : 'Создать' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const groups = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
|
||||
const newGroup = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
isPublic: true
|
||||
})
|
||||
|
||||
const fetchGroups = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch('/api/groups', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
groups.value = data.groups || []
|
||||
} catch (e) {
|
||||
console.error('[Groups Page] Failed to fetch groups', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
if (!newGroup.name.trim()) return
|
||||
creating.value = true
|
||||
try {
|
||||
await $fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
body: newGroup,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newGroup.name = ''
|
||||
newGroup.description = ''
|
||||
await fetchGroups()
|
||||
} catch (e) {
|
||||
alert('Ошибка при создании группы: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openGroupSettings = (group) => {
|
||||
navigateTo(`/admin/groups/${group.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchGroups()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950 p-6">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-slate-100">XFlow Admin Panel</h1>
|
||||
<div class="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-500"></span>
|
||||
Online
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-blue-400">{{ stats.users }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Registered Users</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-emerald-400">{{ stats.canvases }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Canvases</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-yellow-400">{{ stats.nodes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Total Nodes</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-3xl font-bold text-purple-400">{{ stats.pushes }}</div>
|
||||
<div class="text-sm text-slate-400 mt-1">Push Subscribers</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<NuxtLink
|
||||
to="/admin/groups"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition flex items-center gap-2"
|
||||
>
|
||||
👥 Manage Groups & Teams
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="refreshData"
|
||||
class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg font-medium transition"
|
||||
>
|
||||
🔄 Refresh Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Users -->
|
||||
<div class="lg:col-span-2 bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Users</h2>
|
||||
<button @click="refreshData" class="text-sm text-blue-400 hover:text-blue-300">Refresh</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th class="text-left p-3">Email</th>
|
||||
<th class="text-left p-3">Username</th>
|
||||
<th class="text-left p-3">Role</th>
|
||||
<th class="text-left p-3">ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="text-slate-300">
|
||||
<tr v-for="user in users" :key="user.id" class="border-t border-slate-800">
|
||||
<td class="p-3">{{ user.email }}</td>
|
||||
<td class="p-3">{{ user.username }}</td>
|
||||
<td class="p-3">
|
||||
<span :class="roleBadge(user.role)">{{ user.role }}</span>
|
||||
</td>
|
||||
<td class="p-3 text-xs text-slate-500 font-mono">{{ user.id }}</td>
|
||||
</tr>
|
||||
<tr v-if="users.length === 0">
|
||||
<td colspan="4" class="p-6 text-center text-slate-500">No users found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canvases -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="p-4 border-b border-slate-700">
|
||||
<h2 class="text-lg font-semibold text-slate-200">Canvases</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-slate-800">
|
||||
<div v-for="canvas in canvases" :key="canvas.id" class="p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<NuxtLink :to="`/canvas/${canvas.id}`" class="text-blue-400 hover:text-blue-300 font-medium">
|
||||
{{ canvas.name }}
|
||||
</NuxtLink>
|
||||
<span class="text-xs text-slate-500">{{ canvas.nodeCount }} nodes</span>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-1">{{ formatDate(canvas.updatedAt) }}</div>
|
||||
</div>
|
||||
<div v-if="canvases.length === 0" class="p-6 text-center text-slate-500">
|
||||
No canvases yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'admin']
|
||||
})
|
||||
|
||||
const users = ref([])
|
||||
const canvases = ref([])
|
||||
const stats = reactive({ users: 0, canvases: 0, nodes: 0, pushes: 0 })
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const refreshData = async () => {
|
||||
if (!auth.token.value) return
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/admin/stats', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
users.value = data.users || []
|
||||
canvases.value = data.canvases || []
|
||||
stats.users = data.stats?.users || 0
|
||||
stats.canvases = data.stats?.canvases || 0
|
||||
stats.nodes = data.stats?.nodes || 0
|
||||
stats.pushes = data.stats?.pushes || 0
|
||||
} catch (e) {
|
||||
console.error('Failed to load admin data', e)
|
||||
}
|
||||
}
|
||||
|
||||
const roleBadge = (role) => {
|
||||
return role === 'admin'
|
||||
? 'px-2 py-0.5 bg-red-900/50 text-red-300 rounded text-xs'
|
||||
: 'px-2 py-0.5 bg-slate-700 text-slate-300 rounded text-xs'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'N/A'
|
||||
return new Date(date).toLocaleDateString('ru-RU')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Ждем инициализации пользователя если нужно
|
||||
if (!auth.user.value) {
|
||||
await auth.initAuth()
|
||||
}
|
||||
refreshData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div class="h-screen w-full">
|
||||
<ClientOnly>
|
||||
<FlowEditor :canvas-id="canvasId" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const canvasId = computed(() => route.params.id || 'demo')
|
||||
</script>
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950">
|
||||
<div class="max-w-6xl mx-auto p-6">
|
||||
<h1 class="text-3xl font-bold text-slate-100 mb-6">Канвасы</h1>
|
||||
|
||||
<!-- Canvas Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div v-for="canvas in canvases" :key="canvas.id"
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl p-6 hover:border-blue-500 transition-colors">
|
||||
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-slate-100">{{ canvas.name }}</h3>
|
||||
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ new Date(canvas.updatedAt).toLocaleDateString('ru-RU') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
|
||||
{{ canvas.isPublic ? 'Публичный' : 'Личный' }}
|
||||
</span>
|
||||
<span class="px-2 py-1 bg-slate-800 text-slate-300 rounded text-xs">
|
||||
{{ canvas.nodes?.length || 0 }} узлов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<NuxtLink :to="`/canvas/${canvas.id}`"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
|
||||
Открыть
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create New Canvas -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 border-dashed hover:border-blue-500 transition-colors">
|
||||
<div class="flex flex-col items-center justify-center h-full min-h-[200px]">
|
||||
<div class="text-4xl mb-4">+</div>
|
||||
<h3 class="text-lg font-semibold text-slate-100 mb-2">Создать канвас</h3>
|
||||
<p class="text-sm text-slate-400 text-center mb-4">
|
||||
Создайте новую ментальную карту
|
||||
</p>
|
||||
<button @click="createNewCanvas"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
// Mock canvases data - replace with actual API call
|
||||
const canvases = ref([
|
||||
{
|
||||
id: 'xflow-project',
|
||||
name: 'Обзор Проекта XFlow',
|
||||
description: 'Основная ментальная карта проекта',
|
||||
isPublic: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
nodes: 11
|
||||
},
|
||||
{
|
||||
id: 'demo',
|
||||
name: 'Демо канвас',
|
||||
description: 'Пример ментальной карты',
|
||||
isPublic: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
nodes: 5
|
||||
}
|
||||
])
|
||||
|
||||
const createNewCanvas = () => {
|
||||
// Redirect to flow editor with new canvas
|
||||
router.push('/canvas/new-canvas')
|
||||
}
|
||||
|
||||
// Redirect to specific canvas if ID is provided
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated.value) {
|
||||
// Load user's canvases
|
||||
console.log('Loading canvases for user:', auth.user.value?.email)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950">
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<!-- Debug Info for Prod -->
|
||||
<div v-if="auth.user.value" class="mb-4 p-2 bg-slate-800 border border-slate-700 rounded text-[10px] font-mono text-slate-500 flex justify-between items-center">
|
||||
<span>Logged in as: <b class="text-blue-400">{{ auth.user.value.email }}</b></span>
|
||||
<span>Internal ID: <b class="text-emerald-400">{{ auth.user.value.id }}</b></span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-100">Мои Карты</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="triggerImport" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition">
|
||||
Импорт .canvas
|
||||
</button>
|
||||
<input ref="importInput" type="file" accept=".canvas,.json" class="hidden" @change="handleImport" />
|
||||
<button @click="openCreateModal" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-bold shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
|
||||
<span>➕</span> Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
|
||||
<button @click="activeTab = 'personal'" :class="tabClass('personal')" class="px-4 py-2 rounded text-sm font-medium transition">
|
||||
Личные
|
||||
</button>
|
||||
<button @click="activeTab = 'team'" :class="tabClass('team')" class="px-4 py-2 rounded text-sm font-medium transition">
|
||||
Командные
|
||||
</button>
|
||||
<button @click="activeTab = 'public'" :class="tabClass('public')" class="px-4 py-2 rounded text-sm font-medium transition">
|
||||
Публичные
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Canvas Grid -->
|
||||
<div v-if="filteredCanvases.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="canvas in filteredCanvases" :key="canvas.id"
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer group"
|
||||
@click="openCanvas(canvas.id)">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-200 group-hover:text-blue-400 transition">{{ canvas.name }}</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Публичная</span>
|
||||
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Общая</span>
|
||||
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Приватная</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-slate-400">
|
||||
<span v-if="canvas.ownerId === currentUserId">Ваша карта</span>
|
||||
<span v-else>Поделился {{ getOwnerName(canvas.ownerId) }}</span>
|
||||
<span v-if="canvas.groupId" class="ml-2">• {{ getGroupName(canvas.groupId) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 pt-3 border-t border-slate-800 flex justify-between items-center">
|
||||
<div class="flex gap-3">
|
||||
<button @click.stop="duplicateCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-blue-400 transition flex items-center gap-1">
|
||||
<span>📑</span> Дублировать
|
||||
</button>
|
||||
<button @click.stop="exportCanvas(canvas.id)" class="text-xs text-slate-400 hover:text-emerald-400 transition flex items-center gap-1">
|
||||
<span>💾</span> Экспорт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button v-if="canDeleteCanvas(canvas)" @click.stop="openSettings(canvas)"
|
||||
class="p-1.5 text-slate-500 hover:text-blue-400 hover:bg-blue-400/10 rounded transition-colors"
|
||||
title="Настройки доступа">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button v-if="canDeleteCanvas(canvas)" @click.stop="deleteCanvas(canvas.id)"
|
||||
class="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-400/10 rounded transition-colors"
|
||||
title="Удалить карту">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12">
|
||||
<div class="text-slate-500 mb-4">Карты не найдены</div>
|
||||
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition">
|
||||
Создать первую карту
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
|
||||
<span>✨</span> Новая Карта
|
||||
</h2>
|
||||
<form @submit.prevent="createCanvas" class="space-y-6">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Название Карты</label>
|
||||
<input v-model="newCanvas.name" required class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition" placeholder="Моя Awesome Карта" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Видимость</label>
|
||||
<select v-model="newCanvas.visibility" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
|
||||
<option value="private">Приватная (только вы)</option>
|
||||
<option value="public">Публичная (любой может просматривать)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="userGroups.length > 0">
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Поделиться с Группой (опционально)</label>
|
||||
<select v-model="newCanvas.groupId" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
|
||||
<option value="">Без группы</option>
|
||||
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/20">
|
||||
Создать
|
||||
</button>
|
||||
<button type="button" @click="showCreateModal = false" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div v-if="showSettingsModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="closeSettings">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
|
||||
<span>⚙️</span> Настройки доступа
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="saveSettings" class="space-y-6">
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Название Карты</label>
|
||||
<input
|
||||
v-model="editCanvas.name"
|
||||
required
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Уровень доступа</label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="editCanvas.isPublic = false"
|
||||
:class="!editCanvas.isPublic ? 'bg-blue-600 border-blue-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'"
|
||||
class="px-4 py-2 border rounded-xl text-sm font-medium transition"
|
||||
>
|
||||
Приватная
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="editCanvas.isPublic = true"
|
||||
:class="editCanvas.isPublic ? 'bg-emerald-600 border-emerald-500 text-white' : 'bg-slate-800 border-slate-700 text-slate-400'"
|
||||
class="px-4 py-2 border rounded-xl text-sm font-medium transition"
|
||||
>
|
||||
Публичная
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-500 mt-2">
|
||||
{{ editCanvas.isPublic ? 'Любой пользователь сможет просматривать эту карту.' : 'Карта доступна только вам и членам выбранной группы.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="userGroups.length > 0">
|
||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider block mb-2">Принадлежность Группе</label>
|
||||
<select v-model="editCanvas.groupId" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white focus:border-blue-500 outline-none transition appearance-none">
|
||||
<option :value="null">Без группы (Личная)</option>
|
||||
<option v-for="group in userGroups" :key="group.id" :value="group.id">{{ group.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition shadow-lg shadow-blue-900/20" :disabled="saving">
|
||||
{{ saving ? 'Сохранение...' : 'Сохранить' }}
|
||||
</button>
|
||||
<button type="button" @click="closeSettings" class="flex-1 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-xl font-bold transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const user = computed(() => auth.user.value)
|
||||
const authFetch = async (url, opts = {}) => {
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
auth.logout()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const canvases = ref([])
|
||||
const userGroups = ref([])
|
||||
const showCreateModal = ref(false)
|
||||
const importInput = ref(null)
|
||||
const activeTab = ref('personal')
|
||||
|
||||
const newCanvas = reactive({
|
||||
name: '',
|
||||
visibility: 'private',
|
||||
groupId: ''
|
||||
})
|
||||
|
||||
const openCreateModal = () => {
|
||||
newCanvas.name = ''
|
||||
newCanvas.visibility = 'private'
|
||||
newCanvas.groupId = ''
|
||||
showSettingsModal.value = false
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
const showSettingsModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const editCanvas = reactive({
|
||||
id: '',
|
||||
name: '',
|
||||
isPublic: false,
|
||||
groupId: null
|
||||
})
|
||||
|
||||
const openSettings = (canvas) => {
|
||||
editCanvas.id = canvas.id
|
||||
editCanvas.name = canvas.name
|
||||
editCanvas.isPublic = canvas.isPublic
|
||||
editCanvas.groupId = canvas.groupId
|
||||
showSettingsModal.value = true
|
||||
}
|
||||
|
||||
const closeSettings = () => {
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await authFetch(`/api/canvases/${editCanvas.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
name: editCanvas.name,
|
||||
isPublic: editCanvas.isPublic,
|
||||
groupId: editCanvas.groupId
|
||||
}
|
||||
})
|
||||
await loadData()
|
||||
closeSettings()
|
||||
} catch (error) {
|
||||
alert('Ошибка при сохранении: ' + (error.data?.message || error.message))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const currentUserId = computed(() => user.value?.id || '')
|
||||
|
||||
// Load data
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [canvasesRes, groupsRes] = await Promise.all([
|
||||
authFetch('/api/canvas'),
|
||||
authFetch('/api/user/groups')
|
||||
])
|
||||
canvases.value = canvasesRes.canvases || []
|
||||
userGroups.value = groupsRes.groups || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter canvases based on active tab
|
||||
const filteredCanvases = computed(() => {
|
||||
const userId = currentUserId.value
|
||||
if (!userId) return []
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'personal':
|
||||
return canvases.value.filter(c => c.ownerId === userId && (!c.groupId || c.groupId === ''))
|
||||
case 'team':
|
||||
return canvases.value.filter(c => c.groupId && c.groupId !== '')
|
||||
case 'public':
|
||||
return canvases.value.filter(c => c.isPublic)
|
||||
default:
|
||||
return canvases.value
|
||||
}
|
||||
})
|
||||
|
||||
// Tab styling
|
||||
const tabClass = (tab) => {
|
||||
return activeTab.value === tab
|
||||
? 'bg-slate-800 text-slate-200'
|
||||
: 'text-slate-400 hover:text-slate-300'
|
||||
}
|
||||
|
||||
// Canvas actions
|
||||
const openCanvas = (id) => {
|
||||
navigateTo(`/canvas/${id}`)
|
||||
}
|
||||
|
||||
const createCanvas = async () => {
|
||||
try {
|
||||
const payload = {
|
||||
name: newCanvas.name,
|
||||
isPublic: newCanvas.visibility === 'public',
|
||||
groupId: newCanvas.groupId || undefined
|
||||
}
|
||||
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
|
||||
showCreateModal.value = false
|
||||
newCanvas.name = ''
|
||||
newCanvas.visibility = 'private'
|
||||
newCanvas.groupId = ''
|
||||
await loadData()
|
||||
navigateTo(`/canvas/${result.canvas.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create canvas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const duplicateCanvas = async (id) => {
|
||||
try {
|
||||
const result = await authFetch(`/api/canvas/${id}/duplicate`, { method: 'POST' })
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate canvas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const triggerImport = () => {
|
||||
importInput.value?.click()
|
||||
}
|
||||
|
||||
const handleImport = async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
const name = file.name.replace(/\.canvas$|\.json$/, '')
|
||||
const payload = {
|
||||
name,
|
||||
nodes: data.nodes || [],
|
||||
edges: data.edges || [],
|
||||
isPublic: false
|
||||
}
|
||||
const result = await authFetch('/api/canvas', { method: 'POST', body: payload })
|
||||
await loadData()
|
||||
navigateTo(`/canvas/${result.canvas.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to import canvas:', error)
|
||||
alert('Ошибка импорта: проверьте формат файла')
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const exportCanvas = async (id) => {
|
||||
try {
|
||||
const canvas = await authFetch(`/api/canvas/${id}`)
|
||||
const blob = new Blob([JSON.stringify(canvas, null, 2)], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${canvas.name || 'canvas'}.canvas`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
console.error('Failed to export canvas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const canDeleteCanvas = (canvas) => {
|
||||
return canvas.ownerId === currentUserId.value
|
||||
}
|
||||
|
||||
const deleteCanvas = async (id) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить эту карту?')) return
|
||||
try {
|
||||
await authFetch(`/api/canvas/${id}`, { method: 'DELETE' })
|
||||
await loadData()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete canvas:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getOwnerName = (ownerId) => {
|
||||
return ownerId === currentUserId.value ? 'вы' : `Пользователь ${ownerId}`
|
||||
}
|
||||
|
||||
const getGroupName = (groupId) => {
|
||||
const group = userGroups.value.find(g => g.id === groupId)
|
||||
return group?.name || 'Unknown Group'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated.value) {
|
||||
loadData()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.isAuthenticated.value, (isAuth) => {
|
||||
if (isAuth) loadData()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="h-screen w-full">
|
||||
<ClientOnly>
|
||||
<FlowEditor canvas-id="demo" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center justify-center p-4 md:p-8">
|
||||
<div v-if="!isAuthenticated" class="text-center max-w-md px-4">
|
||||
<h1 class="text-5xl md:text-6xl font-black text-blue-500 mb-6 tracking-tight">XFlow</h1>
|
||||
<p class="text-slate-400 text-lg mb-10 leading-relaxed">Визуальное управление задачами в формате Obsidian Canvas.</p>
|
||||
<NuxtLink
|
||||
to="/login"
|
||||
class="w-full md:w-auto inline-block px-10 py-4 bg-blue-600 hover:bg-blue-500 text-white rounded-2xl font-bold text-lg shadow-xl shadow-blue-900/40 transition-all transform active:scale-95"
|
||||
>
|
||||
Начать работу
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div v-else class="w-full max-w-6xl">
|
||||
<div class="flex items-center justify-between mb-8 px-2">
|
||||
<h1 class="text-3xl md:text-4xl font-bold text-white">Дашборд</h1>
|
||||
<div class="md:hidden w-10 h-10 bg-slate-800 rounded-full flex items-center justify-center text-xl">✨</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<NuxtLink
|
||||
to="/canvases"
|
||||
class="p-6 bg-slate-800 border border-slate-700 rounded-lg hover:border-blue-500 transition group"
|
||||
>
|
||||
<div class="text-3xl mb-2">🗺️</div>
|
||||
<h3 class="font-semibold group-hover:text-blue-400 transition">Проекты</h3>
|
||||
<p class="text-sm text-slate-400">{{ homeStats.canvases }} канвасов доступно</p>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
|
||||
<div class="text-3xl mb-2">✅</div>
|
||||
<h3 class="font-semibold">Задачи</h3>
|
||||
<p class="text-sm text-slate-400">{{ homeStats.tasks.completed }} / {{ homeStats.tasks.total }} выполнено</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-slate-800 border border-slate-700 rounded-lg">
|
||||
<div class="text-3xl mb-2">🏆</div>
|
||||
<h3 class="font-semibold">Ачивки</h3>
|
||||
<p class="text-sm text-slate-400">{{ homeStats.achievements }} разблокировано</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<InstallPWA />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated, token } = useAuth()
|
||||
const homeStats = ref({
|
||||
canvases: 0,
|
||||
tasks: { total: 0, completed: 0 },
|
||||
achievements: 0
|
||||
})
|
||||
const loadingStats = ref(true)
|
||||
|
||||
const fetchHomeStats = async () => {
|
||||
if (!isAuthenticated.value) return
|
||||
try {
|
||||
const data = await $fetch('/api/user/stats', {
|
||||
headers: { Authorization: `Bearer ${token.value}` }
|
||||
})
|
||||
homeStats.value = data.stats
|
||||
} catch (e) {
|
||||
console.error('Failed to load home stats', e)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchHomeStats()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<!-- Splash / Loader while checking Firebase session -->
|
||||
<div v-if="isInitializing" class="text-center animate-pulse">
|
||||
<div class="text-6xl mb-4">🚀</div>
|
||||
<div class="text-slate-400 font-medium">Проверка авторизации...</div>
|
||||
</div>
|
||||
|
||||
<!-- Login UI -->
|
||||
<div v-else class="w-full max-w-sm bg-slate-800 border border-slate-700 rounded-xl p-6 shadow-xl text-center">
|
||||
<div class="mb-6">
|
||||
<div class="text-5xl mb-4">🚀</div>
|
||||
<h1 class="text-2xl font-bold mb-2">XFlow</h1>
|
||||
<p class="text-slate-400 text-sm">Визуальный менеджер задач</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="signInWithGoogle"
|
||||
:disabled="loading"
|
||||
class="w-full py-3 bg-white hover:bg-gray-100 text-slate-900 rounded-lg font-medium transition flex items-center justify-center gap-3 disabled:opacity-50"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
{{ loading ? 'Вход...' : 'Войти через Google' }}
|
||||
</button>
|
||||
|
||||
<p class="mt-4 text-xs text-slate-500">
|
||||
Авторизация через Firebase Google Sign-In
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
layout: 'default'
|
||||
})
|
||||
|
||||
const { googleSignIn, initAuth, isAuthenticated, isInitializing } = useAuth()
|
||||
const loading = ref(false)
|
||||
|
||||
const signInWithGoogle = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
await googleSignIn()
|
||||
} catch (error) {
|
||||
console.error('Google sign-in failed:', error)
|
||||
alert('Ошибка входа через Google: ' + (error.message || 'Неизвестная ошибка'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAuthenticated.value) {
|
||||
navigateTo('/')
|
||||
}
|
||||
})
|
||||
|
||||
watch(isAuthenticated, (val) => {
|
||||
if (val) navigateTo('/')
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950">
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<!-- Profile Header -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img :src="profile.avatar" :alt="profile.displayName"
|
||||
class="w-20 h-20 rounded-full bg-slate-800" />
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
|
||||
<p class="text-slate-400 mt-1">{{ profile.bio }}</p>
|
||||
<div class="flex items-center gap-4 mt-3 text-sm text-slate-500">
|
||||
<span>Joined {{ formatDate(profile.joinedAt) }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ profile.canvasCount }} canvases</span>
|
||||
<span>•</span>
|
||||
<span>{{ profile.followerCount }} followers</span>
|
||||
<span>•</span>
|
||||
<span>{{ profile.followingCount }} following</span>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="!isOwnProfile" @click="toggleFollow"
|
||||
:class="followButtonClass"
|
||||
class="px-4 py-2 rounded-lg font-medium transition">
|
||||
{{ isFollowing ? 'Following' : 'Follow' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-6 bg-slate-900 rounded-lg p-1 w-fit">
|
||||
<button @click="activeTab = 'canvases'" :class="tabClass('canvases')" class="px-4 py-2 rounded text-sm font-medium transition">
|
||||
Canvases
|
||||
</button>
|
||||
<button @click="activeTab = 'activity'" :class="tabClass('activity')" class="px-4 py-2 rounded text-sm font-medium transition">
|
||||
Activity
|
||||
</button>
|
||||
<button v-if="isOwnProfile" @click="activeTab = 'groups'" :class="tabClass('groups')" class="px-4 py-2 rounded text-sm font-medium transition">
|
||||
Groups
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Canvases Tab -->
|
||||
<div v-if="activeTab === 'canvases'" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div v-for="canvas in userCanvases" :key="canvas.id"
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl p-4 hover:border-slate-600 transition cursor-pointer"
|
||||
@click="openCanvas(canvas.id)">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-slate-200 hover:text-blue-400 transition">{{ canvas.name }}</h3>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ canvas.nodeCount }} nodes • {{ formatDate(canvas.updatedAt) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<span v-if="canvas.isPublic" class="text-xs px-2 py-1 bg-emerald-900/50 text-emerald-300 rounded">Public</span>
|
||||
<span v-else-if="canvas.groupId" class="text-xs px-2 py-1 bg-blue-900/50 text-blue-300 rounded">Shared</span>
|
||||
<span v-else class="text-xs px-2 py-1 bg-slate-700 text-slate-400 rounded">Private</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400">
|
||||
{{ formatDate(canvas.updatedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activity Tab -->
|
||||
<div v-if="activeTab === 'activity'" class="space-y-3">
|
||||
<div v-for="activity in userActivities" :key="activity.id"
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full"></div>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm text-slate-300">
|
||||
<span class="font-medium">{{ formatActivity(activity.action) }}</span>
|
||||
<span v-if="activity.targetType === 'canvas'" class="text-blue-400"> canvas</span>
|
||||
<span v-if="activity.targetType === 'group'" class="text-emerald-400"> group</span>
|
||||
<span v-if="activity.targetType === 'user'" class="text-purple-400"> user</span>
|
||||
</p>
|
||||
<p class="text-xs text-slate-500 mt-1">{{ formatDate(activity.timestamp) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Groups Tab -->
|
||||
<div v-if="activeTab === 'groups' && isOwnProfile" class="space-y-6">
|
||||
|
||||
<!-- My Groups -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider">Мои группы</h2>
|
||||
<button @click="showCreateGroup = true" class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-xs font-medium">
|
||||
+ Создать группу
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div v-for="group in userGroups" :key="group.id"
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
|
||||
</div>
|
||||
<span :class="group.membershipRole === 'admin' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'"
|
||||
class="text-xs px-2 py-1 rounded ml-2 shrink-0">{{ group.membershipRole }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-xs text-slate-500">{{ group.isPublic ? '🌐 Публичная' : '🔒 Приватная' }}</span>
|
||||
<button v-if="group.ownerId !== currentUserId" @click="leaveGroup(group.id)"
|
||||
class="text-xs text-red-400 hover:text-red-300 transition">Выйти</button>
|
||||
<button v-else @click="deleteGroup(group.id)"
|
||||
class="text-xs text-red-400 hover:text-red-300 transition">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm col-span-2 py-4 text-center">
|
||||
Вы не состоите ни в одной группе
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Public Groups -->
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">Все публичные группы</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div v-for="group in availableGroups" :key="group.id"
|
||||
class="bg-slate-900 border border-slate-700 rounded-xl p-4 flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-slate-200 truncate">{{ group.name }}</h3>
|
||||
<p class="text-xs text-slate-500 mt-0.5">{{ group.description }}</p>
|
||||
</div>
|
||||
<button v-if="!isMember(group.id)" @click="joinGroup(group.id)"
|
||||
class="ml-3 px-3 py-1 bg-emerald-600 hover:bg-emerald-500 text-white rounded text-xs shrink-0">Вступить</button>
|
||||
<span v-else class="ml-3 text-xs text-slate-500 shrink-0">✓ Участник</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Group Modal -->
|
||||
<div v-if="showCreateGroup" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" @click.self="showCreateGroup = false">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md">
|
||||
<h2 class="text-lg font-semibold text-slate-200 mb-4">Создать группу</h2>
|
||||
<form @submit.prevent="createGroup" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-sm text-slate-400 block mb-1">Название *</label>
|
||||
<input v-model="newGroup.name" required class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Название группы" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-slate-400 block mb-1">Описание</label>
|
||||
<input v-model="newGroup.description" class="w-full px-3 py-2 bg-slate-800 border border-slate-600 rounded-lg text-white" placeholder="Краткое описание" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" v-model="newGroup.isPublic" id="isPublic" class="rounded" />
|
||||
<label for="isPublic" class="text-sm text-slate-300">Публичная (другие могут вступить)</label>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit" class="flex-1 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium">Создать</button>
|
||||
<button type="button" @click="showCreateGroup = false" class="flex-1 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded-lg">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const route = useRoute()
|
||||
const authFetch = async (url, opts = {}) => {
|
||||
try {
|
||||
return await $fetch(url, {
|
||||
...opts,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}`, ...opts.headers }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error.response?.status === 401) {
|
||||
auth.logout()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const profileId = computed(() => route.params.id || auth.user.value?.id)
|
||||
const currentUserId = computed(() => auth.user.value?.id || '')
|
||||
const isOwnProfile = computed(() => profileId.value === currentUserId.value)
|
||||
|
||||
const profile = ref({
|
||||
userId: profileId.value,
|
||||
displayName: 'Loading...',
|
||||
bio: '',
|
||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${profileId.value}`,
|
||||
joinedAt: new Date().toISOString(),
|
||||
canvasCount: 0,
|
||||
followerCount: 0,
|
||||
followingCount: 0
|
||||
})
|
||||
|
||||
const userCanvases = ref([])
|
||||
const userActivities = ref([])
|
||||
const userGroups = ref([])
|
||||
const allGroups = ref([])
|
||||
const isFollowing = ref(false)
|
||||
const activeTab = ref('canvases')
|
||||
const showCreateGroup = ref(false)
|
||||
const newGroup = reactive({ name: '', description: '', isPublic: true })
|
||||
|
||||
const availableGroups = computed(() => allGroups.value.filter(g => g.isPublic))
|
||||
const isMember = (groupId) => userGroups.value.some(g => g.id === groupId)
|
||||
|
||||
// Load profile data
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const [profileData, canvasesData, activitiesData] = await Promise.all([
|
||||
authFetch(`/api/users/${profileId.value}/profile`),
|
||||
authFetch(`/api/users/${profileId.value}/canvases`),
|
||||
authFetch(`/api/users/${profileId.value}/activity`)
|
||||
])
|
||||
|
||||
profile.value = profileData.profile
|
||||
userCanvases.value = canvasesData.canvases
|
||||
userActivities.value = activitiesData.activities
|
||||
|
||||
// Redirect to pretty URL if using UID but username exists
|
||||
if (profile.value.user?.username && route.params.id === profile.value.userId) {
|
||||
navigateTo(`/profile/${profile.value.user.username}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isOwnProfile.value) {
|
||||
const [groupsData, allGroupsData] = await Promise.all([
|
||||
authFetch('/api/user/groups'),
|
||||
authFetch('/api/groups')
|
||||
])
|
||||
userGroups.value = groupsData.groups
|
||||
allGroups.value = allGroupsData.groups
|
||||
}
|
||||
|
||||
if (!isOwnProfile.value) {
|
||||
try {
|
||||
const followData = await authFetch(`/api/users/${profileId.value}/follow-status`)
|
||||
isFollowing.value = followData.isFollowing
|
||||
} catch {}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Actions
|
||||
const openCanvas = (id) => {
|
||||
navigateTo(`/canvas/${id}`)
|
||||
}
|
||||
|
||||
const toggleFollow = async () => {
|
||||
try {
|
||||
const method = isFollowing.value ? 'DELETE' : 'POST'
|
||||
await authFetch(`/api/users/${profileId.value}/follow`, { method })
|
||||
isFollowing.value = !isFollowing.value
|
||||
profile.value.followerCount += isFollowing.value ? 1 : -1
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle follow:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const createGroup = async () => {
|
||||
try {
|
||||
const res = await authFetch('/api/groups', { method: 'POST', body: { ...newGroup } })
|
||||
userGroups.value.push({ ...res.group, membershipRole: 'admin' })
|
||||
allGroups.value.push(res.group)
|
||||
showCreateGroup.value = false
|
||||
newGroup.name = ''
|
||||
newGroup.description = ''
|
||||
newGroup.isPublic = true
|
||||
} catch (e) {
|
||||
console.error('Failed to create group:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const joinGroup = async (groupId) => {
|
||||
try {
|
||||
await authFetch(`/api/groups/${groupId}/join`, { method: 'POST' })
|
||||
const group = allGroups.value.find(g => g.id === groupId)
|
||||
if (group) userGroups.value.push({ ...group, membershipRole: 'member' })
|
||||
} catch (e) {
|
||||
console.error('Failed to join group:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const leaveGroup = async (groupId) => {
|
||||
if (!confirm('Выйти из группы?')) return
|
||||
try {
|
||||
await authFetch(`/api/groups/${groupId}/leave`, { method: 'POST' })
|
||||
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
|
||||
} catch (e) {
|
||||
console.error('Failed to leave group:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteGroup = async (groupId) => {
|
||||
if (!confirm('Удалить группу? Это действие необратимо.')) return
|
||||
try {
|
||||
await authFetch(`/api/groups/${groupId}`, { method: 'DELETE' })
|
||||
userGroups.value = userGroups.value.filter(g => g.id !== groupId)
|
||||
allGroups.value = allGroups.value.filter(g => g.id !== groupId)
|
||||
} catch (e) {
|
||||
console.error('Failed to delete group:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
const formatDate = (date) => {
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const formatActivity = (action) => {
|
||||
const actions = {
|
||||
created_canvas: 'created',
|
||||
updated_canvas: 'updated',
|
||||
joined_group: 'joined',
|
||||
left_group: 'left',
|
||||
followed_user: 'followed',
|
||||
unfollowed_user: 'unfollowed'
|
||||
}
|
||||
return actions[action] || action
|
||||
}
|
||||
|
||||
const tabClass = (tab) => {
|
||||
return activeTab.value === tab
|
||||
? 'bg-slate-800 text-slate-200'
|
||||
: 'text-slate-400 hover:text-slate-300'
|
||||
}
|
||||
|
||||
const followButtonClass = computed(() => {
|
||||
return isFollowing.value
|
||||
? 'bg-slate-700 hover:bg-slate-600 text-white'
|
||||
: 'bg-blue-600 hover:bg-blue-500 text-white'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated.value) {
|
||||
loadProfile()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => auth.isAuthenticated.value, (isAuth) => {
|
||||
if (isAuth) loadProfile()
|
||||
})
|
||||
watch(profileId, loadProfile)
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-950">
|
||||
<div class="max-w-4xl mx-auto p-6">
|
||||
<!-- Profile Header -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 mb-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img :src="profile.avatar" :alt="profile.displayName"
|
||||
class="w-20 h-20 rounded-full bg-slate-800" />
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-slate-100">{{ profile.displayName }}</h1>
|
||||
<p class="text-slate-400">{{ profile.email }}</p>
|
||||
<p class="text-sm text-slate-500 mt-2">{{ profile.bio }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-sm text-slate-500">Участник с</div>
|
||||
<div class="text-slate-300">{{ new Date(profile.createdAt).toLocaleDateString('ru-RU') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-2xl font-bold text-blue-400">{{ stats.canvases }}</div>
|
||||
<div class="text-sm text-slate-400">Канвасов</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-2xl font-bold text-green-400">{{ stats.nodes }}</div>
|
||||
<div class="text-sm text-slate-400">Узлов создано</div>
|
||||
</div>
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||
<div class="text-2xl font-bold text-purple-400">{{ stats.groups }}</div>
|
||||
<div class="text-sm text-slate-400">Групп</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Canvases -->
|
||||
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-xl font-semibold text-slate-100 mb-4">Последние канвасы</h2>
|
||||
<div class="space-y-3">
|
||||
<div v-for="canvas in recentCanvases" :key="canvas.id"
|
||||
class="flex items-center justify-between p-3 bg-slate-800 rounded-lg">
|
||||
<div>
|
||||
<h3 class="font-medium text-slate-100">{{ canvas.name }}</h3>
|
||||
<p class="text-sm text-slate-400">{{ canvas.description }}</p>
|
||||
</div>
|
||||
<NuxtLink :to="`/canvas/${canvas.id}`"
|
||||
class="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white rounded text-sm transition-colors">
|
||||
Открыть
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuth()
|
||||
|
||||
const userId = route.params.id
|
||||
|
||||
// Mock profile data - replace with actual API call
|
||||
const profile = ref({
|
||||
id: userId,
|
||||
displayName: 'Пользователь XFlow',
|
||||
email: 'user@example.com',
|
||||
bio: 'Люблю создавать ментальные карты и организовывать идеи',
|
||||
avatar: 'https://via.placeholder.com/80',
|
||||
createdAt: new Date('2024-01-01').toISOString()
|
||||
})
|
||||
|
||||
const stats = ref({
|
||||
canvases: 12,
|
||||
nodes: 156,
|
||||
groups: 3
|
||||
})
|
||||
|
||||
const recentCanvases = ref([
|
||||
{
|
||||
id: 'xflow-project',
|
||||
name: 'Обзор Проекта XFlow',
|
||||
description: 'Основная ментальная карта проекта'
|
||||
},
|
||||
{
|
||||
id: 'demo',
|
||||
name: 'Демо канвас',
|
||||
description: 'Пример ментальной карты'
|
||||
},
|
||||
{
|
||||
id: 'ideas',
|
||||
name: 'Идеи 2024',
|
||||
description: 'Сбор идей и мыслей'
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
// Load profile data
|
||||
console.log('Loading profile for user:', userId)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
<template>
|
||||
<div class="max-w-xl mx-auto p-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Настройки</h1>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Profile Settings -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>👤</span> Личный профиль
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<img :src="profileForm.avatar" class="w-16 h-16 rounded-full bg-slate-700 object-cover border-2 border-blue-500/30" />
|
||||
<div class="flex-1">
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Ссылка на аватар</label>
|
||||
<input v-model="profileForm.avatar" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Имя (Display Name)</label>
|
||||
<input v-model="profileForm.displayName" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">О себе (Bio)</label>
|
||||
<textarea v-model="profileForm.bio" rows="3" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-3 py-1.5 text-sm text-slate-200 outline-none focus:border-blue-500 resize-none"></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="saveProfile"
|
||||
:disabled="saving"
|
||||
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition disabled:opacity-50"
|
||||
>
|
||||
{{ saving ? 'Сохранение...' : 'Сохранить изменения' }}
|
||||
</button>
|
||||
<p v-if="saveMessage" :class="saveMessage.type === 'error' ? 'text-red-400' : 'text-emerald-400'" class="text-center text-xs mt-2">
|
||||
{{ saveMessage.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Groups -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>👥</span> Мои Группы
|
||||
</h2>
|
||||
<div v-if="userGroups.length === 0" class="text-slate-500 text-sm py-4">
|
||||
Вы не состоите ни в одной группе.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="g in userGroups" :key="g.id" class="flex items-center justify-between p-3 bg-slate-900/50 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<div class="font-medium text-slate-200">{{ g.name }}</div>
|
||||
<div class="text-xs text-slate-500">{{ g.description }}</div>
|
||||
</div>
|
||||
<span :class="g.membershipRole === 'admin' ? 'text-purple-400 bg-purple-900/20' : 'text-blue-400 bg-blue-900/20'" class="text-[10px] uppercase font-bold px-2 py-0.5 rounded border border-current/20">
|
||||
{{ g.membershipRole }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Push Notifications -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>🔔</span> Push-уведомления
|
||||
</h2>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Статус поддержки</span>
|
||||
<span :class="isSupported ? 'text-emerald-400' : 'text-red-400'">
|
||||
{{ isSupported ? 'Доступно' : 'Недоступно' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-slate-300">Разрешение</span>
|
||||
<span :class="permissionGranted ? 'text-emerald-400' : 'text-yellow-400'">
|
||||
{{ permissionGranted ? 'Разрешено' : 'Не запрошено' }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isSupported && !permissionGranted"
|
||||
@click="enablePush"
|
||||
class="w-full py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition"
|
||||
>
|
||||
Включить уведомления
|
||||
</button>
|
||||
<button
|
||||
v-if="isSupported && permissionGranted && !subscription"
|
||||
@click="subscribe"
|
||||
class="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg font-medium transition"
|
||||
>
|
||||
Подписаться на пуши
|
||||
</button>
|
||||
<div v-if="subscription" class="text-sm text-emerald-400">
|
||||
✅ Подписка активна
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Achievements -->
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-xl p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<span>🏆</span> Ачивки
|
||||
</h2>
|
||||
<div v-if="achievements.length === 0" class="text-slate-400 text-sm">
|
||||
Пока нет разблокированных ачивок. Завершите задачи в Canvas!
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="ach in achievements"
|
||||
:key="ach.id"
|
||||
class="flex items-center gap-3 p-3 bg-slate-700 rounded-lg"
|
||||
>
|
||||
<span class="text-xl">{{ ach.icon || '🏆' }}</span>
|
||||
<div>
|
||||
<div class="font-medium">{{ ach.title }}</div>
|
||||
<div class="text-xs text-slate-400">{{ ach.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth'
|
||||
})
|
||||
|
||||
const { user } = useAuth()
|
||||
const { isSupported, subscription, requestPermission, subscribe: pushSubscribe } = usePush()
|
||||
const { achievements, loadAchievements } = useAchievements()
|
||||
|
||||
const permissionGranted = ref(false)
|
||||
const saving = ref(false)
|
||||
const saveMessage = ref(null)
|
||||
const userGroups = ref([])
|
||||
|
||||
const profileForm = reactive({
|
||||
displayName: '',
|
||||
username: '',
|
||||
bio: '',
|
||||
avatar: ''
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
const data = await $fetch(`/api/users/${user.value.id}/profile`, {
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
const groupsData = await $fetch('/api/user/groups', {
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
userGroups.value = groupsData.groups || []
|
||||
|
||||
profileForm.displayName = data.profile.displayName || ''
|
||||
profileForm.username = data.profile.user.username || ''
|
||||
profileForm.bio = data.profile.bio || ''
|
||||
profileForm.avatar = data.profile.avatar || ''
|
||||
} catch (e) {
|
||||
console.error('Failed to load profile', e)
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
saving.value = true
|
||||
saveMessage.value = null
|
||||
try {
|
||||
await $fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
body: profileForm,
|
||||
headers: { Authorization: `Bearer ${useAuth().token.value}` }
|
||||
})
|
||||
saveMessage.value = { type: 'success', text: 'Профиль обновлен успешно!' }
|
||||
// Update local user data if needed
|
||||
user.value.username = profileForm.username
|
||||
} catch (e) {
|
||||
saveMessage.value = { type: 'error', text: e.data?.message || 'Ошибка при сохранении' }
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const enablePush = async () => {
|
||||
const granted = await requestPermission()
|
||||
permissionGranted.value = granted
|
||||
if (granted) {
|
||||
await pushSubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
const subscribe = async () => {
|
||||
await pushSubscribe()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
permissionGranted.value = Notification.permission === 'granted'
|
||||
await Promise.all([
|
||||
loadAchievements(),
|
||||
loadProfile()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-slate-900 text-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h1 class="text-3xl font-bold text-white flex items-center gap-3">
|
||||
<span class="text-blue-500">📋</span> Мои Задачи
|
||||
</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<button @click="showCreateModal = true" class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition flex items-center gap-2">
|
||||
<span>➕</span> Новая задача
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Task Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" @click.self="showCreateModal = false">
|
||||
<div class="bg-slate-800 border border-slate-700 rounded-2xl p-6 w-full max-w-md shadow-2xl">
|
||||
<h2 class="text-xl font-bold text-white mb-6">Создать новую задачу</h2>
|
||||
<form @submit.prevent="createTask" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Название задачи</label>
|
||||
<input v-model="newTask.title" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500" placeholder="Что нужно сделать?" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Проект (Канвас)</label>
|
||||
<select v-model="newTask.canvasId" required class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||||
<option value="" disabled>Выберите проект...</option>
|
||||
<option v-for="c in availableCanvases" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Приоритет</label>
|
||||
<select v-model="newTask.priority" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||||
<option value="low">Низкий</option>
|
||||
<option value="medium">Средний</option>
|
||||
<option value="high">Высокий</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 uppercase font-bold">Статус</label>
|
||||
<select v-model="newTask.state" class="w-full mt-1 bg-slate-900 border border-slate-700 rounded-lg px-4 py-2.5 text-slate-200 outline-none focus:border-blue-500">
|
||||
<option v-for="col in columns" :key="col.id" :value="col.id">{{ col.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" :disabled="creating" class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold transition disabled:opacity-50">
|
||||
{{ creating ? 'Создание...' : 'Создать задачу' }}
|
||||
</button>
|
||||
<button type="button" @click="showCreateModal = false" class="px-6 py-3 bg-slate-700 hover:bg-slate-600 text-white rounded-xl font-bold transition">Отмена</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center h-64">
|
||||
<div class="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="tasks.length === 0" class="flex flex-col items-center justify-center h-64 bg-slate-800 rounded-xl border border-slate-700">
|
||||
<span class="text-4xl mb-4">📭</span>
|
||||
<h3 class="text-xl font-medium text-white mb-2">У вас пока нет задач</h3>
|
||||
<p class="text-slate-400">Вам не назначено ни одной задачи в проектах.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<!-- Kanban Columns -->
|
||||
<div v-for="col in columns" :key="col.id" class="bg-slate-800 rounded-xl border border-slate-700 flex flex-col max-h-[80vh]">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between sticky top-0 bg-slate-800 rounded-t-xl z-10">
|
||||
<h3 class="font-bold text-white flex items-center gap-2">
|
||||
<span :class="col.color">{{ col.icon }}</span> {{ col.title }}
|
||||
</h3>
|
||||
<span class="bg-slate-700 text-slate-300 text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ getTasksForColumn(col.id).length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-4 flex-1 overflow-y-auto space-y-4 custom-scrollbar min-h-[200px]">
|
||||
<ClientOnly>
|
||||
<draggable
|
||||
v-model="columnsData[col.id]"
|
||||
group="tasks"
|
||||
item-key="id"
|
||||
class="space-y-4 min-h-[200px]"
|
||||
@change="(evt) => onTaskMove(evt, col.id)"
|
||||
>
|
||||
<template #item="{ element: task }">
|
||||
<div
|
||||
class="bg-slate-900 border border-slate-600 rounded-lg p-4 shadow-sm hover:border-blue-500 transition-colors cursor-pointer group"
|
||||
@click="openTask(task)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="text-xs font-medium px-2 py-0.5 rounded" :class="getPriorityClass(task)">
|
||||
{{ getPriorityLabel(task) }}
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 font-mono">{{ task.id.split('-')[1] || task.id }}</div>
|
||||
</div>
|
||||
|
||||
<h4 class="font-medium text-white mb-2 line-clamp-2">{{ getTaskTitle(task) }}</h4>
|
||||
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="text-xs text-slate-400 flex items-center gap-1">
|
||||
<span>🗺️</span>
|
||||
<span class="truncate max-w-[120px]">{{ task.canvasName }}</span>
|
||||
</div>
|
||||
<div v-if="task.alarm || task.data?.alarm" class="text-xs text-orange-400 flex items-center gap-1" :title="new Date(task.alarm || task.data?.alarm).toLocaleString('ru-RU')">
|
||||
<span>⏰</span>
|
||||
<span>{{ formatShortDate(task.alarm || task.data?.alarm) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</ClientOnly>
|
||||
|
||||
<div v-if="columnsData[col.id].length === 0" class="text-center py-6 border-2 border-dashed border-slate-700 rounded-lg pointer-events-none">
|
||||
<span class="text-slate-500 text-sm">Нет задач</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
const auth = useAuth()
|
||||
const loading = ref(true)
|
||||
const tasks = ref([])
|
||||
const columnsData = reactive({
|
||||
todo: [],
|
||||
in_progress: [],
|
||||
review: [],
|
||||
blocked: [],
|
||||
completed: []
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const availableCanvases = ref([])
|
||||
const newTask = reactive({
|
||||
title: '',
|
||||
canvasId: '',
|
||||
priority: 'medium',
|
||||
state: 'todo'
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ id: 'todo', title: 'К выполнению', icon: '📋', color: 'text-slate-400' },
|
||||
{ id: 'in_progress', title: 'В работе', icon: '⏳', color: 'text-blue-400' },
|
||||
{ id: 'review', title: 'На проверке', icon: '👀', color: 'text-purple-400' },
|
||||
{ id: 'blocked', title: 'Заблокировано', icon: '⛔', color: 'text-red-400' },
|
||||
{ id: 'completed', title: 'Завершено', icon: '✅', color: 'text-emerald-400' }
|
||||
]
|
||||
|
||||
// Fetch all tasks for current user
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/tasks', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
tasks.value = data.tasks || []
|
||||
|
||||
// Split tasks into columns
|
||||
columns.forEach(col => {
|
||||
columnsData[col.id] = tasks.value.filter(t => {
|
||||
const taskState = t.state || t.data?.state || 'todo'
|
||||
return taskState === col.id
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCanvases = async () => {
|
||||
try {
|
||||
const data = await $fetch('/api/canvas', {
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
availableCanvases.value = data.canvases || []
|
||||
if (availableCanvases.value.length > 0) {
|
||||
newTask.canvasId = availableCanvases.value[0].id
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load canvases', e)
|
||||
}
|
||||
}
|
||||
|
||||
const createTask = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
await $fetch('/api/tasks/create', {
|
||||
method: 'POST',
|
||||
body: newTask,
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
showCreateModal.value = false
|
||||
newTask.title = ''
|
||||
await fetchTasks() // Refresh list
|
||||
} catch (e) {
|
||||
alert('Ошибка при создании задачи: ' + (e.data?.message || e.message))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onTaskMove = async (evt, newStatus) => {
|
||||
if (evt.added) {
|
||||
const task = evt.added.element
|
||||
console.log(`Moving task ${task.id} to ${newStatus}`)
|
||||
|
||||
try {
|
||||
await $fetch('/api/tasks/update-status', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
taskId: task.id,
|
||||
canvasId: task.canvasId,
|
||||
newState: newStatus
|
||||
},
|
||||
headers: { Authorization: `Bearer ${auth.token.value}` }
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Failed to update task status', e)
|
||||
// Rollback on error? (In a production app, we should)
|
||||
alert('Не удалось обновить статус задачи на сервере')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getTasksForColumn = (state) => {
|
||||
return tasks.value.filter(t => {
|
||||
const taskState = t.state || t.data?.state || 'todo'
|
||||
return taskState === state
|
||||
})
|
||||
}
|
||||
|
||||
const getTaskTitle = (task) => {
|
||||
if (task.label) return task.label
|
||||
const text = task.text || task.data?.text
|
||||
if (text) {
|
||||
const lines = text.split('\n')
|
||||
const firstLine = lines.find(l => l.trim().startsWith('# ')) || lines.find(l => l.trim())
|
||||
return firstLine ? firstLine.replace('# ', '').trim().slice(0, 50) : 'Без названия'
|
||||
}
|
||||
return 'Задача'
|
||||
}
|
||||
|
||||
const getPriorityClass = (task) => {
|
||||
const priority = task.priority || task.data?.priority
|
||||
if (priority === 'high') return 'bg-red-900/50 text-red-300'
|
||||
if (priority === 'low') return 'bg-slate-700 text-slate-300'
|
||||
return 'bg-blue-900/50 text-blue-300' // medium
|
||||
}
|
||||
|
||||
const getPriorityLabel = (task) => {
|
||||
const priority = task.priority || task.data?.priority
|
||||
if (priority === 'high') return 'Высокий'
|
||||
if (priority === 'low') return 'Низкий'
|
||||
return 'Средний'
|
||||
}
|
||||
|
||||
const formatShortDate = (dateStr) => {
|
||||
try {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
|
||||
} catch {
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
|
||||
const openTask = (task) => {
|
||||
navigateTo(`/canvas/${task.canvasId}`)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user.value) {
|
||||
await auth.init()
|
||||
}
|
||||
await Promise.all([
|
||||
fetchTasks(),
|
||||
fetchCanvases()
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #475569 transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: #475569;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||