Compare commits
23 Commits
f58ca9b885
...
v1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fb5b15b75 | |||
| 8b3d0f3d19 | |||
| f4e1bae73f | |||
| 27235f6d54 | |||
| 34ec8944c1 | |||
| f7021313c6 | |||
| 02de1aaf02 | |||
| f60ac2a5f3 | |||
| 176afcff39 | |||
| 7b37f6cf70 | |||
| 7d324a6140 | |||
| 5c253b169e | |||
| 54bfb8edd7 | |||
| 9393cb823d | |||
| 7e8cae3801 | |||
| bc53c37fe0 | |||
| 008b235ac5 | |||
| dba93dc702 | |||
| 96fcf773f2 | |||
| 94da4dd1cf | |||
| 65d4d6c052 | |||
| 1cf0e3216b | |||
| be68f5e130 |
@@ -0,0 +1,55 @@
|
|||||||
|
# Version Control
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Dependencies & Cache
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.cache
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.env.local
|
||||||
|
!.env.example
|
||||||
|
!.env.example.coolify
|
||||||
|
|
||||||
|
# Native Mobile (Android)
|
||||||
|
apps/mobile
|
||||||
|
app
|
||||||
|
.gradle
|
||||||
|
**/.gradle
|
||||||
|
.kotlin
|
||||||
|
build
|
||||||
|
**/build
|
||||||
|
local.properties
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Desktop CLI & Contracts build
|
||||||
|
apps/desktop
|
||||||
|
packages/shared-contracts/dist
|
||||||
|
|
||||||
|
# Legacy configs & DB dumps
|
||||||
|
docker/legacy-server
|
||||||
|
*.sql
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Notes, Mindmaps & Brainstorming
|
||||||
|
.obsidian
|
||||||
|
.qwen
|
||||||
|
scratch
|
||||||
|
scripts
|
||||||
|
*.canvas
|
||||||
|
|
||||||
|
# IDE settings
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# ========================================================
|
||||||
|
# XFlow — Coolify Environment Variables Template
|
||||||
|
# ========================================================
|
||||||
|
# В Coolify UI:
|
||||||
|
# 1. Set Compose file = docker-compose.coolify.yml
|
||||||
|
# 2. Set Dockerfile = Dockerfile.coolify
|
||||||
|
# 3. Скопируйте эти переменные в раздел Environment Variables проекта.
|
||||||
|
|
||||||
|
APP_NAME=xflow
|
||||||
|
NODE_ENV=production
|
||||||
|
SITE_URL=https://xflow.yourdomain.com
|
||||||
|
TZ=Asia/Almaty
|
||||||
|
|
||||||
|
# База данных PostgreSQL
|
||||||
|
POSTGRES_USER=xflow
|
||||||
|
POSTGRES_PASSWORD=generate-secure-password
|
||||||
|
POSTGRES_DB=xflow
|
||||||
|
DATABASE_URL=postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow
|
||||||
|
|
||||||
|
# Redis (кеширование и фоновые задачи)
|
||||||
|
REDIS_URL=redis://redis:6379
|
||||||
|
|
||||||
|
# Безопасность и JWT
|
||||||
|
JWT_SECRET=generate-super-secret-jwt-key-minimum-32-chars
|
||||||
|
|
||||||
|
# ИИ провайдеры (OpenRouter / Qwen / DeepSeek)
|
||||||
|
OPEN_ROUTER_API_KEY=
|
||||||
|
NUXT_OPEN_ROUTER_API_KEY=
|
||||||
|
NUXT_QWEN_API_KEY=
|
||||||
|
DEEPSEEK_API_KEY=
|
||||||
|
|
||||||
|
# Firebase Push Notifications (Web Push PWA)
|
||||||
|
FIREBASE_API_KEY=
|
||||||
|
FIREBASE_AUTH_DOMAIN=
|
||||||
|
FIREBASE_PROJECT_ID=
|
||||||
|
FIREBASE_STORAGE_BUCKET=
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=
|
||||||
|
FIREBASE_APP_ID=
|
||||||
@@ -39,3 +39,7 @@ server/api/node_modules/
|
|||||||
obsidian-git/
|
obsidian-git/
|
||||||
|
|
||||||
/app/generated/prisma
|
/app/generated/prisma
|
||||||
|
|
||||||
|
*.apk
|
||||||
|
local.properties
|
||||||
|
.gradle/
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# ==========================================
|
||||||
|
# Stage 1: builder
|
||||||
|
# ==========================================
|
||||||
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
|
ENV HUSKY=0
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
ENV NODE_OPTIONS="--max-old-space-size=4096"
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG SITE_URL=""
|
||||||
|
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
||||||
|
ARG REDIS_URL="redis://redis:6379"
|
||||||
|
ARG JWT_SECRET="super-secret-jwt-key"
|
||||||
|
ARG OPEN_ROUTER_API_KEY=""
|
||||||
|
ARG NUXT_OPEN_ROUTER_API_KEY=""
|
||||||
|
ARG NUXT_QWEN_API_KEY=""
|
||||||
|
ARG DEEPSEEK_API_KEY=""
|
||||||
|
ARG FIREBASE_API_KEY="AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck"
|
||||||
|
ARG FIREBASE_AUTH_DOMAIN="xflow-d8dd5.firebaseapp.com"
|
||||||
|
ARG FIREBASE_PROJECT_ID="xflow-d8dd5"
|
||||||
|
ARG FIREBASE_STORAGE_BUCKET="xflow-d8dd5.firebasestorage.app"
|
||||||
|
ARG FIREBASE_MESSAGING_SENDER_ID="944408960366"
|
||||||
|
ARG FIREBASE_APP_ID="1:944408960366:web:4d2cd393e85a0ca9b4357f"
|
||||||
|
ARG TZ="Asia/Almaty"
|
||||||
|
|
||||||
|
ENV SITE_URL=${SITE_URL} \
|
||||||
|
DATABASE_URL=${DATABASE_URL} \
|
||||||
|
NUXT_DATABASE_URL=${DATABASE_URL} \
|
||||||
|
REDIS_URL=${REDIS_URL} \
|
||||||
|
NUXT_REDIS_URL=${REDIS_URL} \
|
||||||
|
JWT_SECRET=${JWT_SECRET} \
|
||||||
|
NUXT_JWT_SECRET=${JWT_SECRET} \
|
||||||
|
OPEN_ROUTER_API_KEY=${OPEN_ROUTER_API_KEY} \
|
||||||
|
NUXT_OPEN_ROUTER_API_KEY=${NUXT_OPEN_ROUTER_API_KEY} \
|
||||||
|
NUXT_QWEN_API_KEY=${NUXT_QWEN_API_KEY} \
|
||||||
|
DEEPSEEK_API_KEY=${DEEPSEEK_API_KEY} \
|
||||||
|
FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||||
|
NUXT_FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||||
|
NUXT_PUBLIC_FIREBASE_API_KEY=${FIREBASE_API_KEY} \
|
||||||
|
FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||||
|
NUXT_FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||||
|
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=${FIREBASE_AUTH_DOMAIN} \
|
||||||
|
FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||||
|
NUXT_FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||||
|
NUXT_PUBLIC_FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID} \
|
||||||
|
FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||||
|
NUXT_FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||||
|
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=${FIREBASE_STORAGE_BUCKET} \
|
||||||
|
FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||||
|
NUXT_FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||||
|
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=${FIREBASE_MESSAGING_SENDER_ID} \
|
||||||
|
FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||||
|
NUXT_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||||
|
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||||
|
TZ=${TZ}
|
||||||
|
|
||||||
|
# Копируем package.json, prisma схемы и конфиг
|
||||||
|
COPY apps/web/package*.json ./
|
||||||
|
COPY apps/web/prisma ./prisma
|
||||||
|
COPY apps/web/prisma.config.ts ./
|
||||||
|
RUN npm ci --include=optional && npx prisma generate
|
||||||
|
|
||||||
|
# Копируем исходный код apps/web
|
||||||
|
COPY apps/web ./
|
||||||
|
RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=development npm run build
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# Stage 2: runner
|
||||||
|
# ==========================================
|
||||||
|
FROM node:22-slim AS runner
|
||||||
|
|
||||||
|
ENV HUSKY=0
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends openssl tzdata ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
ARG SITE_URL=""
|
||||||
|
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
||||||
|
ARG REDIS_URL="redis://redis:6379"
|
||||||
|
ARG JWT_SECRET="super-secret-jwt-key"
|
||||||
|
ARG TZ="Asia/Almaty"
|
||||||
|
|
||||||
|
ENV SITE_URL=${SITE_URL} \
|
||||||
|
DATABASE_URL=${DATABASE_URL} \
|
||||||
|
NUXT_DATABASE_URL=${DATABASE_URL} \
|
||||||
|
REDIS_URL=${REDIS_URL} \
|
||||||
|
NUXT_REDIS_URL=${REDIS_URL} \
|
||||||
|
JWT_SECRET=${JWT_SECRET} \
|
||||||
|
NUXT_JWT_SECRET=${JWT_SECRET} \
|
||||||
|
TZ=${TZ}
|
||||||
|
|
||||||
|
COPY --from=builder /src/.output ./.output
|
||||||
|
COPY --from=builder /src/prisma ./prisma
|
||||||
|
COPY --from=builder /src/prisma.config.ts ./
|
||||||
|
COPY --from=builder /src/package.json ./
|
||||||
|
COPY --from=builder /src/node_modules ./node_modules
|
||||||
|
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV NUXT_HOST=0.0.0.0
|
||||||
|
ENV NUXT_PORT=3000
|
||||||
|
ENV NITRO_HOST=0.0.0.0
|
||||||
|
ENV NITRO_PORT=3000
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && exec node .output/server/index.mjs"]
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.PHONY: help up start dev-node db dev down restart logs logs-app logs-db ps db-push db-shell clean install-docker
|
||||||
|
|
||||||
|
CYAN := \033[36m
|
||||||
|
GREEN := \033[32m
|
||||||
|
YELLOW:= \033[33m
|
||||||
|
RESET := \033[0m
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "$(CYAN)XFlow Stack Management:$(RESET)"
|
||||||
|
@echo " $(GREEN)make up$(RESET) - Запустить стек (в Docker если установлен, иначе локально через Node)"
|
||||||
|
@echo " $(GREEN)make start$(RESET) - Запустить собранный Nuxt-сервер на Node (порт 3000)"
|
||||||
|
@echo " $(GREEN)make db$(RESET) - Запустить базы (PostgreSQL + Redis) в Docker"
|
||||||
|
@echo " $(GREEN)make dev$(RESET) - Поднять DB в Docker + запустить локальный Nuxt"
|
||||||
|
@echo " $(GREEN)make down$(RESET) - Остановить контейнеры Docker"
|
||||||
|
@echo " $(GREEN)make logs$(RESET) - Логи Docker-контейнеров"
|
||||||
|
@echo " $(GREEN)make install-docker$(RESET) - Инструкция по установке Docker на Ubuntu"
|
||||||
|
|
||||||
|
up:
|
||||||
|
@if command -v docker >/dev/null 2>&1; then \
|
||||||
|
echo "$(GREEN)Запуск контейнеров через Docker Compose...$(RESET)"; \
|
||||||
|
docker compose up -d --build; \
|
||||||
|
else \
|
||||||
|
echo "$(YELLOW)Docker не установлен в системе.$(RESET)"; \
|
||||||
|
echo "$(CYAN)Чтобы установить Docker: sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2 && sudo usermod -aG docker \$$USER$(RESET)"; \
|
||||||
|
echo "$(GREEN)Запускаем XFlow через Node.js на порту 3000...$(RESET)"; \
|
||||||
|
$(MAKE) start; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
start:
|
||||||
|
@PORT=3000 node /home/alexives/Desktop/StudioProjects/XFlow/apps/web/.output/server/index.mjs
|
||||||
|
|
||||||
|
db:
|
||||||
|
docker compose up -d db redis
|
||||||
|
@echo "$(GREEN)PostgreSQL (5432) и Redis (6379) запущены!$(RESET)"
|
||||||
|
|
||||||
|
dev: db
|
||||||
|
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npm --prefix apps/web run prisma db push || true
|
||||||
|
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npm --prefix apps/web run dev
|
||||||
|
|
||||||
|
down:
|
||||||
|
@if command -v docker >/dev/null 2>&1; then docker compose down; fi
|
||||||
|
|
||||||
|
restart:
|
||||||
|
@if command -v docker >/dev/null 2>&1; then docker compose restart; else $(MAKE) start; fi
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
logs-app:
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
logs-db:
|
||||||
|
docker compose logs -f db
|
||||||
|
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
db-push:
|
||||||
|
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npx --prefix apps/web prisma db push
|
||||||
|
|
||||||
|
db-shell:
|
||||||
|
docker compose exec db psql -U xflow -d xflow
|
||||||
|
|
||||||
|
clean:
|
||||||
|
docker compose down -v --remove-orphans
|
||||||
|
|
||||||
|
install-docker:
|
||||||
|
@echo "$(CYAN)Для установки Docker выполните:$(RESET)"
|
||||||
|
@echo "sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2"
|
||||||
|
@echo "sudo usermod -aG docker \$$USER"
|
||||||
|
@echo "$(CYAN)Затем обновите сессию: newgrp docker$(RESET)"
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { initializeApp, getApps } from 'firebase/app'
|
|
||||||
|
|
||||||
export default defineNuxtPlugin(() => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
const firebaseConfig = {
|
|
||||||
apiKey: config.public.firebaseApiKey,
|
|
||||||
authDomain: config.public.firebaseAuthDomain,
|
|
||||||
projectId: config.public.firebaseProjectId,
|
|
||||||
appId: config.public.firebaseAppId,
|
|
||||||
messagingSenderId: config.public.firebaseMessagingSenderId
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!firebaseConfig.apiKey) {
|
|
||||||
console.warn('[Firebase] API key not configured. Google Sign-In will not work.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!getApps().length) {
|
|
||||||
initializeApp(firebaseConfig)
|
|
||||||
console.log('[Firebase] Initialized')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { prisma } from '~/server/utils/prisma'
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const userId = getRouterParam(event, 'id')
|
|
||||||
|
|
||||||
if (!userId) {
|
|
||||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
|
||||||
}
|
|
||||||
|
|
||||||
let profile = await prisma.profile.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ userId: userId },
|
|
||||||
{ user: { username: userId } }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { canvases: true, followers: true, following: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// If profile doesn't exist but user does, create it
|
|
||||||
if (!profile) {
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [{ id: userId }, { username: userId }]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
throw createError({ statusCode: 404, message: 'User not found' })
|
|
||||||
}
|
|
||||||
|
|
||||||
profile = await prisma.profile.create({
|
|
||||||
data: {
|
|
||||||
userId: user.id,
|
|
||||||
displayName: user.username,
|
|
||||||
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${user.id}`
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
user: {
|
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: { canvases: true, followers: true, following: true }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
profile: {
|
|
||||||
...profile,
|
|
||||||
canvasCount: profile.user._count.canvases,
|
|
||||||
followerCount: profile.user._count.followers,
|
|
||||||
followingCount: profile.user._count.following
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -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,137 @@
|
|||||||
|
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 = 6
|
||||||
|
versionName = "1.5"
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError = false
|
||||||
|
checkReleaseBuilds = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Автоматическое копирование релизного APK на рабочий стол пользователя после сборки
|
||||||
|
|
||||||
|
val desktopDir = File(System.getProperty("user.home"), "Desktop")
|
||||||
|
|
||||||
|
|
||||||
|
tasks.register("copyReleaseApkToDesktop") {
|
||||||
|
notCompatibleWithConfigurationCache("Copies APK to user Desktop")
|
||||||
|
doLast {
|
||||||
|
val userHome = System.getProperty("user.home")
|
||||||
|
val desktopDir = File(userHome, "Desktop")
|
||||||
|
if (desktopDir.exists() && desktopDir.isDirectory) {
|
||||||
|
val apkFile = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
val vName = android.defaultConfig.versionName ?: "release"
|
||||||
|
apkFile.copyTo(File(desktopDir, "Avtex-v${vName}.apk"), overwrite = true)
|
||||||
|
apkFile.copyTo(File(desktopDir, "Avtex-release.apk"), overwrite = true)
|
||||||
|
println(">>> [AUTO-DEPLOY] Релизный APK скопирован на Рабочий стол: Avtex-v${vName}.apk и Avtex-release.apk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
tasks.findByName("assembleRelease")?.finalizedBy("copyReleaseApkToDesktop")
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,112 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".AvtexApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_avtex_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.Avtex">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_avtex_round">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Deep link intent-filter for Gemini / Assistant App Actions & XFlow links -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="xflow" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Google Assistant Native Note-Taking Action ("Сделай заметку...") -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.CREATE_NOTE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Android System Share Action (Поделиться текстом из любого приложения или ассистента) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Google Voice Actions Note Creation -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.android.gms.actions.CREATE_NOTE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Android Text Selection Process Action (выделение текста в любом месте -> отправить в Avtex) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="text/plain" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".engine.SleepTrackingService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse">
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Detects sleep and wake states using motion sensors and Sleep API for Home Assistant smart home automations." />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".engine.SleepReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="com.google.android.gms.permission.ACTIVITY_RECOGNITION">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.example.avtex.ACTION_SLEEP_API_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".engine.BootReceiver"
|
||||||
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_avtex_round">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -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,290 @@
|
|||||||
|
package com.example.avtex
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.role.RoleManager
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.example.avtex.theme.AvtexTheme
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private val speechRecognizerLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
|
||||||
|
val matches = result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||||
|
val spokenText = matches?.firstOrNull()
|
||||||
|
if (!spokenText.isNullOrBlank()) {
|
||||||
|
routeAndSaveNote(spokenText, preferredList = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val roleRequestLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
Toast.makeText(this, "Avtex назначен приложением заметок по умолчанию!", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
handleIncomingIntent(intent)
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
AvtexTheme {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalVoiceInputLauncher provides { launchVoiceInput() },
|
||||||
|
LocalRoleNotesRequester provides { requestDefaultNotesRole() }
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
MainNavigation()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleIncomingIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun launchVoiceInput() {
|
||||||
|
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "ru-RU")
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PROMPT, "Скажите заметку, задачу или фильм...")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
speechRecognizerLauncher.launch(intent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Speech recognizer not available", e)
|
||||||
|
Toast.makeText(this, "Голосовой ввод недоступен на этом устройстве", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestDefaultNotesRole() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
try {
|
||||||
|
val roleManager = getSystemService(RoleManager::class.java)
|
||||||
|
if (roleManager != null && roleManager.isRoleAvailable(RoleManager.ROLE_NOTES)) {
|
||||||
|
val roleIntent = roleManager.createRequestRoleIntent(RoleManager.ROLE_NOTES)
|
||||||
|
roleRequestLauncher.launch(roleIntent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to request ROLE_NOTES", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
|
||||||
|
startActivity(settingsIntent)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(this, "Откройте: Настройки -> Приложения -> Приложения по умолчанию", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncomingIntent(intent: Intent?) {
|
||||||
|
if (intent == null) return
|
||||||
|
val action = intent.action
|
||||||
|
Log.d(TAG, "Incoming intent action: $action, data: ${intent.data}")
|
||||||
|
|
||||||
|
// 1. Google Assistant / Android System Note Action (android.intent.action.CREATE_NOTE)
|
||||||
|
if (action == Intent.ACTION_CREATE_NOTE) {
|
||||||
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
?: intent.getStringExtra("android.intent.extra.TEXT")
|
||||||
|
?: intent.getStringExtra(Intent.EXTRA_TITLE)
|
||||||
|
val title = intent.getStringExtra(Intent.EXTRA_TITLE)
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
|
||||||
|
routeAndSaveNote(fullText, preferredList = null)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
launchVoiceInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Google Voice Actions (com.google.android.gms.actions.CREATE_NOTE)
|
||||||
|
if (action == "com.google.android.gms.actions.CREATE_NOTE") {
|
||||||
|
val text = intent.getStringExtra("com.google.android.gms.actions.extra.TEXT")
|
||||||
|
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
val name = intent.getStringExtra("com.google.android.gms.actions.extra.NAME")
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
val fullText = if (!name.isNullOrBlank() && name != text) "$name: $text" else text
|
||||||
|
routeAndSaveNote(fullText, preferredList = null)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
launchVoiceInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Android Text Selection (ACTION_PROCESS_TEXT)
|
||||||
|
if (action == Intent.ACTION_PROCESS_TEXT) {
|
||||||
|
val text = intent.getCharSequenceExtra(Intent.EXTRA_PROCESS_TEXT)?.toString()
|
||||||
|
if (!text.isNullOrBlank()) {
|
||||||
|
routeAndSaveNote(text, preferredList = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Android Share Sheet Action (android.intent.action.SEND with text/plain)
|
||||||
|
if (action == Intent.ACTION_SEND && intent.type?.startsWith("text/") == true) {
|
||||||
|
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
val subject = intent.getStringExtra(Intent.EXTRA_SUBJECT)
|
||||||
|
if (!sharedText.isNullOrBlank()) {
|
||||||
|
val fullText = if (!subject.isNullOrBlank()) "$subject — $sharedText" else sharedText
|
||||||
|
routeAndSaveNote(fullText, preferredList = null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Deep Link Action (xflow://...)
|
||||||
|
val data = intent.data
|
||||||
|
if (data != null && data.scheme == "xflow") {
|
||||||
|
val host = data.host ?: ""
|
||||||
|
val path = data.path ?: ""
|
||||||
|
if (host == "voice" || path.contains("record")) {
|
||||||
|
launchVoiceInput()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val listName = data.getQueryParameter("list_name")
|
||||||
|
?: data.getQueryParameter("name")
|
||||||
|
?: data.getQueryParameter("list")
|
||||||
|
|
||||||
|
val itemName = data.getQueryParameter("item_name")
|
||||||
|
?: data.getQueryParameter("item")
|
||||||
|
?: data.getQueryParameter("note_text")
|
||||||
|
?: data.getQueryParameter("text")
|
||||||
|
?: data.getQueryParameter("note_title")
|
||||||
|
|
||||||
|
if (!itemName.isNullOrBlank()) {
|
||||||
|
routeAndSaveNote(itemName, preferredList = listName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Google Assistant / Gemini Built-in Intent Extras
|
||||||
|
val extras = intent.extras
|
||||||
|
if (extras != null) {
|
||||||
|
val itemName = extras.getString("itemListElement.name")
|
||||||
|
?: extras.getString("item_name")
|
||||||
|
?: extras.getString("note.text")
|
||||||
|
?: extras.getString("note_text")
|
||||||
|
?: extras.getString("text")
|
||||||
|
|
||||||
|
val listName = extras.getString("itemList.name")
|
||||||
|
?: extras.getString("list_name")
|
||||||
|
?: extras.getString("category")
|
||||||
|
|
||||||
|
if (!itemName.isNullOrBlank()) {
|
||||||
|
routeAndSaveNote(itemName, preferredList = listName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun routeAndSaveNote(rawText: String, preferredList: String?) {
|
||||||
|
val cleanText = rawText.trim()
|
||||||
|
if (cleanText.isBlank()) return
|
||||||
|
|
||||||
|
val (targetList, category) = resolveListAndCategory(cleanText, preferredList)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val repo = AvtexApplication.instance.notesRepository
|
||||||
|
repo.addNoteItem(
|
||||||
|
title = cleanText,
|
||||||
|
listTitle = targetList,
|
||||||
|
category = category
|
||||||
|
)
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
"✅ Avtex: Записано в «$targetList»:\n$cleanText",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
AvtexApplication.instance.settingsRepository.addLog(
|
||||||
|
tag = "Assistant",
|
||||||
|
message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"",
|
||||||
|
isImportant = true
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save note from assistant", e)
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
"Ошибка сохранения: ${e.localizedMessage}",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveListAndCategory(text: String, preferredList: String?): Pair<String, String> {
|
||||||
|
val lower = text.lowercase()
|
||||||
|
|
||||||
|
// Если список был явно передан в интенте
|
||||||
|
if (!preferredList.isNullOrBlank()) {
|
||||||
|
val prefLower = preferredList.lowercase()
|
||||||
|
return when {
|
||||||
|
prefLower.contains("фильм") || prefLower.contains("кино") || prefLower.contains("сериал") || prefLower.contains("movie") ->
|
||||||
|
Pair(preferredList, "movies")
|
||||||
|
prefLower.contains("покупк") || prefLower.contains("магаз") || prefLower.contains("купить") || prefLower.contains("shop") ->
|
||||||
|
Pair(preferredList, "shopping")
|
||||||
|
prefLower.contains("задач") || prefLower.contains("дело") || prefLower.contains("task") || prefLower.contains("todo") ->
|
||||||
|
Pair(preferredList, "tasks")
|
||||||
|
else -> Pair(preferredList, "general")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Авто-маршрутизация по содержанию текста
|
||||||
|
return when {
|
||||||
|
lower.contains("фильм") || lower.contains("сериал") || lower.contains("кино") ||
|
||||||
|
lower.contains("посмотреть") || lower.contains("сезон") || lower.contains("актер") ||
|
||||||
|
lower.contains("режиссер") || lower.contains("movie") || lower.contains("cinema") ->
|
||||||
|
Pair("Фильмы на просмотр", "movies")
|
||||||
|
|
||||||
|
lower.contains("купить") || lower.contains("покупки") || lower.contains("магазин") ||
|
||||||
|
lower.contains("взять") || lower.contains("продукты") || lower.contains("цена") ||
|
||||||
|
lower.contains("аптек") || lower.contains("buy") || lower.contains("shop") ->
|
||||||
|
Pair("Покупки", "shopping")
|
||||||
|
|
||||||
|
lower.contains("задача") || lower.contains("сделать") || lower.contains("напомни") ||
|
||||||
|
lower.contains("позвонить") || lower.contains("отправить") || lower.contains("todo") ->
|
||||||
|
Pair("Задачи", "tasks")
|
||||||
|
|
||||||
|
else -> Pair("Быстрые заметки", "general")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AvtexMainActivity"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Hub
|
||||||
|
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.MindmapsScreen
|
||||||
|
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.Hub, 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.Bedtime, 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.Settings, contentDescription = "Настройки") },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
"Настройки",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = navColors
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
when (selectedTab) {
|
||||||
|
0 -> MindmapsScreen(modifier = Modifier.padding(innerPadding))
|
||||||
|
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
|
||||||
|
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
|
||||||
|
3 -> SettingsScreen(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,211 @@
|
|||||||
|
package com.example.avtex.notes
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class NotesRepository(private val context: Context) {
|
||||||
|
|
||||||
|
private val dbHelper = XFlowDatabaseHelper(context)
|
||||||
|
private val syncClient = XFlowSyncClient(context)
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
|
||||||
|
val listsFlow: StateFlow<List<NoteList>> = _listsFlow.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reload() {
|
||||||
|
scope.launch {
|
||||||
|
val lists = loadListsFromDb()
|
||||||
|
_listsFlow.value = lists
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadListsFromDb(): List<NoteList> = withContext(Dispatchers.IO) {
|
||||||
|
val db = dbHelper.readableDatabase
|
||||||
|
val lists = mutableListOf<NoteList>()
|
||||||
|
|
||||||
|
val cursorLists = db.query(
|
||||||
|
XFlowDatabaseHelper.TABLE_LISTS,
|
||||||
|
null, null, null, null, null, "created_at DESC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursorLists.use { cLists ->
|
||||||
|
while (cLists.moveToNext()) {
|
||||||
|
val listId = cLists.getString(cLists.getColumnIndexOrThrow("id"))
|
||||||
|
val title = cLists.getString(cLists.getColumnIndexOrThrow("title"))
|
||||||
|
val category = cLists.getString(cLists.getColumnIndexOrThrow("category"))
|
||||||
|
val description = cLists.getString(cLists.getColumnIndexOrThrow("description")) ?: ""
|
||||||
|
val color = cLists.getString(cLists.getColumnIndexOrThrow("color"))
|
||||||
|
val icon = cLists.getString(cLists.getColumnIndexOrThrow("icon"))
|
||||||
|
val createdAt = cLists.getLong(cLists.getColumnIndexOrThrow("created_at"))
|
||||||
|
|
||||||
|
// Загружаем элементы списка
|
||||||
|
val items = mutableListOf<NoteItem>()
|
||||||
|
val cursorItems = db.query(
|
||||||
|
XFlowDatabaseHelper.TABLE_ITEMS,
|
||||||
|
null, "list_id = ?", arrayOf(listId), null, null, "created_at DESC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cursorItems.use { cItems ->
|
||||||
|
while (cItems.moveToNext()) {
|
||||||
|
val itemId = cItems.getString(cItems.getColumnIndexOrThrow("id"))
|
||||||
|
val itemTitle = cItems.getString(cItems.getColumnIndexOrThrow("title"))
|
||||||
|
val itemDesc = cItems.getString(cItems.getColumnIndexOrThrow("description")) ?: ""
|
||||||
|
val isCompleted = cItems.getInt(cItems.getColumnIndexOrThrow("is_completed")) == 1
|
||||||
|
val status = cItems.getString(cItems.getColumnIndexOrThrow("status"))
|
||||||
|
val priority = cItems.getString(cItems.getColumnIndexOrThrow("priority"))
|
||||||
|
val metadata = cItems.getString(cItems.getColumnIndexOrThrow("metadata")) ?: "{}"
|
||||||
|
val itemCreatedAt = cItems.getLong(cItems.getColumnIndexOrThrow("created_at"))
|
||||||
|
|
||||||
|
items.add(
|
||||||
|
NoteItem(
|
||||||
|
id = itemId,
|
||||||
|
listId = listId,
|
||||||
|
title = itemTitle,
|
||||||
|
description = itemDesc,
|
||||||
|
isCompleted = isCompleted,
|
||||||
|
status = status,
|
||||||
|
priority = priority,
|
||||||
|
metadata = metadata,
|
||||||
|
createdAt = itemCreatedAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lists.add(
|
||||||
|
NoteList(
|
||||||
|
id = listId,
|
||||||
|
title = title,
|
||||||
|
category = category,
|
||||||
|
description = description,
|
||||||
|
color = color,
|
||||||
|
icon = icon,
|
||||||
|
items = items,
|
||||||
|
createdAt = createdAt
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lists
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun addNoteItem(
|
||||||
|
title: String,
|
||||||
|
listTitle: String = "Фильмы на просмотр",
|
||||||
|
category: String = "movies",
|
||||||
|
description: String = ""
|
||||||
|
): NoteItem = withContext(Dispatchers.IO) {
|
||||||
|
val db = dbHelper.writableDatabase
|
||||||
|
|
||||||
|
// Ищем список по названию
|
||||||
|
var targetListId: String? = null
|
||||||
|
val c = db.query(
|
||||||
|
XFlowDatabaseHelper.TABLE_LISTS,
|
||||||
|
arrayOf("id"), "LOWER(title) = LOWER(?)", arrayOf(listTitle.trim()),
|
||||||
|
null, null, null
|
||||||
|
)
|
||||||
|
c.use {
|
||||||
|
if (it.moveToFirst()) {
|
||||||
|
targetListId = it.getString(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если список не найден, создаем его
|
||||||
|
if (targetListId == null) {
|
||||||
|
val newListId = "list-" + UUID.randomUUID().toString()
|
||||||
|
val cvList = ContentValues().apply {
|
||||||
|
put("id", newListId)
|
||||||
|
put("title", listTitle.trim())
|
||||||
|
put("category", category)
|
||||||
|
put("description", "Список $listTitle")
|
||||||
|
put("created_at", System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
db.insert(XFlowDatabaseHelper.TABLE_LISTS, null, cvList)
|
||||||
|
targetListId = newListId
|
||||||
|
}
|
||||||
|
|
||||||
|
val newItemId = "item-" + UUID.randomUUID().toString()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val cvItem = ContentValues().apply {
|
||||||
|
put("id", newItemId)
|
||||||
|
put("list_id", targetListId)
|
||||||
|
put("title", title.trim())
|
||||||
|
put("description", description)
|
||||||
|
put("is_completed", 0)
|
||||||
|
put("status", if (category == "movies") "want_to_watch" else "todo")
|
||||||
|
put("priority", "normal")
|
||||||
|
put("metadata", "{}")
|
||||||
|
put("created_at", now)
|
||||||
|
}
|
||||||
|
db.insert(XFlowDatabaseHelper.TABLE_ITEMS, null, cvItem)
|
||||||
|
|
||||||
|
reload()
|
||||||
|
|
||||||
|
// Фоновая отправка в XFlow Cloud (Coolify)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val prefs = context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE)
|
||||||
|
val serverUrl = prefs.getString("xflow_server_url", "https://xflow.controlsystem.kz")
|
||||||
|
?: "https://xflow.controlsystem.kz"
|
||||||
|
|
||||||
|
if (serverUrl.isNotBlank()) {
|
||||||
|
syncClient.syncItemToServer(
|
||||||
|
serverUrl = serverUrl,
|
||||||
|
title = title.trim(),
|
||||||
|
listTitle = listTitle.trim(),
|
||||||
|
category = category
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("NotesRepository", "Background cloud sync failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NoteItem(
|
||||||
|
id = newItemId,
|
||||||
|
listId = targetListId,
|
||||||
|
title = title.trim(),
|
||||||
|
description = description,
|
||||||
|
isCompleted = false,
|
||||||
|
status = if (category == "movies") "want_to_watch" else "todo",
|
||||||
|
createdAt = now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun toggleItem(item: NoteItem) = withContext(Dispatchers.IO) {
|
||||||
|
val db = dbHelper.writableDatabase
|
||||||
|
val newCompleted = !item.isCompleted
|
||||||
|
val newStatus = if (newCompleted) {
|
||||||
|
if (item.status == "want_to_watch") "watched" else "completed"
|
||||||
|
} else {
|
||||||
|
if (item.status == "watched") "want_to_watch" else "todo"
|
||||||
|
}
|
||||||
|
|
||||||
|
val cv = ContentValues().apply {
|
||||||
|
put("is_completed", if (newCompleted) 1 else 0)
|
||||||
|
put("status", newStatus)
|
||||||
|
}
|
||||||
|
db.update(XFlowDatabaseHelper.TABLE_ITEMS, cv, "id = ?", arrayOf(item.id))
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deleteItem(itemId: String) = withContext(Dispatchers.IO) {
|
||||||
|
val db = dbHelper.writableDatabase
|
||||||
|
db.delete(XFlowDatabaseHelper.TABLE_ITEMS, "id = ?", arrayOf(itemId))
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,780 @@
|
|||||||
|
package com.example.avtex.ui
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
|
import androidx.compose.material.icons.filled.Mic
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Terminal
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import com.example.avtex.LocalVoiceInputLauncher
|
||||||
|
import com.example.avtex.LocalRoleNotesRequester
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.avtex.AvtexApplication
|
||||||
|
import com.example.avtex.MainActivity
|
||||||
|
import com.example.avtex.notes.NoteItem
|
||||||
|
import com.example.avtex.notes.NoteList
|
||||||
|
import com.example.avtex.theme.CyanAccent
|
||||||
|
import com.example.avtex.theme.EmeraldMint
|
||||||
|
import com.example.avtex.theme.GlassBorder
|
||||||
|
import com.example.avtex.theme.NeonViolet
|
||||||
|
import com.example.avtex.theme.ObsidianBg
|
||||||
|
import com.example.avtex.theme.ObsidianSurface
|
||||||
|
import com.example.avtex.theme.ObsidianSurfaceElevated
|
||||||
|
import com.example.avtex.theme.ObsidianSurfaceHigh
|
||||||
|
import com.example.avtex.theme.SolarAmber
|
||||||
|
import com.example.avtex.theme.TextMuted
|
||||||
|
import com.example.avtex.theme.TextPrimary
|
||||||
|
import com.example.avtex.theme.TextSecondary
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ListsScreen(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val repository = remember { AvtexApplication.instance.notesRepository }
|
||||||
|
val lists by repository.listsFlow.collectAsState()
|
||||||
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
var selectedCategory by remember { mutableStateOf("all") }
|
||||||
|
var newItemText by remember { mutableStateOf("") }
|
||||||
|
var showAssistantTester by remember { mutableStateOf(false) }
|
||||||
|
val voiceLauncher = LocalVoiceInputLauncher.current
|
||||||
|
|
||||||
|
val filteredLists = remember(lists, selectedCategory) {
|
||||||
|
if (selectedCategory == "all") lists
|
||||||
|
else lists.filter { it.category == selectedCategory }
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBg),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
// 1. Header
|
||||||
|
item {
|
||||||
|
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Списки и Заметки",
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "XFlow синхронизация • Голосовой ввод Ассистента",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
// Direct Voice Button
|
||||||
|
Button(
|
||||||
|
onClick = { voiceLauncher?.invoke() },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
|
||||||
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Mic,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = "Голос",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button to toggle Assistant Tester
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showAssistantTester = !showAssistantTester },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f)),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
containerColor = if (showAssistantTester) NeonViolet.copy(alpha = 0.2f) else ObsidianSurface
|
||||||
|
),
|
||||||
|
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Mic,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeonViolet,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = if (showAssistantTester) "Скрыть тест" else "Тест Ассистента",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = NeonViolet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Interactive Assistant Simulator Card
|
||||||
|
item {
|
||||||
|
AnimatedVisibility(visible = showAssistantTester) {
|
||||||
|
AssistantTesterCard(context = context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Category Filter Chips
|
||||||
|
item {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
CategoryChip(
|
||||||
|
label = "Все",
|
||||||
|
selected = selectedCategory == "all",
|
||||||
|
onClick = { selectedCategory = "all" }
|
||||||
|
)
|
||||||
|
CategoryChip(
|
||||||
|
label = "Фильмы 🎬",
|
||||||
|
selected = selectedCategory == "movies",
|
||||||
|
onClick = { selectedCategory = "movies" }
|
||||||
|
)
|
||||||
|
CategoryChip(
|
||||||
|
label = "Покупки 🛒",
|
||||||
|
selected = selectedCategory == "shopping",
|
||||||
|
onClick = { selectedCategory = "shopping" }
|
||||||
|
)
|
||||||
|
CategoryChip(
|
||||||
|
label = "Задачи 📋",
|
||||||
|
selected = selectedCategory == "tasks",
|
||||||
|
onClick = { selectedCategory = "tasks" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Manual Quick Add Box
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(ObsidianSurfaceElevated)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||||
|
.padding(12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newItemText,
|
||||||
|
onValueChange = { newItemText = it },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
if (selectedCategory == "movies") "Название фильма на просмотр..."
|
||||||
|
else if (selectedCategory == "shopping") "Купить..."
|
||||||
|
else "Новая запись или заметка...",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
},
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = NeonViolet,
|
||||||
|
unfocusedBorderColor = GlassBorder,
|
||||||
|
focusedTextColor = TextPrimary,
|
||||||
|
unfocusedTextColor = TextPrimary,
|
||||||
|
cursorColor = NeonViolet
|
||||||
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (newItemText.isNotBlank()) {
|
||||||
|
coroutineScope.launch {
|
||||||
|
val targetCategory = if (selectedCategory == "all") "movies" else selectedCategory
|
||||||
|
val listTitle = when (targetCategory) {
|
||||||
|
"movies" -> "Фильмы на просмотр"
|
||||||
|
"shopping" -> "Покупки"
|
||||||
|
"tasks" -> "Задачи"
|
||||||
|
else -> "Быстрые заметки"
|
||||||
|
}
|
||||||
|
repository.addNoteItem(
|
||||||
|
title = newItemText.trim(),
|
||||||
|
listTitle = listTitle,
|
||||||
|
category = targetCategory
|
||||||
|
)
|
||||||
|
newItemText = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
modifier = Modifier.height(52.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Добавить", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Lists and Items
|
||||||
|
if (filteredLists.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.padding(vertical = 40.dp, horizontal = 20.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text(
|
||||||
|
text = "Нет записей в выбранной категории",
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(filteredLists, key = { it.id }) { list ->
|
||||||
|
ListCard(
|
||||||
|
list = list,
|
||||||
|
onToggleItem = { item ->
|
||||||
|
coroutineScope.launch { repository.toggleItem(item) }
|
||||||
|
},
|
||||||
|
onDeleteItem = { item ->
|
||||||
|
coroutineScope.launch { repository.deleteItem(item.id) }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AssistantTesterCard(context: Context) {
|
||||||
|
val voiceLauncher = LocalVoiceInputLauncher.current
|
||||||
|
val roleRequester = LocalRoleNotesRequester.current
|
||||||
|
var customVoiceQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedPreset by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
val clipboard = remember { context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager }
|
||||||
|
|
||||||
|
val adbCommand = remember(customVoiceQuery) {
|
||||||
|
val query = if (customVoiceQuery.isNotBlank()) customVoiceQuery else "Интерстеллар"
|
||||||
|
"adb shell am start -a android.intent.action.CREATE_NOTE -e android.intent.extra.TEXT \"$query\" com.example.avtex"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun simulateVoiceCommand(text: String) {
|
||||||
|
val intent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_CREATE_NOTE
|
||||||
|
putExtra(Intent.EXTRA_TEXT, text)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(ObsidianSurfaceElevated)
|
||||||
|
.border(1.dp, NeonViolet.copy(alpha = 0.4f), RoundedCornerShape(20.dp))
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(NeonViolet.copy(alpha = 0.2f))
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Mic,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = NeonViolet,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Симулятор голосового Ассистента",
|
||||||
|
fontSize = 15.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Проверка распознавания и авто-сортировки заметок",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct Voice Action & System Role
|
||||||
|
Button(
|
||||||
|
onClick = { voiceLauncher?.invoke() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("🎤 Сказать голосом прямо сейчас", fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { roleRequester?.invoke() },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(1.dp, NeonViolet.copy(alpha = 0.5f))
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Settings, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("⚙️ Сделать Avtex заметками по умолчанию", fontSize = 12.sp, color = NeonViolet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explanatory note about Google Keep
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Info, contentDescription = null, tint = SolarAmber, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Почему Ассистент отправляет в Google Keep?", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = SolarAmber)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "Google зафиксировал Keep как облачного провайдера заметок. Чтобы переключить:\n• Откройте: Настройки -> Google -> Google Ассистент -> «Заметки и списки» -> выберите «Не синхронизировать».\n• Либо произносите явно: «Окей Google, открой Avtex и запиши...».\n• Либо нажмите кнопку «Сказать голосом» выше или зажмите иконку Avtex на рабочем столе!",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = TextSecondary,
|
||||||
|
lineHeight = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Быстрые сценарии тестирования (нажмите для проверки):",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
|
||||||
|
// Preset Buttons Grid
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
AssistantPresetButton(
|
||||||
|
label = "🎬 Фильм: «Запиши фильм Начало на вечер»",
|
||||||
|
onClick = { simulateVoiceCommand("фильм Начало на вечер") }
|
||||||
|
)
|
||||||
|
AssistantPresetButton(
|
||||||
|
label = "🛒 Покупки: «Купить молоко, кофе и сыр»",
|
||||||
|
onClick = { simulateVoiceCommand("купить молоко, кофе и сыр") }
|
||||||
|
)
|
||||||
|
AssistantPresetButton(
|
||||||
|
label = "📋 Задача: «Позвонить автомеханику в 11»",
|
||||||
|
onClick = { simulateVoiceCommand("задача позвонить автомеханику в 11") }
|
||||||
|
)
|
||||||
|
AssistantPresetButton(
|
||||||
|
label = "💡 Заметка: «Идея для нового проекта XFlow»",
|
||||||
|
onClick = { simulateVoiceCommand("Идея для нового проекта XFlow") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Custom text input
|
||||||
|
Text(
|
||||||
|
text = "Своя фраза для проверки:",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = customVoiceQuery,
|
||||||
|
onValueChange = { customVoiceQuery = it },
|
||||||
|
placeholder = { Text("Например: сериал Локи 2 сезон", fontSize = 12.sp, color = TextMuted) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
singleLine = true,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedBorderColor = NeonViolet,
|
||||||
|
unfocusedBorderColor = GlassBorder,
|
||||||
|
focusedTextColor = TextPrimary,
|
||||||
|
unfocusedTextColor = TextPrimary
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (customVoiceQuery.isNotBlank()) {
|
||||||
|
simulateVoiceCommand(customVoiceQuery.trim())
|
||||||
|
customVoiceQuery = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.PlayArrow, contentDescription = "Запуск", tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ADB command helper
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Terminal, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(14.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Команда для теста через ADB с ПК:",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = CyanAccent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("adb command", adbCommand))
|
||||||
|
Toast.makeText(context, "Команда скопирована в буфер", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", tint = TextMuted, modifier = Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = adbCommand,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = TextSecondary,
|
||||||
|
lineHeight = 13.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AssistantPresetButton(
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(8.dp))
|
||||||
|
.clickable { onClick() }
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "ТЕСТ ▶",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = NeonViolet
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CategoryChip(
|
||||||
|
label: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onClick,
|
||||||
|
label = { Text(label, fontSize = 12.sp) },
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = NeonViolet.copy(alpha = 0.25f),
|
||||||
|
selectedLabelColor = Color.White,
|
||||||
|
containerColor = ObsidianSurfaceElevated,
|
||||||
|
labelColor = TextMuted
|
||||||
|
),
|
||||||
|
border = FilterChipDefaults.filterChipBorder(
|
||||||
|
enabled = true,
|
||||||
|
selected = selected,
|
||||||
|
borderColor = GlassBorder,
|
||||||
|
selectedBorderColor = NeonViolet.copy(alpha = 0.6f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ListCard(
|
||||||
|
list: NoteList,
|
||||||
|
onToggleItem: (NoteItem) -> Unit,
|
||||||
|
onDeleteItem: (NoteItem) -> Unit
|
||||||
|
) {
|
||||||
|
val accentColor = when (list.category) {
|
||||||
|
"movies" -> CyanAccent
|
||||||
|
"shopping" -> EmeraldMint
|
||||||
|
"tasks" -> SolarAmber
|
||||||
|
else -> NeonViolet
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(ObsidianSurfaceElevated)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp))
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
// List Title Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(accentColor)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = list.title,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(accentColor.copy(alpha = 0.15f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${list.items.count { !it.isCompleted }} активных",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = accentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items list
|
||||||
|
if (list.items.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "Список пуст",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextMuted,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
list.items.forEach { item ->
|
||||||
|
ItemRow(
|
||||||
|
item = item,
|
||||||
|
accentColor = accentColor,
|
||||||
|
onToggle = { onToggleItem(item) },
|
||||||
|
onDelete = { onDeleteItem(item) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ItemRow(
|
||||||
|
item: NoteItem,
|
||||||
|
accentColor: Color,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||||
|
.clickable { onToggle() }
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Checkbox(
|
||||||
|
checked = item.isCompleted,
|
||||||
|
onCheckedChange = { onToggle() },
|
||||||
|
colors = CheckboxDefaults.colors(
|
||||||
|
checkedColor = accentColor,
|
||||||
|
uncheckedColor = TextMuted,
|
||||||
|
checkmarkColor = ObsidianBg
|
||||||
|
),
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
color = if (item.isCompleted) TextMuted else TextPrimary,
|
||||||
|
textDecoration = if (item.isCompleted) TextDecoration.LineThrough else TextDecoration.None
|
||||||
|
)
|
||||||
|
if (item.status == "want_to_watch") {
|
||||||
|
Text(
|
||||||
|
text = "🎬 Буду смотреть",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = CyanAccent,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
} else if (item.status == "watched") {
|
||||||
|
Text(
|
||||||
|
text = "✔ Просмотрено",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = EmeraldMint,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Delete,
|
||||||
|
contentDescription = "Удалить",
|
||||||
|
tint = TextMuted,
|
||||||
|
modifier = Modifier.size(15.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
package com.example.avtex.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebResourceError
|
||||||
|
import android.webkit.WebResourceRequest
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import com.example.avtex.theme.*
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class CanvasItem(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val description: String = "",
|
||||||
|
val nodeCount: Int = 0,
|
||||||
|
val isPublic: Boolean = false,
|
||||||
|
val updatedAt: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MindmapsScreen(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val prefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
|
||||||
|
val serverUrl = remember {
|
||||||
|
mutableStateOf(prefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
|
||||||
|
}
|
||||||
|
|
||||||
|
var canvases by remember {
|
||||||
|
mutableStateOf(
|
||||||
|
listOf(
|
||||||
|
CanvasItem("c-1", "Архитектура XFlow (Monorepo & Onion)", "Ядро системы, Nuxt 4 и модули", 14, true, "Сегодня"),
|
||||||
|
CanvasItem("c-2", "План развития 2026", "AI ассистент, списки, трекер сна", 9, false, "Вчера"),
|
||||||
|
CanvasItem("c-3", "Идеи и проекты (Brainstorm)", "Синхронизация Obsidian и Mobile", 6, false, "25 сен")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLoading by remember { mutableStateOf(false) }
|
||||||
|
var selectedCanvasId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var showCreateDialog by remember { mutableStateOf(false) }
|
||||||
|
var newCanvasName by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
// Если открыт конкретный канвас — отображаем интерактивный редактор Flow через WebView
|
||||||
|
if (selectedCanvasId != null) {
|
||||||
|
BackHandler {
|
||||||
|
selectedCanvasId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
CanvasEditorView(
|
||||||
|
canvasId = selectedCanvasId!!,
|
||||||
|
serverUrl = serverUrl.value,
|
||||||
|
onClose = { selectedCanvasId = null }
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
containerColor = ObsidianBg,
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "Майндкарты",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(NeonViolet.copy(alpha = 0.2f))
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
) {
|
||||||
|
Text("XFlow Canvas", fontSize = 10.sp, color = NeonViolet, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = {
|
||||||
|
isLoading = true
|
||||||
|
scope.launch {
|
||||||
|
canvases = fetchCanvases(serverUrl.value, canvases)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Refresh, contentDescription = "Обновить", tint = Color.White)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(containerColor = ObsidianSurface)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { showCreateDialog = true },
|
||||||
|
containerColor = NeonViolet,
|
||||||
|
contentColor = Color.White,
|
||||||
|
shape = CircleShape
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Add, contentDescription = "Создать майндкарту")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Баннер интеграции
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
listOf(
|
||||||
|
NeonViolet.copy(alpha = 0.2f),
|
||||||
|
ElectricIndigo.copy(alpha = 0.15f),
|
||||||
|
Color.Transparent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(NeonViolet.copy(alpha = 0.25f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Hub, contentDescription = null, tint = NeonViolet, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Интерактивный граф узлов", color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp)
|
||||||
|
Text(
|
||||||
|
"Полная совместимость с Obsidian Canvas и веб-сервером XFlow",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = NeonViolet)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 80.dp)
|
||||||
|
) {
|
||||||
|
items(canvases, key = { it.id }) { canvas ->
|
||||||
|
CanvasCard(
|
||||||
|
canvas = canvas,
|
||||||
|
onClick = { selectedCanvasId = canvas.id }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showCreateDialog) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showCreateDialog = false },
|
||||||
|
containerColor = ObsidianSurfaceElevated,
|
||||||
|
title = { Text("Новая майндкарта", color = Color.White, fontWeight = FontWeight.Bold) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = newCanvasName,
|
||||||
|
onValueChange = { newCanvasName = it },
|
||||||
|
placeholder = { Text("Название майндкарты (напр. Проект X)") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
focusedTextColor = Color.White,
|
||||||
|
unfocusedTextColor = Color.White,
|
||||||
|
focusedBorderColor = NeonViolet,
|
||||||
|
unfocusedBorderColor = GlassBorder
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (newCanvasName.isNotBlank()) {
|
||||||
|
val newItem = CanvasItem(
|
||||||
|
id = "c-" + System.currentTimeMillis(),
|
||||||
|
name = newCanvasName.trim(),
|
||||||
|
description = "Создано с мобильного XFlow",
|
||||||
|
nodeCount = 1,
|
||||||
|
updatedAt = "Только что"
|
||||||
|
)
|
||||||
|
canvases = listOf(newItem) + canvases
|
||||||
|
newCanvasName = ""
|
||||||
|
showCreateDialog = false
|
||||||
|
selectedCanvasId = newItem.id
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
|
||||||
|
) {
|
||||||
|
Text("Создать")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showCreateDialog = false }) {
|
||||||
|
Text("Отмена", color = TextMuted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CanvasCard(
|
||||||
|
canvas: CanvasItem,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(ObsidianSurfaceElevated),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.AccountTree, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = canvas.name,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 15.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (canvas.description.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
text = canvas.description,
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = "${canvas.nodeCount} узлов",
|
||||||
|
color = CyanAccent,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Text(
|
||||||
|
text = canvas.updatedAt,
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
Icons.Default.ChevronRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = TextMuted,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
@Composable
|
||||||
|
fun CanvasEditorView(
|
||||||
|
canvasId: String,
|
||||||
|
serverUrl: String,
|
||||||
|
onClose: () -> Unit
|
||||||
|
) {
|
||||||
|
var webViewLoading by remember { mutableStateOf(true) }
|
||||||
|
var hasError by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val editorUrl = remember(canvasId, serverUrl) {
|
||||||
|
val base = serverUrl.trimEnd('/')
|
||||||
|
if (canvasId.startsWith("c-")) {
|
||||||
|
"$base/canvas"
|
||||||
|
} else {
|
||||||
|
"$base/canvas/$canvasId"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize().background(ObsidianBg)) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.useWideViewPort = true
|
||||||
|
settings.loadWithOverviewMode = true
|
||||||
|
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
|
webViewLoading = true
|
||||||
|
hasError = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
webViewLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceivedError(view: WebView?, request: WebResourceRequest?, error: WebResourceError?) {
|
||||||
|
webViewLoading = false
|
||||||
|
hasError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUrl(editorUrl)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Верхняя панель закрытия редактора
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(12.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onClose,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(ObsidianSurfaceElevated.copy(alpha = 0.85f))
|
||||||
|
.border(1.dp, GlassBorder, CircleShape)
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад", tint = Color.White)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
"XFlow Canvas Viewer",
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(ObsidianSurfaceElevated.copy(alpha = 0.85f))
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(20.dp))
|
||||||
|
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webViewLoading) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = NeonViolet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasError) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(ObsidianBg.copy(alpha = 0.95f))
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Icon(Icons.Default.CloudOff, contentDescription = null, tint = TextMuted, modifier = Modifier.size(54.dp))
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Редактор оффлайн", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 18.sp)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Не удалось подключиться к $editorUrl.\nПроверьте подключение к серверу XFlow в Настройках.",
|
||||||
|
color = TextMuted,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(20.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onClose,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
|
||||||
|
) {
|
||||||
|
Text("Вернуться к списку")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun fetchCanvases(serverUrl: String, fallback: List<CanvasItem>): List<CanvasItem> = withContext(Dispatchers.IO) {
|
||||||
|
val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("${serverUrl.trimEnd('/')}/api/canvas")
|
||||||
|
.get()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
response.use {
|
||||||
|
if (it.isSuccessful) {
|
||||||
|
val bodyStr = it.body?.string() ?: return@withContext fallback
|
||||||
|
val json = JSONObject(bodyStr)
|
||||||
|
val list = mutableListOf<CanvasItem>()
|
||||||
|
val arr = json.optJSONArray("canvases") ?: JSONArray()
|
||||||
|
for (i in 0 until arr.length()) {
|
||||||
|
val obj = arr.getJSONObject(i)
|
||||||
|
list.add(
|
||||||
|
CanvasItem(
|
||||||
|
id = obj.optString("id", "c-$i"),
|
||||||
|
name = obj.optString("name", "Майндкарта"),
|
||||||
|
description = obj.optString("description", ""),
|
||||||
|
nodeCount = obj.optJSONArray("nodes")?.length() ?: 0,
|
||||||
|
isPublic = obj.optBoolean("isPublic", false),
|
||||||
|
updatedAt = obj.optString("updatedAt", "Недавно")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (list.isNotEmpty()) list else fallback
|
||||||
|
} else {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,579 @@
|
|||||||
|
package com.example.avtex.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
|
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()) }
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val xflowPrefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
|
||||||
|
var xflowServerUrl by remember {
|
||||||
|
mutableStateOf(xflowPrefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
|
||||||
|
}
|
||||||
|
var xflowTestStatus by remember { mutableStateOf<String?>(null) }
|
||||||
|
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
|
||||||
|
|
||||||
|
var isSaved by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XFlow Cloud Server Card
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(ObsidianSurfaceElevated)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
|
||||||
|
.padding(18.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(CyanAccent.copy(alpha = 0.15f))
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Link,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = CyanAccent,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Сервер XFlow (Coolify / Cloud)",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Синхронизация Canvas майндкарт и заметок",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = xflowServerUrl,
|
||||||
|
onValueChange = { xflowServerUrl = it; isSaved = false },
|
||||||
|
label = { Text("URL сервера XFlow") },
|
||||||
|
placeholder = { Text("https://xflow.controlsystem.kz") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
singleLine = true,
|
||||||
|
colors = customTextFieldColors()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = xflowTestStatus ?: "Статус: https://xflow.controlsystem.kz",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (xflowTestStatus?.contains("OK") == true) EmeraldMint else TextMuted,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
xflowTestStatus = "Проверка..."
|
||||||
|
try {
|
||||||
|
val client = okhttp3.OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
val req = okhttp3.Request.Builder()
|
||||||
|
.url("${xflowServerUrl.trimEnd('/')}/api/canvas")
|
||||||
|
.build()
|
||||||
|
val resp = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
client.newCall(req).execute()
|
||||||
|
}
|
||||||
|
resp.use {
|
||||||
|
if (it.isSuccessful) {
|
||||||
|
xflowTestStatus = "Подключено: HTTP ${it.code} OK"
|
||||||
|
} else {
|
||||||
|
xflowTestStatus = "Ответ: HTTP ${it.code}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
xflowTestStatus = "Ошибка: ${e.localizedMessage}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Проверить", color = CyanAccent, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save Button
|
||||||
|
item {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
repository.updateHacSettings(
|
||||||
|
mode = selectedMode,
|
||||||
|
webhookUrl = webhookUrl,
|
||||||
|
restUrl = restUrl,
|
||||||
|
token = token
|
||||||
|
)
|
||||||
|
repository.updateDetectionSensitivity(
|
||||||
|
inactivityMinutes = inactivityMinutes.roundToInt(),
|
||||||
|
confidenceThreshold = confidenceThreshold.roundToInt(),
|
||||||
|
broadcastIntents = broadcastIntents
|
||||||
|
)
|
||||||
|
xflowPrefs.edit().putString("xflow_server_url", xflowServerUrl.trim()).apply()
|
||||||
|
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: 25 KiB |
|
After Width: | Height: | Size: 292 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="#07091C"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -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>
|
||||||
@@ -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: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1010 B |
|
After Width: | Height: | Size: 1010 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">Avtex</string>
|
||||||
|
<string name="shortcut_voice_note">Голосовая заметка</string>
|
||||||
|
<string name="shortcut_voice_note_desc">Быстрая запись заметки голосом в Avtex</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,77 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Шорткат по долгому нажатию на иконку: Голосовая заметка -->
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="voice_note"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
|
android:shortcutShortLabel="@string/shortcut_voice_note"
|
||||||
|
android:shortcutLongLabel="@string/shortcut_voice_note_desc">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="com.example.avtex"
|
||||||
|
android:targetClass="com.example.avtex.MainActivity"
|
||||||
|
android:data="xflow://voice/record" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<!-- Создание заметки через Gemini / Google Assistant ("Сделай заметку...") -->
|
||||||
|
<capability android:name="actions.intent.CREATE_NOTE">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="com.example.avtex"
|
||||||
|
android:targetClass="com.example.avtex.MainActivity"
|
||||||
|
android:data="xflow://notes/create">
|
||||||
|
<parameter
|
||||||
|
android:name="note.text"
|
||||||
|
android:key="note_text" />
|
||||||
|
<parameter
|
||||||
|
android:name="note.name"
|
||||||
|
android:key="note_title" />
|
||||||
|
</intent>
|
||||||
|
</capability>
|
||||||
|
|
||||||
|
<!-- Добавление элемента в список через Gemini / Google Assistant ("Добавь фильм в Avtex...") -->
|
||||||
|
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="com.example.avtex"
|
||||||
|
android:targetClass="com.example.avtex.MainActivity"
|
||||||
|
android:data="xflow://lists/add">
|
||||||
|
<parameter
|
||||||
|
android:name="itemList.name"
|
||||||
|
android:key="list_name" />
|
||||||
|
<parameter
|
||||||
|
android:name="itemListElement.name"
|
||||||
|
android:key="item_name" />
|
||||||
|
</intent>
|
||||||
|
</capability>
|
||||||
|
|
||||||
|
<!-- Создание списка через Gemini / Google Assistant -->
|
||||||
|
<capability android:name="actions.intent.CREATE_ITEM_LIST">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="com.example.avtex"
|
||||||
|
android:targetClass="com.example.avtex.MainActivity"
|
||||||
|
android:data="xflow://lists/create">
|
||||||
|
<parameter
|
||||||
|
android:name="itemList.name"
|
||||||
|
android:key="list_name" />
|
||||||
|
</intent>
|
||||||
|
</capability>
|
||||||
|
|
||||||
|
<!-- Просмотр списка -->
|
||||||
|
<capability android:name="actions.intent.GET_ITEM_LIST">
|
||||||
|
<intent
|
||||||
|
android:action="android.intent.action.VIEW"
|
||||||
|
android:targetPackage="com.example.avtex"
|
||||||
|
android:targetClass="com.example.avtex.MainActivity"
|
||||||
|
android:data="xflow://lists/view">
|
||||||
|
<parameter
|
||||||
|
android:name="itemList.name"
|
||||||
|
android:key="list_name" />
|
||||||
|
</intent>
|
||||||
|
</capability>
|
||||||
|
|
||||||
|
</shortcuts>
|
||||||
@@ -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
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { jwtDecode } from 'jwt-decode'
|
import { jwtDecode } from 'jwt-decode'
|
||||||
|
import { getApps } from 'firebase/app'
|
||||||
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -15,8 +16,6 @@ export const useAuth = () => {
|
|||||||
sameSite: 'lax'
|
sameSite: 'lax'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isInitializing = useState('auth_initializing', () => true)
|
|
||||||
|
|
||||||
const user = computed<User | null>(() => {
|
const user = computed<User | null>(() => {
|
||||||
if (!token.value) return null
|
if (!token.value) return null
|
||||||
try {
|
try {
|
||||||
@@ -30,6 +29,10 @@ export const useAuth = () => {
|
|||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
|
||||||
const googleSignIn = async () => {
|
const googleSignIn = async () => {
|
||||||
|
if (!getApps().length) {
|
||||||
|
alert('Ошибка: Firebase не настроен. Проверьте переменные окружения FIREBASE_API_KEY в Coolify.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const auth = getAuth()
|
const auth = getAuth()
|
||||||
const provider = new GoogleAuthProvider()
|
const provider = new GoogleAuthProvider()
|
||||||
const result = await signInWithPopup(auth, provider)
|
const result = await signInWithPopup(auth, provider)
|
||||||
@@ -46,8 +49,10 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
|
if (getApps().length) {
|
||||||
const auth = getAuth()
|
const auth = getAuth()
|
||||||
await signOut(auth)
|
await signOut(auth)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Firebase] Sign out failed or not initialized', e)
|
console.warn('[Firebase] Sign out failed or not initialized', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -58,7 +63,12 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const initAuth = () => {
|
const initAuth = () => {
|
||||||
if (process.server) return
|
if (process.server) return
|
||||||
|
if (!getApps().length) {
|
||||||
|
console.warn('[Firebase] Firebase App not initialized, skipping onAuthStateChanged.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const auth = getAuth()
|
const auth = getAuth()
|
||||||
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
||||||
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
||||||
@@ -78,17 +88,19 @@ export const useAuth = () => {
|
|||||||
console.error('Failed to restore backend auth from Firebase', e)
|
console.error('Failed to restore backend auth from Firebase', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
isInitializing.value = false
|
|
||||||
})
|
})
|
||||||
return unsubscribe
|
|
||||||
|
onUnmounted(() => unsubscribe())
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Firebase] Auth initialization skipped:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
|
||||||
user,
|
user,
|
||||||
|
token,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isInitializing,
|
|
||||||
googleSignIn,
|
googleSignIn,
|
||||||
logout,
|
logout,
|
||||||
initAuth
|
initAuth
|
||||||