feat(assistant): add full Assistant note-taking pipeline, intent routing, and in-app Assistant Simulator
This commit is contained in:
@@ -30,14 +30,25 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Deep link intent-filter for Gemini / Assistant App Actions -->
|
||||
<!-- Deep link intent-filter for Gemini / Assistant App Actions & XFlow links -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="xflow"
|
||||
android:host="lists" />
|
||||
<data android:scheme="xflow" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Google Assistant Native Note-Taking Action ("Сделай заметку...") -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.CREATE_NOTE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Android System Share Action (Поделиться текстом из любого приложения или ассистента) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.example.avtex
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
@@ -40,47 +41,147 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
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
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == "xflow") {
|
||||
val listName = data.getQueryParameter("list_name") ?: data.getQueryParameter("name") ?: "Фильмы на просмотр"
|
||||
val itemName = data.getQueryParameter("item_name") ?: data.getQueryParameter("item")
|
||||
|
||||
if (!itemName.isNullOrBlank()) {
|
||||
saveItemFromAssistant(itemName, listName)
|
||||
// 1. Google Assistant / Android System Note Action (android.intent.action.CREATE_NOTE)
|
||||
if (action == Intent.ACTION_CREATE_NOTE) {
|
||||
val text = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
?: intent.getStringExtra("android.intent.extra.TEXT")
|
||||
?: intent.getStringExtra(Intent.EXTRA_TITLE)
|
||||
val title = intent.getStringExtra(Intent.EXTRA_TITLE)
|
||||
if (!text.isNullOrBlank()) {
|
||||
val fullText = if (!title.isNullOrBlank() && title != text) "$title: $text" else text
|
||||
routeAndSaveNote(fullText, preferredList = null)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Обработка через Intent Extras (Google Assistant Built-in Intents)
|
||||
val extras = intent.extras
|
||||
if (extras != null) {
|
||||
val itemName = extras.getString("itemListElement.name") ?: extras.getString("item_name")
|
||||
val listName = extras.getString("itemList.name") ?: extras.getString("list_name") ?: "Фильмы на просмотр"
|
||||
// 2. 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
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Deep Link Action (xflow://...)
|
||||
val data = intent.data
|
||||
if (data != null && data.scheme == "xflow") {
|
||||
val path = data.path ?: data.host ?: ""
|
||||
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()) {
|
||||
saveItemFromAssistant(itemName, listName)
|
||||
routeAndSaveNote(itemName, preferredList = listName)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 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 {
|
||||
try {
|
||||
val repo = AvtexApplication.instance.notesRepository
|
||||
val category = if (listName.contains("фильм", ignoreCase = true)) "movies" else "general"
|
||||
|
||||
repo.addNoteItem(
|
||||
title = itemName,
|
||||
listTitle = listName,
|
||||
title = cleanText,
|
||||
listTitle = targetList,
|
||||
category = category
|
||||
)
|
||||
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"✅ Записано в XFlow: $itemName ($listName)",
|
||||
"✅ XFlow: Записано в «$targetList»:\n$cleanText",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
|
||||
AvtexApplication.instance.settingsRepository.addLog(
|
||||
tag = "Assistant",
|
||||
message = "Заметка через ассистент: \"$cleanText\" в список \"$targetList\"",
|
||||
isImportant = true
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to save note from assistant", e)
|
||||
Toast.makeText(
|
||||
this@MainActivity,
|
||||
"Ошибка сохранения: ${e.localizedMessage}",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveListAndCategory(text: String, preferredList: String?): Pair<String, String> {
|
||||
val lower = text.lowercase()
|
||||
|
||||
// Если список был явно передан в интенте
|
||||
if (!preferredList.isNullOrBlank()) {
|
||||
val prefLower = preferredList.lowercase()
|
||||
return when {
|
||||
prefLower.contains("фильм") || prefLower.contains("кино") || prefLower.contains("сериал") || prefLower.contains("movie") ->
|
||||
Pair(preferredList, "movies")
|
||||
prefLower.contains("покупк") || prefLower.contains("магаз") || prefLower.contains("купить") || prefLower.contains("shop") ->
|
||||
Pair(preferredList, "shopping")
|
||||
prefLower.contains("задач") || prefLower.contains("дело") || prefLower.contains("task") || prefLower.contains("todo") ->
|
||||
Pair(preferredList, "tasks")
|
||||
else -> Pair(preferredList, "general")
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-маршрутизация по содержанию текста
|
||||
return when {
|
||||
lower.contains("фильм") || lower.contains("сериал") || lower.contains("кино") ||
|
||||
lower.contains("посмотреть") || lower.contains("сезон") || lower.contains("актер") ||
|
||||
lower.contains("режиссер") || lower.contains("movie") || lower.contains("cinema") ->
|
||||
Pair("Фильмы на просмотр", "movies")
|
||||
|
||||
lower.contains("купить") || lower.contains("покупки") || lower.contains("магазин") ||
|
||||
lower.contains("взять") || lower.contains("продукты") || lower.contains("цена") ||
|
||||
lower.contains("аптек") || lower.contains("buy") || lower.contains("shop") ->
|
||||
Pair("Покупки", "shopping")
|
||||
|
||||
lower.contains("задача") || lower.contains("сделать") || lower.contains("напомни") ||
|
||||
lower.contains("позвонить") || lower.contains("отправить") || lower.contains("todo") ->
|
||||
Pair("Задачи", "tasks")
|
||||
|
||||
else -> Pair("Быстрые заметки", "general")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AvtexMainActivity"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -17,24 +22,30 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.ShoppingCart
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material.icons.filled.ExpandLess
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -48,17 +59,18 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.example.avtex.AvtexApplication
|
||||
import com.example.avtex.MainActivity
|
||||
import com.example.avtex.notes.NoteItem
|
||||
import com.example.avtex.notes.NoteList
|
||||
import com.example.avtex.theme.CyanAccent
|
||||
import com.example.avtex.theme.ElectricIndigo
|
||||
import com.example.avtex.theme.EmeraldMint
|
||||
import com.example.avtex.theme.GlassBorder
|
||||
import com.example.avtex.theme.NeonViolet
|
||||
@@ -74,35 +86,30 @@ import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val repository = remember { AvtexApplication.instance.notesRepository }
|
||||
val lists by repository.listsFlow.collectAsState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
var selectedCategory by remember { mutableStateOf("all") }
|
||||
var newItemText by remember { mutableStateOf("") }
|
||||
var isAddingActive by remember { mutableStateOf(false) }
|
||||
|
||||
val categories = listOf(
|
||||
"all" to "Все",
|
||||
"movies" to "🎬 Фильмы",
|
||||
"shopping" to "🛒 Покупки",
|
||||
"tasks" to "⚡ Дела"
|
||||
)
|
||||
var showAssistantTester by remember { mutableStateOf(false) }
|
||||
|
||||
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(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.background(ObsidianBg)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentPadding = PaddingValues(top = 16.dp, bottom = 32.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
.background(ObsidianBg),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)
|
||||
) {
|
||||
// Header
|
||||
// 1. Header
|
||||
item {
|
||||
Column(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
@@ -110,77 +117,94 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = "XFlow Заметки & Списки",
|
||||
fontSize = 22.sp,
|
||||
text = "Списки и Заметки",
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "Мульти-записная книжка • Голос Gemini • Синхронизация",
|
||||
text = "XFlow синхронизация • Голосовой ввод Ассистента",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { repository.reload() },
|
||||
modifier = Modifier
|
||||
.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, CircleShape)
|
||||
|
||||
// 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.Refresh,
|
||||
contentDescription = "Обновить",
|
||||
tint = CyanAccent,
|
||||
modifier = Modifier.size(18.dp)
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = null,
|
||||
tint = NeonViolet,
|
||||
modifier = Modifier.size(16.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)
|
||||
) {
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (isSelected) TextPrimary else TextSecondary
|
||||
text = if (showAssistantTester) "Скрыть тест" else "Тест Ассистента",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NeonViolet
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Add Item Bar
|
||||
// 2. Interactive Assistant Simulator Card
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@@ -189,7 +213,9 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
onValueChange = { newItemText = it },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (selectedCategory == "movies") "Название фильма на просмотр..." else "Новая запись...",
|
||||
if (selectedCategory == "movies") "Название фильма на просмотр..."
|
||||
else if (selectedCategory == "shopping") "Купить..."
|
||||
else "Новая запись или заметка...",
|
||||
fontSize = 13.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
@@ -214,7 +240,8 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
val listTitle = when (targetCategory) {
|
||||
"movies" -> "Фильмы на просмотр"
|
||||
"shopping" -> "Покупки"
|
||||
else -> "Общий список"
|
||||
"tasks" -> "Задачи"
|
||||
else -> "Быстрые заметки"
|
||||
}
|
||||
repository.addNoteItem(
|
||||
title = newItemText.trim(),
|
||||
@@ -235,22 +262,33 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
// Lists and Items
|
||||
// 5. Lists and Items
|
||||
if (filteredLists.isEmpty()) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 40.dp),
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.padding(vertical = 40.dp, horizontal = 20.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
"Нет записей. Добавьте первую заметку или скажите Gemini!",
|
||||
fontSize = 13.sp,
|
||||
text = "Нет записей в выбранной категории",
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextSecondary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Добавьте запись вручную или нажмите «Тест Ассистента» выше!",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(filteredLists, key = { it.id }) { list ->
|
||||
ListCard(
|
||||
@@ -267,6 +305,253 @@ fun ListsScreen(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantTesterCard(context: Context) {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "Быстрые сценарии тестирования (нажмите для проверки):",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextSecondary
|
||||
)
|
||||
|
||||
// Preset Buttons Grid
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AssistantPresetButton(
|
||||
label = "🎬 Фильм: «Запиши фильм Начало на вечер»",
|
||||
onClick = { simulateVoiceCommand("фильм Начало на вечер") }
|
||||
)
|
||||
AssistantPresetButton(
|
||||
label = "🛒 Покупки: «Купить молоко, кофе и сыр»",
|
||||
onClick = { simulateVoiceCommand("купить молоко, кофе и сыр") }
|
||||
)
|
||||
AssistantPresetButton(
|
||||
label = "📋 Задача: «Позвонить автомеханику в 11»",
|
||||
onClick = { simulateVoiceCommand("задача позвонить автомеханику в 11") }
|
||||
)
|
||||
AssistantPresetButton(
|
||||
label = "💡 Заметка: «Идея для нового проекта XFlow»",
|
||||
onClick = { simulateVoiceCommand("Идея для нового проекта XFlow") }
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Custom text input
|
||||
Text(
|
||||
text = "Своя фраза для проверки:",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextSecondary
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = customVoiceQuery,
|
||||
onValueChange = { customVoiceQuery = it },
|
||||
placeholder = { Text("Например: сериал Локи 2 сезон", fontSize = 12.sp, color = TextMuted) },
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
singleLine = true,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedBorderColor = NeonViolet,
|
||||
unfocusedBorderColor = GlassBorder,
|
||||
focusedTextColor = TextPrimary,
|
||||
unfocusedTextColor = TextPrimary
|
||||
)
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = {
|
||||
if (customVoiceQuery.isNotBlank()) {
|
||||
simulateVoiceCommand(customVoiceQuery.trim())
|
||||
customVoiceQuery = ""
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = ButtonDefaults.buttonColors(containerColor = NeonViolet)
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, contentDescription = "Запуск", tint = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
// ADB command helper
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(10.dp))
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Terminal, contentDescription = null, tint = CyanAccent, modifier = Modifier.size(14.dp))
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = "Команда для теста через ADB с ПК:",
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = CyanAccent
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("adb command", adbCommand))
|
||||
Toast.makeText(context, "Команда скопирована в буфер", Toast.LENGTH_SHORT).show()
|
||||
},
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(Icons.Default.ContentCopy, contentDescription = "Копировать", tint = TextMuted, modifier = Modifier.size(14.dp))
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = adbCommand,
|
||||
fontSize = 10.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
color = TextSecondary,
|
||||
lineHeight = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AssistantPresetButton(
|
||||
label: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(8.dp))
|
||||
.clickable { onClick() }
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = TextPrimary
|
||||
)
|
||||
Text(
|
||||
text = "ТЕСТ ▶",
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = NeonViolet
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CategoryChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FilterChip(
|
||||
selected = selected,
|
||||
onClick = onClick,
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NeonViolet.copy(alpha = 0.25f),
|
||||
selectedLabelColor = Color.White,
|
||||
containerColor = ObsidianSurfaceElevated,
|
||||
labelColor = TextMuted
|
||||
),
|
||||
border = FilterChipDefaults.filterChipBorder(
|
||||
enabled = true,
|
||||
selected = selected,
|
||||
borderColor = GlassBorder,
|
||||
selectedBorderColor = NeonViolet.copy(alpha = 0.6f)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ListCard(
|
||||
list: NoteList,
|
||||
@@ -283,9 +568,9 @@ private fun ListCard(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.border(1.dp, GlassBorder, RoundedCornerShape(18.dp))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
@@ -307,17 +592,25 @@ private fun ListCard(
|
||||
)
|
||||
Text(
|
||||
text = list.title,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = TextPrimary
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(accentColor.copy(alpha = 0.15f))
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${list.items.count { !it.isCompleted }} активных",
|
||||
fontSize = 11.sp,
|
||||
color = TextMuted
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = accentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Items list
|
||||
if (list.items.isEmpty()) {
|
||||
@@ -325,7 +618,7 @@ private fun ListCard(
|
||||
text = "Список пуст",
|
||||
fontSize = 12.sp,
|
||||
color = TextMuted,
|
||||
modifier = Modifier.padding(start = 16.dp)
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 4.dp)
|
||||
)
|
||||
} else {
|
||||
list.items.forEach { item ->
|
||||
@@ -351,10 +644,10 @@ private fun ItemRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(ObsidianSurfaceElevated)
|
||||
.background(ObsidianSurface)
|
||||
.border(1.dp, GlassBorder.copy(alpha = 0.5f), RoundedCornerShape(10.dp))
|
||||
.clickable { onToggle() }
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Checkbox(
|
||||
@@ -381,13 +674,15 @@ private fun ItemRow(
|
||||
Text(
|
||||
text = "🎬 Буду смотреть",
|
||||
fontSize = 10.sp,
|
||||
color = CyanAccent
|
||||
color = CyanAccent,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
} else if (item.status == "watched") {
|
||||
Text(
|
||||
text = "✔ Просмотрено",
|
||||
fontSize = 10.sp,
|
||||
color = EmeraldMint
|
||||
color = EmeraldMint,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.example.avtex.ui
|
||||
|
||||
import android.content.Context
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
@@ -80,6 +82,12 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
|
||||
var token by remember(settings.hacToken) { mutableStateOf(settings.hacToken) }
|
||||
var inactivityMinutes by remember(settings.inactivityMinutes) { mutableFloatStateOf(settings.inactivityMinutes.toFloat()) }
|
||||
var confidenceThreshold by remember(settings.confidenceThreshold) { mutableFloatStateOf(settings.confidenceThreshold.toFloat()) }
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val xflowPrefs = remember { context.getSharedPreferences("xflow_prefs", Context.MODE_PRIVATE) }
|
||||
var xflowServerUrl by remember {
|
||||
mutableStateOf(xflowPrefs.getString("xflow_server_url", "https://xflow.controlsystem.kz") ?: "https://xflow.controlsystem.kz")
|
||||
}
|
||||
var xflowTestStatus by remember { mutableStateOf<String?>(null) }
|
||||
var broadcastIntents by remember(settings.broadcastIntents) { mutableStateOf(settings.broadcastIntents) }
|
||||
|
||||
var isSaved by remember { mutableStateOf(false) }
|
||||
@@ -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
|
||||
item {
|
||||
Button(
|
||||
@@ -398,6 +507,7 @@ fun SettingsScreen(modifier: Modifier = Modifier) {
|
||||
confidenceThreshold = confidenceThreshold.roundToInt(),
|
||||
broadcastIntents = broadcastIntents
|
||||
)
|
||||
xflowPrefs.edit().putString("xflow_server_url", xflowServerUrl.trim()).apply()
|
||||
isSaved = true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- Добавление элемента в список через Gemini / Google Assistant -->
|
||||
<!-- Создание заметки через Gemini / Google Assistant ("Сделай заметку...") -->
|
||||
<capability android:name="actions.intent.CREATE_NOTE">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
android:targetPackage="com.example.avtex"
|
||||
android:targetClass="com.example.avtex.MainActivity"
|
||||
android:data="xflow://notes/create">
|
||||
<parameter
|
||||
android:name="note.text"
|
||||
android:key="note_text" />
|
||||
<parameter
|
||||
android:name="note.name"
|
||||
android:key="note_title" />
|
||||
</intent>
|
||||
</capability>
|
||||
|
||||
<!-- Добавление элемента в список через Gemini / Google Assistant ("Добавь фильм в Avtex...") -->
|
||||
<capability android:name="actions.intent.ADD_ITEM_TO_LIST">
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
|
||||
Reference in New Issue
Block a user