21 Commits

Author SHA1 Message Date
Alexyz f4e1bae73f fix(icon): move launcher foreground to drawable-nodpi to fix 3x density blowout, regenerate all mipmap densities, and bump to v1.4 2026-10-03 22:08:52 +05:00
Alexyz 27235f6d54 feat(voice): add direct speech recognition, system ROLE_NOTES, voice shortcuts, and change app label to Avtex 2026-09-29 14:55:05 +05:00
Alexyz 34ec8944c1 fix(icon): rename launcher icon resource to ic_launcher_avtex and bump to v1.2 (code 3) to bust OnePlus launcher cache 2026-09-29 14:43:01 +05:00
Alexyz f7021313c6 fix(icon): crop launcher icon safe zone, bump versionCode to 2 (v1.1) to force launcher cache refresh 2026-09-28 21:54:53 +05:00
Alexyz 02de1aaf02 feat(assistant): add full Assistant note-taking pipeline, intent routing, and in-app Assistant Simulator 2026-09-28 21:52:04 +05:00
Alexyz f60ac2a5f3 feat(mobile): add Mindmaps & Canvas interactive screen with server sync and embedded flow viewer 2026-09-28 12:07:15 +05:00
Alexyz 176afcff39 feat(mobile): add automatic background cloud sync to NotesRepository pointing to Coolify server 2026-09-28 12:04:38 +05:00
Alexyz 7b37f6cf70 feat(firebase): embed production Firebase credentials as build-time defaults in nuxt.config, Dockerfile and compose 2026-09-28 12:01:25 +05:00
Alexyz 7d324a6140 fix(pwa,auth): serve manifest and icons from apps/web/public and prevent Firebase uninitialized crash 2026-09-28 11:49:46 +05:00
Alexyz 5c253b169e fix(prisma): remove deprecated url from schema.prisma per Prisma 7 specification (url is managed by prisma.config.ts) 2026-09-28 11:31:24 +05:00
Alexyz 54bfb8edd7 fix(prisma,db): add url to datasource in schema.prisma, copy prisma.config.ts to runner, and set POSTGRES_HOST_AUTH_METHOD=trust 2026-09-28 11:25:06 +05:00
Alexyz 9393cb823d fix(coolify): provide fallback POSTGRES_PASSWORD and remove unknown --skip-generate flag from prisma db push 2026-09-28 11:19:04 +05:00
Alexyz 7e8cae3801 fix(deploy): switch Dockerfile to node:22-slim to resolve esbuild glibc/musl mismatch and add profile fallback 2026-09-28 11:03:06 +05:00
Alexyz bc53c37fe0 chore(docker): expand .dockerignore to exclude mobile, gradle, kotlin, and local build artifacts 2026-09-28 10:48:11 +05:00
Alexyz 008b235ac5 chore(make): add smart fallback to make up for environments without docker 2026-09-28 10:43:41 +05:00
Alexyz dba93dc702 feat(docker): add local docker-compose stack and Makefile for full stack management 2026-09-28 10:41:05 +05:00
Alexyz 96fcf773f2 fix(auth): add resilient local fallback for Google auth when PostgreSQL is offline 2026-09-28 10:39:20 +05:00
Alexyz 94da4dd1cf feat(branding): update XFlow logo with fused Avtex neon crescent and dynamic X-flow waves 2026-09-28 10:39:19 +05:00
Alexyz 65d4d6c052 fix(server): standardize prisma generator and add resilient dev fallback in PrismaListRepository 2026-09-28 10:39:19 +05:00
Alexyz 1cf0e3216b feat(deploy): add Coolify docker-compose, multi-stage Dockerfile, and env template matching XCargo and Signage patterns 2026-09-28 10:39:19 +05:00
Alexyz be68f5e130 feat: restructure into XFlow monorepo with Nuxt 4, Modular Onion Monolith, Android client, and Desktop CLI 2026-09-28 10:39:19 +05:00
241 changed files with 26728 additions and 199 deletions
+55
View File
@@ -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
+38
View File
@@ -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=
+4
View File
@@ -39,3 +39,7 @@ server/api/node_modules/
obsidian-git/
/app/generated/prisma
*.apk
local.properties
.gradle/
+115
View File
@@ -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"]
+71
View File
@@ -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)"
-23
View File
@@ -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')
}
})
-66
View File
@@ -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
}
}
})
+158
View File
@@ -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);
+22
View File
@@ -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"
}
}
+164
View File
@@ -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);
+13
View File
@@ -0,0 +1,13 @@
*.iml
.gradle
/local.properties
local.properties
/.idea/
.idea/
.DS_Store
/build
build/
app/build/
/captures
.externalNativeBuild
.cxx
+91
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/build
+111
View File
@@ -0,0 +1,111 @@
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 = 5
versionName = "1.4"
}
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)
}
+10
View File
@@ -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.** { *; }
Binary file not shown.
@@ -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
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 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="#131929"
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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 688 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 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)
}
}
+6
View File
@@ -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
}
+29
View File
@@ -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
+50
View File
@@ -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" }
Binary file not shown.
+8
View File
@@ -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
Vendored Executable
+172
View File
@@ -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" "$@"
+84
View File
@@ -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
+33
View File
@@ -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")
+47
View File
@@ -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 { getApps } from 'firebase/app'
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
interface User {
@@ -15,8 +16,6 @@ export const useAuth = () => {
sameSite: 'lax'
})
const isInitializing = useState('auth_initializing', () => true)
const user = computed<User | null>(() => {
if (!token.value) return null
try {
@@ -30,6 +29,10 @@ export const useAuth = () => {
const isAdmin = computed(() => user.value?.role === 'admin')
const googleSignIn = async () => {
if (!getApps().length) {
alert('Ошибка: Firebase не настроен. Проверьте переменные окружения FIREBASE_API_KEY в Coolify.')
return
}
const auth = getAuth()
const provider = new GoogleAuthProvider()
const result = await signInWithPopup(auth, provider)
@@ -46,8 +49,10 @@ export const useAuth = () => {
const logout = async () => {
try {
const auth = getAuth()
await signOut(auth)
if (getApps().length) {
const auth = getAuth()
await signOut(auth)
}
} catch (e) {
console.warn('[Firebase] Sign out failed or not initialized', e)
} finally {
@@ -58,37 +63,44 @@ export const useAuth = () => {
const initAuth = () => {
if (process.server) return
if (!getApps().length) {
console.warn('[Firebase] Firebase App not initialized, skipping onAuthStateChanged.')
return
}
const auth = getAuth()
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
// If user is authenticated in Firebase but we lost our backend token, try to restore
if (firebaseUser && !token.value) {
try {
const idToken = await firebaseUser.getIdToken()
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
method: 'POST',
body: { idToken }
})
token.value = res.token
try {
const auth = getAuth()
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
// If user is authenticated in Firebase but we lost our backend token, try to restore
if (firebaseUser && !token.value) {
try {
const idToken = await firebaseUser.getIdToken()
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
method: 'POST',
body: { idToken }
})
token.value = res.token
if (useRoute().path === '/login') {
await navigateTo('/')
if (useRoute().path === '/login') {
await navigateTo('/')
}
} catch (e) {
console.error('Failed to restore backend auth from Firebase', e)
}
} catch (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 {
token,
user,
token,
isAuthenticated,
isAdmin,
isInitializing,
googleSignIn,
logout,
initAuth

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