Compare commits
23 Commits
f58ca9b885
..
v1.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fb5b15b75 | |||
| 8b3d0f3d19 | |||
| f4e1bae73f | |||
| 27235f6d54 | |||
| 34ec8944c1 | |||
| f7021313c6 | |||
| 02de1aaf02 | |||
| f60ac2a5f3 | |||
| 176afcff39 | |||
| 7b37f6cf70 | |||
| 7d324a6140 | |||
| 5c253b169e | |||
| 54bfb8edd7 | |||
| 9393cb823d | |||
| 7e8cae3801 | |||
| bc53c37fe0 | |||
| 008b235ac5 | |||
| dba93dc702 | |||
| 96fcf773f2 | |||
| 94da4dd1cf | |||
| 65d4d6c052 | |||
| 1cf0e3216b | |||
| be68f5e130 |
@@ -1,18 +1,55 @@
|
|||||||
node_modules
|
# Version Control
|
||||||
.output
|
|
||||||
.nuxt
|
|
||||||
node_modules/.cache
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
|
||||||
|
# Dependencies & Cache
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
.cache
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
.env.local
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.example.coolify
|
!.env.example.coolify
|
||||||
dist
|
|
||||||
*.log
|
|
||||||
*.canvas
|
|
||||||
|
|
||||||
# Exclude non-web monorepo apps from Docker build context
|
# Native Mobile (Android)
|
||||||
apps/mobile
|
apps/mobile
|
||||||
|
app
|
||||||
|
.gradle
|
||||||
|
**/.gradle
|
||||||
|
.kotlin
|
||||||
|
build
|
||||||
|
**/build
|
||||||
|
local.properties
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Desktop CLI & Contracts build
|
||||||
apps/desktop
|
apps/desktop
|
||||||
packages/shared-contracts/dist
|
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
|
||||||
|
|||||||
@@ -39,3 +39,7 @@ server/api/node_modules/
|
|||||||
obsidian-git/
|
obsidian-git/
|
||||||
|
|
||||||
/app/generated/prisma
|
/app/generated/prisma
|
||||||
|
|
||||||
|
*.apk
|
||||||
|
local.properties
|
||||||
|
.gradle/
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# Stage 1: builder
|
# Stage 1: builder
|
||||||
# ==========================================
|
# ==========================================
|
||||||
FROM node:22-alpine AS builder
|
FROM node:22-slim AS builder
|
||||||
|
|
||||||
ENV HUSKY=0
|
ENV HUSKY=0
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
@@ -9,7 +9,8 @@ ENV NODE_OPTIONS="--max-old-space-size=4096"
|
|||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN apk add --no-cache libc6-compat git openssl
|
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 SITE_URL=""
|
||||||
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
||||||
@@ -19,12 +20,12 @@ ARG OPEN_ROUTER_API_KEY=""
|
|||||||
ARG NUXT_OPEN_ROUTER_API_KEY=""
|
ARG NUXT_OPEN_ROUTER_API_KEY=""
|
||||||
ARG NUXT_QWEN_API_KEY=""
|
ARG NUXT_QWEN_API_KEY=""
|
||||||
ARG DEEPSEEK_API_KEY=""
|
ARG DEEPSEEK_API_KEY=""
|
||||||
ARG FIREBASE_API_KEY=""
|
ARG FIREBASE_API_KEY="AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck"
|
||||||
ARG FIREBASE_AUTH_DOMAIN=""
|
ARG FIREBASE_AUTH_DOMAIN="xflow-d8dd5.firebaseapp.com"
|
||||||
ARG FIREBASE_PROJECT_ID=""
|
ARG FIREBASE_PROJECT_ID="xflow-d8dd5"
|
||||||
ARG FIREBASE_STORAGE_BUCKET=""
|
ARG FIREBASE_STORAGE_BUCKET="xflow-d8dd5.firebasestorage.app"
|
||||||
ARG FIREBASE_MESSAGING_SENDER_ID=""
|
ARG FIREBASE_MESSAGING_SENDER_ID="944408960366"
|
||||||
ARG FIREBASE_APP_ID=""
|
ARG FIREBASE_APP_ID="1:944408960366:web:4d2cd393e85a0ca9b4357f"
|
||||||
ARG TZ="Asia/Almaty"
|
ARG TZ="Asia/Almaty"
|
||||||
|
|
||||||
ENV SITE_URL=${SITE_URL} \
|
ENV SITE_URL=${SITE_URL} \
|
||||||
@@ -58,10 +59,11 @@ ENV SITE_URL=${SITE_URL} \
|
|||||||
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
NUXT_PUBLIC_FIREBASE_APP_ID=${FIREBASE_APP_ID} \
|
||||||
TZ=${TZ}
|
TZ=${TZ}
|
||||||
|
|
||||||
# Копируем package.json и prisma схемы из apps/web
|
# Копируем package.json, prisma схемы и конфиг
|
||||||
COPY apps/web/package*.json ./
|
COPY apps/web/package*.json ./
|
||||||
COPY apps/web/prisma ./prisma
|
COPY apps/web/prisma ./prisma
|
||||||
RUN npm ci --ignore-scripts && npx prisma generate
|
COPY apps/web/prisma.config.ts ./
|
||||||
|
RUN npm ci --include=optional && npx prisma generate
|
||||||
|
|
||||||
# Копируем исходный код apps/web
|
# Копируем исходный код apps/web
|
||||||
COPY apps/web ./
|
COPY apps/web ./
|
||||||
@@ -70,14 +72,15 @@ RUN HUSKY=0 NODE_ENV=development npx nuxi prepare && HUSKY=0 NODE_ENV=developmen
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# Stage 2: runner
|
# Stage 2: runner
|
||||||
# ==========================================
|
# ==========================================
|
||||||
FROM node:22-alpine AS runner
|
FROM node:22-slim AS runner
|
||||||
|
|
||||||
ENV HUSKY=0
|
ENV HUSKY=0
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
RUN apk add --no-cache tzdata openssl
|
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 SITE_URL=""
|
||||||
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
ARG DATABASE_URL="postgresql://xflow:xflow@db:5432/xflow"
|
||||||
@@ -96,6 +99,7 @@ ENV SITE_URL=${SITE_URL} \
|
|||||||
|
|
||||||
COPY --from=builder /src/.output ./.output
|
COPY --from=builder /src/.output ./.output
|
||||||
COPY --from=builder /src/prisma ./prisma
|
COPY --from=builder /src/prisma ./prisma
|
||||||
|
COPY --from=builder /src/prisma.config.ts ./
|
||||||
COPY --from=builder /src/package.json ./
|
COPY --from=builder /src/package.json ./
|
||||||
COPY --from=builder /src/node_modules ./node_modules
|
COPY --from=builder /src/node_modules ./node_modules
|
||||||
|
|
||||||
@@ -108,4 +112,4 @@ ENV NITRO_PORT=3000
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["sh", "-c", "npx prisma db push --accept-data-loss --skip-generate && exec node .output/server/index.mjs"]
|
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && exec node .output/server/index.mjs"]
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
.PHONY: help up start dev-node db dev down restart logs logs-app logs-db ps db-push db-shell clean install-docker
|
||||||
|
|
||||||
|
CYAN := \033[36m
|
||||||
|
GREEN := \033[32m
|
||||||
|
YELLOW:= \033[33m
|
||||||
|
RESET := \033[0m
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "$(CYAN)XFlow Stack Management:$(RESET)"
|
||||||
|
@echo " $(GREEN)make up$(RESET) - Запустить стек (в Docker если установлен, иначе локально через Node)"
|
||||||
|
@echo " $(GREEN)make start$(RESET) - Запустить собранный Nuxt-сервер на Node (порт 3000)"
|
||||||
|
@echo " $(GREEN)make db$(RESET) - Запустить базы (PostgreSQL + Redis) в Docker"
|
||||||
|
@echo " $(GREEN)make dev$(RESET) - Поднять DB в Docker + запустить локальный Nuxt"
|
||||||
|
@echo " $(GREEN)make down$(RESET) - Остановить контейнеры Docker"
|
||||||
|
@echo " $(GREEN)make logs$(RESET) - Логи Docker-контейнеров"
|
||||||
|
@echo " $(GREEN)make install-docker$(RESET) - Инструкция по установке Docker на Ubuntu"
|
||||||
|
|
||||||
|
up:
|
||||||
|
@if command -v docker >/dev/null 2>&1; then \
|
||||||
|
echo "$(GREEN)Запуск контейнеров через Docker Compose...$(RESET)"; \
|
||||||
|
docker compose up -d --build; \
|
||||||
|
else \
|
||||||
|
echo "$(YELLOW)Docker не установлен в системе.$(RESET)"; \
|
||||||
|
echo "$(CYAN)Чтобы установить Docker: sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2 && sudo usermod -aG docker \$$USER$(RESET)"; \
|
||||||
|
echo "$(GREEN)Запускаем XFlow через Node.js на порту 3000...$(RESET)"; \
|
||||||
|
$(MAKE) start; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
start:
|
||||||
|
@PORT=3000 node /home/alexives/Desktop/StudioProjects/XFlow/apps/web/.output/server/index.mjs
|
||||||
|
|
||||||
|
db:
|
||||||
|
docker compose up -d db redis
|
||||||
|
@echo "$(GREEN)PostgreSQL (5432) и Redis (6379) запущены!$(RESET)"
|
||||||
|
|
||||||
|
dev: db
|
||||||
|
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npm --prefix apps/web run prisma db push || true
|
||||||
|
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npm --prefix apps/web run dev
|
||||||
|
|
||||||
|
down:
|
||||||
|
@if command -v docker >/dev/null 2>&1; then docker compose down; fi
|
||||||
|
|
||||||
|
restart:
|
||||||
|
@if command -v docker >/dev/null 2>&1; then docker compose restart; else $(MAKE) start; fi
|
||||||
|
|
||||||
|
logs:
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
logs-app:
|
||||||
|
docker compose logs -f app
|
||||||
|
|
||||||
|
logs-db:
|
||||||
|
docker compose logs -f db
|
||||||
|
|
||||||
|
ps:
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
db-push:
|
||||||
|
DATABASE_URL="postgresql://xflow:xflow_pass@localhost:5432/xflow" npx --prefix apps/web prisma db push
|
||||||
|
|
||||||
|
db-shell:
|
||||||
|
docker compose exec db psql -U xflow -d xflow
|
||||||
|
|
||||||
|
clean:
|
||||||
|
docker compose down -v --remove-orphans
|
||||||
|
|
||||||
|
install-docker:
|
||||||
|
@echo "$(CYAN)Для установки Docker выполните:$(RESET)"
|
||||||
|
@echo "sudo apt-get update && sudo apt-get install -y docker.io docker-compose-v2"
|
||||||
|
@echo "sudo usermod -aG docker \$$USER"
|
||||||
|
@echo "$(CYAN)Затем обновите сессию: newgrp docker$(RESET)"
|
||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "com.example.avtex"
|
applicationId = "com.example.avtex"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 6
|
||||||
versionName = "1.0"
|
versionName = "1.5"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -49,6 +49,11 @@ android {
|
|||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError = false
|
||||||
|
checkReleaseBuilds = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
@@ -104,3 +109,29 @@ dependencies {
|
|||||||
// DataStore for settings
|
// DataStore for settings
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Автоматическое копирование релизного APK на рабочий стол пользователя после сборки
|
||||||
|
|
||||||
|
val desktopDir = File(System.getProperty("user.home"), "Desktop")
|
||||||
|
|
||||||
|
|
||||||
|
tasks.register("copyReleaseApkToDesktop") {
|
||||||
|
notCompatibleWithConfigurationCache("Copies APK to user Desktop")
|
||||||
|
doLast {
|
||||||
|
val userHome = System.getProperty("user.home")
|
||||||
|
val desktopDir = File(userHome, "Desktop")
|
||||||
|
if (desktopDir.exists() && desktopDir.isDirectory) {
|
||||||
|
val apkFile = layout.buildDirectory.file("outputs/apk/release/app-release.apk").get().asFile
|
||||||
|
if (apkFile.exists()) {
|
||||||
|
val vName = android.defaultConfig.versionName ?: "release"
|
||||||
|
apkFile.copyTo(File(desktopDir, "Avtex-v${vName}.apk"), overwrite = true)
|
||||||
|
apkFile.copyTo(File(desktopDir, "Avtex-release.apk"), overwrite = true)
|
||||||
|
println(">>> [AUTO-DEPLOY] Релизный APK скопирован на Рабочий стол: Avtex-v${vName}.apk и Avtex-release.apk")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
tasks.findByName("assembleRelease")?.finalizedBy("copyReleaseApkToDesktop")
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,29 +15,62 @@
|
|||||||
<application
|
<application
|
||||||
android:name=".AvtexApplication"
|
android:name=".AvtexApplication"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_avtex_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:windowSoftInputMode="adjustResize"
|
android:windowSoftInputMode="adjustResize"
|
||||||
android:theme="@style/Theme.Avtex">
|
android:theme="@style/Theme.Avtex">
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_avtex_round">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
|
<!-- Deep link intent-filter for Gemini / Assistant App Actions & XFlow links -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
<data
|
<data android:scheme="xflow" />
|
||||||
android:scheme="xflow"
|
</intent-filter>
|
||||||
android:host="lists" />
|
|
||||||
|
<!-- 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>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
@@ -66,7 +99,9 @@
|
|||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name=".engine.BootReceiver"
|
android:name=".engine.BootReceiver"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:icon="@mipmap/ic_launcher_avtex"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_avtex_round">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|||||||
@@ -1,21 +1,52 @@
|
|||||||
package com.example.avtex
|
package com.example.avtex
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.role.RoleManager
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.speech.RecognizerIntent
|
||||||
|
import android.util.Log
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.example.avtex.theme.AvtexTheme
|
import com.example.avtex.theme.AvtexTheme
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
val LocalVoiceInputLauncher = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
val LocalRoleNotesRequester = compositionLocalOf<(() -> Unit)?> { null }
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
handleIncomingIntent(intent)
|
handleIncomingIntent(intent)
|
||||||
@@ -23,11 +54,16 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
AvtexTheme {
|
AvtexTheme {
|
||||||
Surface(
|
CompositionLocalProvider(
|
||||||
modifier = Modifier.fillMaxSize(),
|
LocalVoiceInputLauncher provides { launchVoiceInput() },
|
||||||
color = MaterialTheme.colorScheme.background
|
LocalRoleNotesRequester provides { requestDefaultNotesRole() }
|
||||||
) {
|
) {
|
||||||
MainNavigation()
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
MainNavigation()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,49 +74,217 @@ class MainActivity : ComponentActivity() {
|
|||||||
handleIncomingIntent(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?) {
|
private fun handleIncomingIntent(intent: Intent?) {
|
||||||
if (intent == null) return
|
if (intent == null) return
|
||||||
|
val action = intent.action
|
||||||
|
Log.d(TAG, "Incoming intent action: $action, data: ${intent.data}")
|
||||||
|
|
||||||
// 1. Обработка App Actions через Deep Link xflow://lists/add
|
// 1. Google Assistant / Android System Note Action (android.intent.action.CREATE_NOTE)
|
||||||
val data = intent.data
|
if (action == Intent.ACTION_CREATE_NOTE) {
|
||||||
if (data != null && data.scheme == "xflow") {
|
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
|
?: intent.getStringExtra("android.intent.extra.TEXT")
|
||||||
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
|
?: intent.getStringExtra(Intent.EXTRA_TITLE)
|
||||||
|
val title = intent.getStringExtra(Intent.EXTRA_TITLE)
|
||||||
if (!itemName.isNullOrBlank()) {
|
if (!text.isNullOrBlank()) {
|
||||||
saveItemFromAssistant(itemName, listName)
|
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
|
||||||
|
routeAndSaveNote(fullText, preferredList = null)
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
launchVoiceInput()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
|
// 2. Google Voice Actions (com.google.android.gms.actions.CREATE_NOTE)
|
||||||
val extras = intent.extras
|
if (action == "com.google.android.gms.actions.CREATE_NOTE") {
|
||||||
if (extras != null) {
|
val text = intent.getStringExtra("com.google.android.gms.actions.extra.TEXT")
|
||||||
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
|
?: intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
|
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()) {
|
if (!itemName.isNullOrBlank()) {
|
||||||
saveItemFromAssistant(itemName, listName)
|
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 saveItemFromAssistant(itemName: String, listName: String) {
|
private fun routeAndSaveNote(rawText: String, preferredList: String?) {
|
||||||
|
val cleanText = rawText.trim()
|
||||||
|
if (cleanText.isBlank()) return
|
||||||
|
|
||||||
|
val (targetList, category) = resolveListAndCategory(cleanText, preferredList)
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val repo = AvtexApplication.instance.notesRepository
|
try {
|
||||||
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
|
val repo = AvtexApplication.instance.notesRepository
|
||||||
|
repo.addNoteItem(
|
||||||
|
title = cleanText,
|
||||||
|
listTitle = targetList,
|
||||||
|
category = category
|
||||||
|
)
|
||||||
|
|
||||||
repo.addNoteItem(
|
Toast.makeText(
|
||||||
title = itemName,
|
this@MainActivity,
|
||||||
listTitle = listName,
|
"✅ Avtex: Записано в «$targetList»:\n$cleanText",
|
||||||
category = category
|
Toast.LENGTH_LONG
|
||||||
)
|
).show()
|
||||||
|
|
||||||
Toast.makeText(
|
AvtexApplication.instance.settingsRepository.addLog(
|
||||||
this@MainActivity,
|
tag = "Assistant",
|
||||||
"✅ Записано в XFlow: $itemName ($listName)",
|
message = "Голосовая заметка: \"$cleanText\" в список \"$targetList\"",
|
||||||
Toast.LENGTH_LONG
|
isImportant = true
|
||||||
).show()
|
)
|
||||||
|
} 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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Bedtime
|
import androidx.compose.material.icons.filled.Bedtime
|
||||||
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
import androidx.compose.material.icons.automirrored.filled.FormatListBulleted
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Hub
|
||||||
import androidx.compose.material.icons.filled.Settings
|
import androidx.compose.material.icons.filled.Settings
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
@@ -31,7 +31,7 @@ import com.example.avtex.theme.ObsidianSurfaceElevated
|
|||||||
import com.example.avtex.theme.TextMuted
|
import com.example.avtex.theme.TextMuted
|
||||||
import com.example.avtex.ui.DashboardScreen
|
import com.example.avtex.ui.DashboardScreen
|
||||||
import com.example.avtex.ui.ListsScreen
|
import com.example.avtex.ui.ListsScreen
|
||||||
import com.example.avtex.ui.OnePlusGuideScreen
|
import com.example.avtex.ui.MindmapsScreen
|
||||||
import com.example.avtex.ui.SettingsScreen
|
import com.example.avtex.ui.SettingsScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -58,10 +58,10 @@ fun MainNavigation() {
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 0,
|
selected = selectedTab == 0,
|
||||||
onClick = { selectedTab = 0 },
|
onClick = { selectedTab = 0 },
|
||||||
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Мониторинг") },
|
icon = { Icon(Icons.Default.Hub, contentDescription = "Майндкарты") },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
"Сон",
|
"Карты",
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (selectedTab == 0) FontWeight.Bold else FontWeight.Normal
|
||||||
)
|
)
|
||||||
@@ -84,10 +84,10 @@ fun MainNavigation() {
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 2,
|
selected = selectedTab == 2,
|
||||||
onClick = { selectedTab = 2 },
|
onClick = { selectedTab = 2 },
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
|
icon = { Icon(Icons.Default.Bedtime, contentDescription = "Сон") },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
"Настройки",
|
"Сон",
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (selectedTab == 2) FontWeight.Bold else FontWeight.Normal
|
||||||
)
|
)
|
||||||
@@ -97,10 +97,10 @@ fun MainNavigation() {
|
|||||||
NavigationBarItem(
|
NavigationBarItem(
|
||||||
selected = selectedTab == 3,
|
selected = selectedTab == 3,
|
||||||
onClick = { selectedTab = 3 },
|
onClick = { selectedTab = 3 },
|
||||||
icon = { Icon(Icons.Default.Info, contentDescription = "OnePlus") },
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Настройки") },
|
||||||
label = {
|
label = {
|
||||||
Text(
|
Text(
|
||||||
"OnePlus",
|
"Настройки",
|
||||||
fontSize = 11.sp,
|
fontSize = 11.sp,
|
||||||
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
|
fontWeight = if (selectedTab == 3) FontWeight.Bold else FontWeight.Normal
|
||||||
)
|
)
|
||||||
@@ -111,10 +111,10 @@ fun MainNavigation() {
|
|||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
when (selectedTab) {
|
when (selectedTab) {
|
||||||
0 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
|
0 -> MindmapsScreen(modifier = Modifier.padding(innerPadding))
|
||||||
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
|
1 -> ListsScreen(modifier = Modifier.padding(innerPadding))
|
||||||
2 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
|
2 -> DashboardScreen(modifier = Modifier.padding(innerPadding))
|
||||||
3 -> OnePlusGuideScreen(modifier = Modifier.padding(innerPadding))
|
3 -> SettingsScreen(modifier = Modifier.padding(innerPadding))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.example.avtex.notes
|
|||||||
|
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -11,9 +12,10 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class NotesRepository(context: Context) {
|
class NotesRepository(private val context: Context) {
|
||||||
|
|
||||||
private val dbHelper = XFlowDatabaseHelper(context)
|
private val dbHelper = XFlowDatabaseHelper(context)
|
||||||
|
private val syncClient = XFlowSyncClient(context)
|
||||||
private val scope = CoroutineScope(Dispatchers.IO)
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
|
private val _listsFlow = MutableStateFlow<List<NoteList>>(emptyList())
|
||||||
@@ -153,6 +155,26 @@ class NotesRepository(context: Context) {
|
|||||||
|
|
||||||
reload()
|
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(
|
NoteItem(
|
||||||
id = newItemId,
|
id = newItemId,
|
||||||
listId = targetListId,
|
listId = targetListId,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package com.example.avtex.ui
|
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.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -17,24 +22,34 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Check
|
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.Delete
|
||||||
import androidx.compose.material.icons.filled.Movie
|
import androidx.compose.material.icons.filled.ExpandLess
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
import androidx.compose.material.icons.filled.ShoppingCart
|
import androidx.compose.material.icons.filled.Mic
|
||||||
import androidx.compose.material.icons.filled.TaskAlt
|
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.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.CheckboxDefaults
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -48,17 +63,18 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.example.avtex.AvtexApplication
|
import com.example.avtex.AvtexApplication
|
||||||
|
import com.example.avtex.MainActivity
|
||||||
import com.example.avtex.notes.NoteItem
|
import com.example.avtex.notes.NoteItem
|
||||||
import com.example.avtex.notes.NoteList
|
import com.example.avtex.notes.NoteList
|
||||||
import com.example.avtex.theme.CyanAccent
|
import com.example.avtex.theme.CyanAccent
|
||||||
import com.example.avtex.theme.ElectricIndigo
|
|
||||||
import com.example.avtex.theme.EmeraldMint
|
import com.example.avtex.theme.EmeraldMint
|
||||||
import com.example.avtex.theme.GlassBorder
|
import com.example.avtex.theme.GlassBorder
|
||||||
import com.example.avtex.theme.NeonViolet
|
import com.example.avtex.theme.NeonViolet
|
||||||
@@ -74,113 +90,150 @@ import kotlinx.coroutines.launch
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ListsScreen(modifier: Modifier = Modifier) {
|
fun ListsScreen(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
val repository = remember { AvtexApplication.instance.notesRepository }
|
val repository = remember { AvtexApplication.instance.notesRepository }
|
||||||
val lists by repository.listsFlow.collectAsState()
|
val lists by repository.listsFlow.collectAsState()
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
|
|
||||||
var selectedCategory by remember { mutableStateOf("all") }
|
var selectedCategory by remember { mutableStateOf("all") }
|
||||||
var newItemText by remember { mutableStateOf("") }
|
var newItemText by remember { mutableStateOf("") }
|
||||||
var isAddingActive by remember { mutableStateOf(false) }
|
var showAssistantTester by remember { mutableStateOf(false) }
|
||||||
|
val voiceLauncher = LocalVoiceInputLauncher.current
|
||||||
val categories = listOf(
|
|
||||||
"all" to "Все",
|
|
||||||
"movies" to "🎬 Фильмы",
|
|
||||||
"shopping" to "🛒 Покупки",
|
|
||||||
"tasks" to "⚡ Дела"
|
|
||||||
)
|
|
||||||
|
|
||||||
val filteredLists = remember(lists, selectedCategory) {
|
val filteredLists = remember(lists, selectedCategory) {
|
||||||
if (selectedCategory == "all") lists else lists.filter { it.category == selectedCategory }
|
if (selectedCategory == "all") lists
|
||||||
|
else lists.filter { it.category == selectedCategory }
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(ObsidianBg)
|
.background(ObsidianBg),
|
||||||
.padding(horizontal = 16.dp),
|
contentPadding = PaddingValues(16.dp),
|
||||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
|
||||||
) {
|
) {
|
||||||
// Header
|
// 1. Header
|
||||||
item {
|
item {
|
||||||
Row(
|
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Row(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "XFlow Заметки & Списки",
|
|
||||||
fontSize = 22.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = TextPrimary
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = TextMuted
|
|
||||||
)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { repository.reload() },
|
|
||||||
modifier = Modifier
|
|
||||||
.size(38.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(ObsidianSurfaceElevated)
|
|
||||||
.border(1.dp, GlassBorder, CircleShape)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Column {
|
||||||
imageVector = Icons.Default.Refresh,
|
|
||||||
contentDescription = "Обновить",
|
|
||||||
tint = CyanAccent,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Category Filter Chips
|
|
||||||
item {
|
|
||||||
LazyRow(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
items(categories) { (key, label) ->
|
|
||||||
val isSelected = selectedCategory == key
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(if (isSelected) NeonViolet.copy(alpha = 0.25f) else ObsidianSurfaceElevated)
|
|
||||||
.border(
|
|
||||||
1.dp,
|
|
||||||
if (isSelected) NeonViolet else GlassBorder,
|
|
||||||
RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.clickable { selectedCategory = key }
|
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = label,
|
text = "Списки и Заметки",
|
||||||
fontSize = 12.sp,
|
fontSize = 24.sp,
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
fontWeight = FontWeight.Bold,
|
||||||
color = if (isSelected) TextPrimary else TextSecondary
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick Add Item Bar
|
// 2. Interactive Assistant Simulator Card
|
||||||
item {
|
item {
|
||||||
Column(
|
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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(ObsidianSurface)
|
.background(ObsidianSurfaceElevated)
|
||||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||||
.padding(12.dp)
|
.padding(12.dp)
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
@@ -189,7 +242,9 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
|||||||
onValueChange = { newItemText = it },
|
onValueChange = { newItemText = it },
|
||||||
placeholder = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
|
if (selectedCategory == "movies") "Название фильма на просмотр..."
|
||||||
|
else if (selectedCategory == "shopping") "Купить..."
|
||||||
|
else "Новая запись или заметка...",
|
||||||
fontSize = 13.sp,
|
fontSize = 13.sp,
|
||||||
color = TextMuted
|
color = TextMuted
|
||||||
)
|
)
|
||||||
@@ -214,7 +269,8 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
|||||||
val listTitle = when (targetCategory) {
|
val listTitle = when (targetCategory) {
|
||||||
"movies" -> "Фильмы на просмотр"
|
"movies" -> "Фильмы на просмотр"
|
||||||
"shopping" -> "Покупки"
|
"shopping" -> "Покупки"
|
||||||
else -> "Общий список"
|
"tasks" -> "Задачи"
|
||||||
|
else -> "Быстрые заметки"
|
||||||
}
|
}
|
||||||
repository.addNoteItem(
|
repository.addNoteItem(
|
||||||
title = newItemText.trim(),
|
title = newItemText.trim(),
|
||||||
@@ -235,20 +291,31 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lists and Items
|
// 5. Lists and Items
|
||||||
if (filteredLists.isEmpty()) {
|
if (filteredLists.isEmpty()) {
|
||||||
item {
|
item {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 40.dp),
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(ObsidianSurface)
|
||||||
|
.padding(vertical = 40.dp, horizontal = 20.dp),
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
"Нет записей. Добавьте первую заметку или скажите Gemini!",
|
Text(
|
||||||
fontSize = 13.sp,
|
text = "Нет записей в выбранной категории",
|
||||||
color = TextMuted
|
fontSize = 14.sp,
|
||||||
)
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = TextSecondary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -267,6 +334,302 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Composable
|
||||||
private fun ListCard(
|
private fun ListCard(
|
||||||
list: NoteList,
|
list: NoteList,
|
||||||
@@ -283,9 +646,9 @@ private fun ListCard(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(18.dp))
|
||||||
.background(ObsidianSurface)
|
.background(ObsidianSurfaceElevated)
|
||||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp))
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
@@ -307,16 +670,24 @@ private fun ListCard(
|
|||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = list.title,
|
text = list.title,
|
||||||
fontSize = 15.sp,
|
fontSize = 16.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = TextPrimary
|
color = TextPrimary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Box(
|
||||||
text = "${list.items.count { !it.isCompleted }} активных",
|
modifier = Modifier
|
||||||
fontSize = 11.sp,
|
.clip(RoundedCornerShape(8.dp))
|
||||||
color = TextMuted
|
.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
|
// Items list
|
||||||
@@ -325,7 +696,7 @@ private fun ListCard(
|
|||||||
text = "Список пуст",
|
text = "Список пуст",
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
color = TextMuted,
|
color = TextMuted,
|
||||||
modifier = Modifier.padding(start = 16.dp)
|
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
list.items.forEach { item ->
|
list.items.forEach { item ->
|
||||||
@@ -351,10 +722,10 @@ private fun ItemRow(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(10.dp))
|
.clip(RoundedCornerShape(10.dp))
|
||||||
.background(ObsidianSurfaceElevated)
|
.background(ObsidianSurface)
|
||||||
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||||
.clickable { onToggle() }
|
.clickable { onToggle() }
|
||||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Checkbox(
|
Checkbox(
|
||||||
@@ -381,13 +752,15 @@ private fun ItemRow(
|
|||||||
Text(
|
Text(
|
||||||
text = "🎬 Буду смотреть",
|
text = "🎬 Буду смотреть",
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = CyanAccent
|
color = CyanAccent,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
} else if (item.status == "watched") {
|
} else if (item.status == "watched") {
|
||||||
Text(
|
Text(
|
||||||
text = "✔ Просмотрено",
|
text = "✔ Просмотрено",
|
||||||
fontSize = 10.sp,
|
fontSize = 10.sp,
|
||||||
color = EmeraldMint
|
color = EmeraldMint,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.example.avtex.ui
|
package com.example.avtex.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@@ -80,6 +82,12 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
|
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
|
||||||
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
|
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
|
||||||
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) }
|
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) }
|
||||||
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
|
val xflowPrefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
|
||||||
|
var xflowServerUrl by remember {
|
||||||
|
mutableStateOf(xflowPrefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
|
||||||
|
}
|
||||||
|
var xflowTestStatus by remember { mutableStateOf<String?>(null) }
|
||||||
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
|
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
|
||||||
|
|
||||||
var isSaved by remember { mutableStateOf(false) }
|
var isSaved by remember { mutableStateOf(false) }
|
||||||
@@ -382,6 +390,107 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// XFlow Cloud Server Card
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(ObsidianSurfaceElevated)
|
||||||
|
.border(1.dp, GlassBorder, RoundedCornerShape(22.dp))
|
||||||
|
.padding(18.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(CyanAccent.copy(alpha = 0.15f))
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Link,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = CyanAccent,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = "Сервер XFlow (Coolify / Cloud)",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = TextPrimary
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Синхронизация Canvas майндкарт и заметок",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = TextMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(14.dp))
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = xflowServerUrl,
|
||||||
|
onValueChange = { xflowServerUrl = it; isSaved = false },
|
||||||
|
label = { Text("URL сервера XFlow") },
|
||||||
|
placeholder = { Text("https://xflow.controlsystem.kz") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
singleLine = true,
|
||||||
|
colors = customTextFieldColors()
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(10.dp))
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = xflowTestStatus ?: "Статус: https://xflow.controlsystem.kz",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (xflowTestStatus?.contains("OK") == true) EmeraldMint else TextMuted,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
androidx.compose.material3.TextButton(
|
||||||
|
onClick = {
|
||||||
|
coroutineScope.launch {
|
||||||
|
xflowTestStatus = "Проверка..."
|
||||||
|
try {
|
||||||
|
val client = okhttp3.OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
val req = okhttp3.Request.Builder()
|
||||||
|
.url("${xflowServerUrl.trimEnd('/')}/api/canvas")
|
||||||
|
.build()
|
||||||
|
val resp = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
|
||||||
|
client.newCall(req).execute()
|
||||||
|
}
|
||||||
|
resp.use {
|
||||||
|
if (it.isSuccessful) {
|
||||||
|
xflowTestStatus = "Подключено: HTTP ${it.code} OK"
|
||||||
|
} else {
|
||||||
|
xflowTestStatus = "Ответ: HTTP ${it.code}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
xflowTestStatus = "Ошибка: ${e.localizedMessage}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Проверить", color = CyanAccent, fontSize = 13.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Save Button
|
// 4. Save Button
|
||||||
item {
|
item {
|
||||||
Button(
|
Button(
|
||||||
@@ -398,6 +507,7 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
|
|||||||
confidenceThreshold = confidenceThreshold.roundToInt(),
|
confidenceThreshold = confidenceThreshold.roundToInt(),
|
||||||
broadcastIntents = broadcastIntents
|
broadcastIntents = broadcastIntents
|
||||||
)
|
)
|
||||||
|
xflowPrefs.edit().putString("xflow_server_url", xflowServerUrl.trim()).apply()
|
||||||
isSaved = true
|
isSaved = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -5,6 +5,6 @@
|
|||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#0D0E1A"
|
android:fillColor="#07091C"
|
||||||
android:pathData="M0,0h108v108h-108z" />
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
Before Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 688 B After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 866 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 434 B After Width: | Height: | Size: 1010 B |
|
After Width: | Height: | Size: 1010 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 570 B After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 7.2 KiB |
@@ -1,3 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">XFlow</string>
|
<string name="app_name">Avtex</string>
|
||||||
|
<string name="shortcut_voice_note">Голосовая заметка</string>
|
||||||
|
<string name="shortcut_voice_note_desc">Быстрая запись заметки голосом в Avtex</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,7 +1,38 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
<!-- Добавление элемента в список через Gemini / Google Assistant -->
|
<!-- Шорткат по долгому нажатию на иконку: Голосовая заметка -->
|
||||||
|
<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">
|
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
|
||||||
<intent
|
<intent
|
||||||
android:action="android.intent.action.VIEW"
|
android:action="android.intent.action.VIEW"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { jwtDecode } from 'jwt-decode'
|
import { jwtDecode } from 'jwt-decode'
|
||||||
|
import { getApps } from 'firebase/app'
|
||||||
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
import { getAuth, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'firebase/auth'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -15,8 +16,6 @@ export const useAuth = () => {
|
|||||||
sameSite: 'lax'
|
sameSite: 'lax'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isInitializing = useState('auth_initializing', () => true)
|
|
||||||
|
|
||||||
const user = computed<User | null>(() => {
|
const user = computed<User | null>(() => {
|
||||||
if (!token.value) return null
|
if (!token.value) return null
|
||||||
try {
|
try {
|
||||||
@@ -30,6 +29,10 @@ export const useAuth = () => {
|
|||||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||||
|
|
||||||
const googleSignIn = async () => {
|
const googleSignIn = async () => {
|
||||||
|
if (!getApps().length) {
|
||||||
|
alert('Ошибка: Firebase не настроен. Проверьте переменные окружения FIREBASE_API_KEY в Coolify.')
|
||||||
|
return
|
||||||
|
}
|
||||||
const auth = getAuth()
|
const auth = getAuth()
|
||||||
const provider = new GoogleAuthProvider()
|
const provider = new GoogleAuthProvider()
|
||||||
const result = await signInWithPopup(auth, provider)
|
const result = await signInWithPopup(auth, provider)
|
||||||
@@ -46,8 +49,10 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
const auth = getAuth()
|
if (getApps().length) {
|
||||||
await signOut(auth)
|
const auth = getAuth()
|
||||||
|
await signOut(auth)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Firebase] Sign out failed or not initialized', e)
|
console.warn('[Firebase] Sign out failed or not initialized', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -58,37 +63,44 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
const initAuth = () => {
|
const initAuth = () => {
|
||||||
if (process.server) return
|
if (process.server) return
|
||||||
|
if (!getApps().length) {
|
||||||
|
console.warn('[Firebase] Firebase App not initialized, skipping onAuthStateChanged.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const auth = getAuth()
|
try {
|
||||||
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
const auth = getAuth()
|
||||||
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => {
|
||||||
if (firebaseUser && !token.value) {
|
// If user is authenticated in Firebase but we lost our backend token, try to restore
|
||||||
try {
|
if (firebaseUser && !token.value) {
|
||||||
const idToken = await firebaseUser.getIdToken()
|
try {
|
||||||
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
const idToken = await firebaseUser.getIdToken()
|
||||||
method: 'POST',
|
const res = await $fetch<{ token: string; user: User }>('/api/auth/google', {
|
||||||
body: { idToken }
|
method: 'POST',
|
||||||
})
|
body: { idToken }
|
||||||
token.value = res.token
|
})
|
||||||
|
token.value = res.token
|
||||||
|
|
||||||
if (useRoute().path === '/login') {
|
if (useRoute().path === '/login') {
|
||||||
await navigateTo('/')
|
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
|
|
||||||
})
|
onUnmounted(() => unsubscribe())
|
||||||
return unsubscribe
|
} catch (err) {
|
||||||
|
console.warn('[Firebase] Auth initialization skipped:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
|
||||||
user,
|
user,
|
||||||
|
token,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isInitializing,
|
|
||||||
googleSignIn,
|
googleSignIn,
|
||||||
logout,
|
logout,
|
||||||
initAuth
|
initAuth
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ export default defineNuxtPlugin(() => {
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
const firebaseConfig = {
|
const firebaseConfig = {
|
||||||
apiKey: config.public.firebaseApiKey,
|
apiKey: config.public.firebaseApiKey || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck',
|
||||||
authDomain: config.public.firebaseAuthDomain,
|
authDomain: config.public.firebaseAuthDomain || 'xflow-d8dd5.firebaseapp.com',
|
||||||
projectId: config.public.firebaseProjectId,
|
projectId: config.public.firebaseProjectId || 'xflow-d8dd5',
|
||||||
appId: config.public.firebaseAppId,
|
appId: config.public.firebaseAppId || '1:944408960366:web:4d2cd393e85a0ca9b4357f',
|
||||||
messagingSenderId: config.public.firebaseMessagingSenderId
|
messagingSenderId: config.public.firebaseMessagingSenderId || '944408960366',
|
||||||
|
storageBucket: (config.public as any).firebaseStorageBucket || 'xflow-d8dd5.firebasestorage.app'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firebaseConfig.apiKey) {
|
if (!firebaseConfig.apiKey) {
|
||||||
@@ -18,6 +19,6 @@ export default defineNuxtPlugin(() => {
|
|||||||
|
|
||||||
if (!getApps().length) {
|
if (!getApps().length) {
|
||||||
initializeApp(firebaseConfig)
|
initializeApp(firebaseConfig)
|
||||||
console.log('[Firebase] Initialized')
|
console.log('[Firebase] Initialized with project:', firebaseConfig.projectId)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
jwtSecret: process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key',
|
jwtSecret: process.env.NUXT_JWT_SECRET || 'your-super-secret-jwt-key',
|
||||||
firebaseApiKey: process.env.NUXT_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || '',
|
firebaseApiKey: process.env.NUXT_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck',
|
||||||
giteaUrl: process.env.GITEA_URL || '',
|
giteaUrl: process.env.GITEA_URL || '',
|
||||||
giteaToken: process.env.GITEA_TOKEN || '',
|
giteaToken: process.env.GITEA_TOKEN || '',
|
||||||
giteaOrg: process.env.GITEA_ORG || '',
|
giteaOrg: process.env.GITEA_ORG || '',
|
||||||
@@ -72,11 +72,12 @@ export default defineNuxtConfig({
|
|||||||
qwenApiKey: '',
|
qwenApiKey: '',
|
||||||
public: {
|
public: {
|
||||||
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
|
apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api',
|
||||||
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || '',
|
firebaseApiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY || process.env.FIREBASE_API_KEY || 'AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck',
|
||||||
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN || process.env.FIREBASE_AUTH_DOMAIN || '',
|
firebaseAuthDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN || process.env.FIREBASE_AUTH_DOMAIN || 'xflow-d8dd5.firebaseapp.com',
|
||||||
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID || process.env.FIREBASE_PROJECT_ID || '',
|
firebaseProjectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID || process.env.FIREBASE_PROJECT_ID || 'xflow-d8dd5',
|
||||||
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID || process.env.FIREBASE_APP_ID || '',
|
firebaseAppId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID || process.env.FIREBASE_APP_ID || '1:944408960366:web:4d2cd393e85a0ca9b4357f',
|
||||||
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || process.env.FIREBASE_MESSAGING_SENDER_ID || ''
|
firebaseMessagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || process.env.FIREBASE_MESSAGING_SENDER_ID || '944408960366',
|
||||||
|
firebaseStorageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET || process.env.FIREBASE_STORAGE_BUCKET || 'xflow-d8dd5.firebasestorage.app'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { defineConfig } from "prisma/config";
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
// Призма 7 иногда требует явной загрузки .env для некоторых команд
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env.DATABASE_URL || "postgresql://admin:secure_master_pass@localhost:5432/xflow_crm_db",
|
url: process.env.DATABASE_URL || "postgresql://xflow:xflow@db:5432/xflow",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="6" fill="#0f172a"/>
|
||||||
|
<text x="16" y="22" font-size="18" text-anchor="middle" fill="#3b82f6" font-family="system-ui">N</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "XFlow",
|
||||||
|
"short_name": "XFlow",
|
||||||
|
"description": "XFlow Intelligent Unified Platform",
|
||||||
|
"lang": "ru",
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
|
"theme_color": "#090A0F",
|
||||||
|
"background_color": "#090A0F",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/pwa-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"id": "/",
|
||||||
|
"name": "XFlow",
|
||||||
|
"short_name": "XFlow",
|
||||||
|
"description": "XFlow Intelligent Unified Platform",
|
||||||
|
"lang": "ru",
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
|
"theme_color": "#090A0F",
|
||||||
|
"background_color": "#090A0F",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/pwa-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/pwa-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#0f172a"/>
|
||||||
|
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 273 B |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" rx="24" fill="#0f172a"/>
|
||||||
|
<text x="96" y="128" font-size="96" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 279 B |
|
After Width: | Height: | Size: 292 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="64" fill="#0f172a"/>
|
||||||
|
<text x="256" y="350" font-size="280" text-anchor="middle" fill="#3b82f6" font-family="system-ui" font-weight="bold">N</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 281 B |
@@ -47,29 +47,43 @@ export default defineEventHandler(async (event) => {
|
|||||||
const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')
|
const username = email.split('@')[0].toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '')
|
||||||
const userId = fbUser.localId
|
const userId = fbUser.localId
|
||||||
|
|
||||||
const user = await prisma.user.upsert({
|
let user: any
|
||||||
where: { email },
|
try {
|
||||||
update: {
|
user = await prisma.user.upsert({
|
||||||
username
|
where: { email },
|
||||||
// Мы не затираем профиль при каждом входе, но нам нужно его получить
|
update: {
|
||||||
},
|
username
|
||||||
create: {
|
},
|
||||||
|
create: {
|
||||||
|
id: userId,
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password: 'firebase-managed',
|
||||||
|
role: 'user',
|
||||||
|
profile: {
|
||||||
|
create: {
|
||||||
|
displayName: fbUser.displayName || username,
|
||||||
|
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
profile: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (dbErr: any) {
|
||||||
|
console.warn('[Auth] Database offline, creating local dev session for:', email, dbErr.message)
|
||||||
|
user = {
|
||||||
id: userId,
|
id: userId,
|
||||||
email,
|
email,
|
||||||
username,
|
username,
|
||||||
password: 'firebase-managed',
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
profile: {
|
profile: {
|
||||||
create: {
|
displayName: fbUser.displayName || username,
|
||||||
displayName: fbUser.displayName || username,
|
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
|
||||||
avatar: fbUser.photoUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
include: {
|
|
||||||
profile: true
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
const token = generateToken({
|
const token = generateToken({
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@@ -7,41 +7,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 400, message: 'User ID is required' })
|
throw createError({ statusCode: 400, message: 'User ID is required' })
|
||||||
}
|
}
|
||||||
|
|
||||||
let profile = await prisma.profile.findFirst({
|
let profile: any = null
|
||||||
where: {
|
try {
|
||||||
OR: [
|
profile = await prisma.profile.findFirst({
|
||||||
{ 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: {
|
where: {
|
||||||
OR: [{ id: userId }, { username: userId }]
|
OR: [
|
||||||
}
|
{ userId: userId },
|
||||||
})
|
{ user: { 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: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
@@ -53,14 +26,55 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [{ id: userId }, { username: userId }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn(`[Profile API] Database unreachable (${e.message}), returning dev profile for ${userId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
profile = {
|
||||||
|
userId,
|
||||||
|
displayName: userId.length > 20 ? 'User' : userId,
|
||||||
|
avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${userId}`,
|
||||||
|
bio: '',
|
||||||
|
user: {
|
||||||
|
_count: { canvases: 0, followers: 0, following: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile: {
|
profile: {
|
||||||
...profile,
|
...profile,
|
||||||
canvasCount: profile.user._count.canvases,
|
canvasCount: profile.user?._count?.canvases || 0,
|
||||||
followerCount: profile.user._count.followers,
|
followerCount: profile.user?._count?.followers || 0,
|
||||||
followingCount: profile.user._count.following
|
followingCount: profile.user?._count?.following || 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ services:
|
|||||||
dockerfile: Dockerfile.coolify
|
dockerfile: Dockerfile.coolify
|
||||||
args:
|
args:
|
||||||
SITE_URL: '${SITE_URL:-http://localhost:3000}'
|
SITE_URL: '${SITE_URL:-http://localhost:3000}'
|
||||||
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
|
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
|
||||||
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
|
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
|
||||||
JWT_SECRET: '${JWT_SECRET:-super-secret-jwt-key}'
|
JWT_SECRET: '${JWT_SECRET:-super-secret-jwt-key}'
|
||||||
OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
|
OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
|
||||||
NUXT_OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
|
NUXT_OPEN_ROUTER_API_KEY: '${OPEN_ROUTER_API_KEY:-}'
|
||||||
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
|
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
|
||||||
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
|
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
|
||||||
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
|
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
|
||||||
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
|
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
|
||||||
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
|
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
|
||||||
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
|
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
|
||||||
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
|
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
|
||||||
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
|
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
|
||||||
TZ: '${TZ:-Asia/Almaty}'
|
TZ: '${TZ:-Asia/Almaty}'
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
@@ -49,9 +49,9 @@ services:
|
|||||||
|
|
||||||
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
|
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
|
||||||
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
|
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
|
||||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD}'
|
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}'
|
||||||
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
|
DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
|
||||||
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD}@db:5432/xflow}'
|
NUXT_DATABASE_URL: '${DATABASE_URL:-postgresql://xflow:${POSTGRES_PASSWORD:-xflow_db_secret_pass}@db:5432/xflow}'
|
||||||
|
|
||||||
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
|
REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
|
||||||
NUXT_REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
|
NUXT_REDIS_URL: '${REDIS_URL:-redis://redis:6379}'
|
||||||
@@ -64,32 +64,33 @@ services:
|
|||||||
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
|
NUXT_QWEN_API_KEY: '${NUXT_QWEN_API_KEY:-}'
|
||||||
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
|
DEEPSEEK_API_KEY: '${DEEPSEEK_API_KEY:-}'
|
||||||
|
|
||||||
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
|
FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
|
||||||
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
|
NUXT_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
|
||||||
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-}'
|
NUXT_PUBLIC_FIREBASE_API_KEY: '${FIREBASE_API_KEY:-AIzaSyDP1IxP7ntZ5k3agzulPjW_dDsQMKsMPck}'
|
||||||
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
|
FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
|
||||||
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
|
NUXT_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
|
||||||
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-}'
|
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN: '${FIREBASE_AUTH_DOMAIN:-xflow-d8dd5.firebaseapp.com}'
|
||||||
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
|
FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
|
||||||
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
|
NUXT_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
|
||||||
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-}'
|
NUXT_PUBLIC_FIREBASE_PROJECT_ID: '${FIREBASE_PROJECT_ID:-xflow-d8dd5}'
|
||||||
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
|
FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
|
||||||
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
|
NUXT_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
|
||||||
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-}'
|
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET: '${FIREBASE_STORAGE_BUCKET:-xflow-d8dd5.firebasestorage.app}'
|
||||||
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
|
FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
|
||||||
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
|
NUXT_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
|
||||||
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-}'
|
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: '${FIREBASE_MESSAGING_SENDER_ID:-944408960366}'
|
||||||
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
|
FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
|
||||||
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
|
NUXT_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
|
||||||
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-}'
|
NUXT_PUBLIC_FIREBASE_APP_ID: '${FIREBASE_APP_ID:-1:944408960366:web:4d2cd393e85a0ca9b4357f}'
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
|
POSTGRES_USER: '${POSTGRES_USER:-xflow}'
|
||||||
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:?}'
|
POSTGRES_PASSWORD: '${POSTGRES_PASSWORD:-xflow_db_secret_pass}'
|
||||||
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
|
POSTGRES_DB: '${POSTGRES_DB:-xflow}'
|
||||||
|
POSTGRES_HOST_AUTH_METHOD: 'trust'
|
||||||
volumes:
|
volumes:
|
||||||
- xflow-pg-data:/var/lib/postgresql/data
|
- xflow-pg-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
container_name: xflow-app
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.coolify
|
||||||
|
args:
|
||||||
|
SITE_URL: http://localhost:3000
|
||||||
|
DATABASE_URL: postgresql://xflow:xflow_pass@db:5432/xflow
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: dev-super-secret-jwt-key
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
NODE_ENV: development
|
||||||
|
HOST: "0.0.0.0"
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://xflow:xflow_pass@db:5432/xflow
|
||||||
|
NUXT_DATABASE_URL: postgresql://xflow:xflow_pass@db:5432/xflow
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
NUXT_REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: dev-super-secret-jwt-key
|
||||||
|
NUXT_JWT_SECRET: dev-super-secret-jwt-key
|
||||||
|
SITE_URL: http://localhost:3000
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- xflow-network
|
||||||
|
|
||||||
|
db:
|
||||||
|
container_name: xflow-db
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: xflow
|
||||||
|
POSTGRES_PASSWORD: xflow_pass
|
||||||
|
POSTGRES_DB: xflow
|
||||||
|
volumes:
|
||||||
|
- xflow-local-pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U xflow -d xflow"]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- xflow-network
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: xflow-redis
|
||||||
|
image: redis:7.2-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
volumes:
|
||||||
|
- xflow-local-redisdata:/data
|
||||||
|
networks:
|
||||||
|
- xflow-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
xflow-local-pgdata:
|
||||||
|
xflow-local-redisdata:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
xflow-network:
|
||||||
|
driver: bridge
|
||||||