Compare commits

...

5 Commits

Author SHA1 Message Date
Alexyz 4d212e1a9a feat: complete lexicon anomalies heal, merge grammatical duplicates and add local Xiaomi-OCR documentation 2026-10-11 17:40:07 +05:00
Alexyz 88a6204f1b fix: instantiate WordsRepository in CrossSystemComparisonScreenState 2026-10-11 15:36:48 +05:00
Alexyz e871476868 fix: merge duplicate symbols, expand Zoroastrian citations, add reading deletion to sessions, and deduplicate Word of the Day 2026-10-11 15:34:52 +05:00
Alexyz 0b12e4583b feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets 2026-10-09 15:26:58 +05:00
Alexyz 7f91366201 feat: Random.org dice and daily word matrix, interpreter voices, curated lexicon
Wire cleromancy rolls and the once-per-UTC-day 000-999 word shuffle to RANDOM.ORG JSON-RPC, with a local CSPRNG fallback. Add settings for interpreter personas and a cleaner default oracle prompt. Replace proper names and bureaucratic filler in the 1000-word lexicon with sacred symbols.
2026-09-08 13:46:11 +05:00
255 changed files with 618715 additions and 7538 deletions
+12
View File
@@ -48,3 +48,15 @@ app.*.map.json
# Local bakeoff dumps # Local bakeoff dumps
/generated_media/ /generated_media/
# Local secrets (RANDOM.ORG API key, etc.)
/lib/secrets.dart
# Raw reference corpora, heavy assets, and temporary outputs
/dictionaries/
/oracle_enricher/
/out/
/assets/illustrations/
bakeoff.json
words_alphabetical.md
words_alphabetical.txt
+94
View File
@@ -0,0 +1,94 @@
# Локальный OCR-сервис Xiaomi-OCR-0
## Контекст
На этой машине развёрнута и работает локальная OCR-модель Xiaomi-OCR-0 (SeerRay-Lab/Xiaomi-OCR-0). Это vision-language модель на 0.8B параметров (основа — Qwen3.5-0.8B, архитектура Qwen3_5ForConditionalGeneration). Она умеет:
- Разбор документов → Markdown (таблицы в OTSL → HTML, формулы в LaTeX, порядок чтения).
- KIE (извлечение ключевой информации) → JSON по заданной схеме полей.
- VQA — короткие ответы на вопросы по изображению.
- Разбор отдельных регионов: текст, таблица, формула.
- Сканы, чеки, формулы (в т.ч. рукописные), рукописный текст, каллиграфию, PDF (через рендер страниц).
- Всё работает только локально, без облачных API. Ключ API не нужен.
## Окружение
| Параметр | Значение |
|---|---|
| ОС | Ubuntu 26.04, Linux x86_64 |
| GPU | NVIDIA RTX 4060 Laptop, 8 ГБ VRAM (vLLM занимает ~75%) |
| Каталог проекта | `/home/alexives/OCR Xiaomi neuro` (в пути есть пробел — всегда бери его в кавычки) |
| Python venv | `/home/alexives/OCR Xiaomi neuro/.venv` → симлинк на `/mnt/data/alexives_cache/xiaomi_ocr_venv` (Python 3.12) |
| Кэш HF (веса модели) | `/mnt/data/alexives_cache/huggingface` |
| Движок инференса | vLLM 0.31.0, CUDA 13 |
| Обязательная переменная окружения | `VLLM_USE_FLASHINFER_SAMPLER=0` (без неё vLLM падает: нет системного nvcc/CUDA toolkit для JIT FlashInfer) |
> На системном разделе `/` мало места (~3.7 ГБ). Любые тяжёлые кэши и зависимости клади в `/mnt/data/alexives_cache/`.
## Запуск и остановка
```bash
"/home/alexives/OCR Xiaomi neuro/start.sh" # запускает vLLM (:8000) и веб-демо (:8787), ждёт готовности
"/home/alexives/OCR Xiaomi neuro/stop.sh" # останавливает оба процесса
```
Холодный старт vLLM занимает ~2 минуты (torch.compile + захват графов CUDA). Перед запросами опрашивай `GET /v1/models`, пока не придёт ответ.
Логи: `vllm.log` и `demo.log` в каталоге проекта.
## Сервисы
| Сервис | URL | Назначение |
|---|---|---|
| vLLM (OpenAI-совместимый API) | `http://127.0.0.1:8000/v1` | Прямой инференс. Основная точка интеграции. |
| Веб-демо + REST | `http://127.0.0.1:8787` | UI в браузере и упрощённый REST с готовой пред- и постобработкой |
- Имя модели в запросах: `SeerRay-Lab/Xiaomi-OCR-0` (именно так, с префиксом).
- Проверка готовности: `curl -s http://127.0.0.1:8000/v1/models` и `curl -s http://127.0.0.1:8787/api/health` (`"ready": true`).
- Сервисы слушают только loopback (`127.0.0.1`). Не открывай их наружу.
## Вариант A: прямой вызов vLLM (рекомендуется)
### Обязательные правила запроса
1. Ровно одно изображение на запрос, сначала изображение, потом текст, в одном user-сообщении.
2. Изображение передаётся как data-URL: `data:image/png;base64,...`.
3. Thinking-режим выключен: `"chat_template_kwargs": {"enable_thinking": false}`.
4. Жадное декодирование (как в эталонном пайплайне):
`"temperature": 0, "top_p": 0.8, "presence_penalty": 0.0, "repetition_penalty": 1.0`
5. `max_tokens`: 4096 по умолчанию для целой страницы (максимум 16384, но картинка + промпт + вывод должны влезть в контекст 16384).
6. Если `finish_reason == "length"` — вывод обрезан: увеличь `max_tokens` или уменьши картинку / раздели её на части.
7. Без системного сообщения. Промпты — на английском, дословно.
### Промпты (дословно)
- **Целая страница документа → Markdown:**
`Extract all information from the main body of the document image and represent it in markdown format, ignoring headers and footers. Tables should be expressed in OTSL format, formulas in the document should be represented using LATEX format, and the parsing should be organized according to the reading order.`
- **Регион с текстом / заголовком:**
`Extract the text in the image.`
- **Регион с таблицей:**
`Parse the table in the image into OTSL.`
- **Регион с формулой:**
`Identify the formula in the image and represent it using LATEX format.`
- **KIE с известными полями:**
`Extract key information in the image`
`Please output the key information in JSON format according to the following schema:`
`{"company": "", "date": "", "total": ""}`
- **KIE без заданных полей:**
`Extract the key information from the document image as a JSON object. Use concise field names. Return JSON only; use an empty string for missing values.`
- **VQA:**
`<ВОПРОС>
Answer the question using a single word or phrase.`
### Постобработка
Таблицы приходят в OTSL (токены `<fcel>`, `<ecel>`, `<lcel>`, `<ucel>`, `<xcel>`, `<nl>`). Конвертируй в HTML:
```python
import sys; sys.path.insert(0, "/home/alexives/OCR Xiaomi neuro")
from postprocess.otsl import convert_otsl_to_html
from postprocess.document import finalize_document
md = finalize_document(raw_model_text)
```
## Вариант B: REST веб-демо (:8787)
POST `http://127.0.0.1:8787/api/ocr`, `Content-Type: application/json`:
```json
{
"task": "document",
"image_base64": "<base64 или data:image/...;base64,...>",
"image_mime": "image/jpeg",
"max_tokens": 4096,
"temperature": 0
}
```
+10
View File
@@ -7,6 +7,16 @@
# The following line activates a set of recommended lints for Flutter apps, # The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices. # packages, and plugins designed to encourage good coding practices.
analyzer:
exclude:
- oracle_enricher/**
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
include: package:flutter_lints/flutter.yaml include: package:flutter_lints/flutter.yaml
linter: linter:
+4 -2
View File
@@ -1,8 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="Оракул 999" android:label="Оракул"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+20 -13
View File
@@ -15,22 +15,29 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir) project.layout.buildDirectory.value(newSubprojectBuildDir)
} }
subprojects { subprojects {
project.evaluationDependsOn(":app") afterEvaluate {
val android = extensions.findByName("android")
if (android != null) {
try {
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)
} catch (e: Throwable) {}
try {
android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType).invoke(android, 34)
} catch (e: Throwable) {}
try {
android.javaClass.getMethod("compileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 34)
} catch (e: Throwable) {}
try {
android.javaClass.getMethod("compileSdkVersion", String::class.java).invoke(android, "android-34")
} catch (e: Throwable) {}
}
}
} }
gradle.projectsEvaluated { subprojects {
rootProject.subprojects.forEach { proj -> project.evaluationDependsOn(":app")
val android = proj.extensions.findByName("android") ?: return@forEach
try {
android.javaClass.getMethod("setCompileSdk", Int::class.javaPrimitiveType).invoke(android, 36)
} catch (_: Throwable) {
try {
android.javaClass.getMethod("setCompileSdkVersion", Int::class.javaPrimitiveType).invoke(android, 36)
} catch (_: Throwable) {
}
}
}
} }
tasks.register<Delete>("clean") { tasks.register<Delete>("clean") {
File diff suppressed because one or more lines are too long
+246
View File
@@ -0,0 +1,246 @@
{
"system": "ogham",
"card_count": 20,
"cards": [
{
"n": 1,
"id": "beith",
"name": "1 Бэйт (Beith / Берёза)",
"symbol": "ᚁ",
"lore": "Берёза знаменует переход порога, чистый старт и смелость первопроходца, готового воплотить ясное намерение в решительное действие.",
"group": "I Аикма Бэйт",
"meaning": "Берёза — первое дерево, заселяющее гари и пустоши, вечный символ очищения, белого листа и неукротимой жизненной силы, пробивающейся сквозь застой.\n\nВ раскладе Beith возвещает начало нового цикла: вы пересекаете порог, за которым открываются свежие пути и возможности. Это импульс рождения идеи или проекта с высоким потенциалом. Карта призывает встретить грядущее с предельно ясным, осознанным намерением — именно чистота замысла определит дальнейший рост.\n\nПоявление этого знака требует мужества и духа первооткрывателя. Пришло время сломать закостеневшие шаблоны мышления, покончить с разрушительными привычками и выйти за привычные рамки. Beith напоминает: перемены не происходят от одних разговоров — нужно взять на себя смелость сделать первый шаг, даже если в этот момент за вами никто не идет. Любое великое обновление строится из крошечных ежедневных выборов: быть чуть честнее с собой, смелее и решительнее, чем вчера.",
"shadow": "Теневое и перевёрнутое проявление Beith вскрывает искажения в отношениях с новым началом, уходящие в две крайности — избытка или дефицита.\n\nВ первом случае это фальстарт и поспешность: действие предпринимается до того, как вы к нему готовы. Силы распыляются на хаос противоречивых идей, погоню за мгновенным успехом или иллюзорные проекты, последствия которых не просчитаны. Это также горечь одиночества первопроходца, опередившего своё время, или нежелание трезво оценить жизнеспособность замысла. Вам необходимо безжалостно отсечь лишние идеи, чтобы спасти главное.\n\nВо втором случае это паралич воли, апатия и застой. Вы судорожно цепляетесь за то, что давно пора отпустить, боитесь сделать шаг в неизвестность и вините окружение в собственных трудностях. Тень Beith предостерегает от иллюзии, будто проблем можно избежать или переждать: нужно взглянуть в лицо внутренним страхам, прекратить саботаж и взять ответственность за движение вперёд.",
"meanings": [],
"pairs": []
},
{
"n": 2,
"id": "luis",
"name": "2 Луис (Luis / Рябина)",
"symbol": "ᚂ",
"lore": "Луис — знак защиты, духовного покровительства и внутренней стойкости, призывающий смотреть глубже внешних обстоятельств и быть заботливым родителем самому себе.",
"group": "I Аикма Бэйт",
"meaning": "Дерево знака — рябина, традиционный оберег от невзгод, символ невидимой защиты, душевной силы и связи со своим духовным хранителем или высшим «Я». В раскладе Луис возвещает, что ты не одинок: тебя берегут, направляют и ценят. Чтобы эта поддержка работала в полную силу, доверяй своей интуиции, учись смотреть сквозь внешнюю видимость вещей и будь открыт мудрым наставлениям, не превращая духовную помощь в инструмент потребительских ожиданий.\n\nВ практическом плане Луис ставит вопрос зрелой заботы о себе: здесь ты призван стать любящим и ответственным родителем собственному внутреннему существу. Это означает создание безопасного пространства для роста, сознательное регулирование своих эмоций, бережное отношение к здоровью и принятие разумных мер предосторожности без впадения в крайности. У тебя есть всё необходимое, чтобы укрепить личные границы и вырастить в себе подлинную жизнестойкость.",
"shadow": "В тени или перевёрнутом положении Луис указывает на искажённое восприятие, ошибочное доверие и две разрушительные крайности безопасности. С одной стороны — гиперопека, удушающий контроль и попытка спрятаться в иллюзорном коконе, что делает человека слабым и беспомощным перед лицом перемен. С другой — острая уязвимость, ощущение покинутости и истощение, когда ты взваливаешь на свои плечи непосильную тяжесть за весь мир, не находя сил позаботиться даже о себе. Пересмотри свои опоры: не строй иллюзорных глухих стен, учись опираться на внутреннюю гибкость и тех, кто действительно надёжен.",
"meanings": [],
"pairs": []
},
{
"n": 3,
"id": "fern",
"name": "3 Ферн (Fern / Ольха)",
"symbol": "ᚃ",
"lore": "Ольха олицетворяет наставничество, защиту и наведение мостов, указывая на доступность поддержки, готовность к усвоению жизненного урока и умение извлекать ценное знание даже из несовершенного опыта.",
"group": "I Аикма Бэйт",
"meaning": "Дерево знака — ольха, чья древесина не гниет в воде, служа вечной и нерушимой опорой для мостов и свай через топкие заводи и бурные реки. В раскладе Fearn олицетворяет поиск жизненного решения, защиту, наставничество и укрепление связей. Знак возвещает: необходимая сила и ресурсы уже в твоем распоряжении. Рядом с тобой находится надежный проводник или защитник — не бойся открыто признать свою потребность в совете и вежливо, с искренним уважением попросить о помощи, не требуя ее как должное.\n\nПоявление Fearn говорит о том, что ты внутренне созрел для прохождения важного урока, и жизнь предоставляет тебе такую возможность. Это предложение практического подхода к преодолению преград, но этот шанс или учитель не обязаны быть безупречными. Учись быть исследователем, отделяющим крупицы подлинного знания от человеческих слабостей и заблуждений: ценному мастерству или верному навыку можно научиться даже у того, чьи личные взгляды тебе чужды, а чужие ошибки способны стать лучшим предостережением. Будь открыт наставничеству и делай шаг навстречу другим людям.",
"shadow": "В тени и перевернутом положении Fearn обнажает искажения в сфере авторитета, доверия и связей, проявляясь через две крайности — слепую веру или ожесточенное отчуждение.\n\nПервая крайность — «мост в никуда» и злоупотребление влиянием. Это попадание под власть ложных гуру, шарлатанов или поиск универсальной панацеи, позволяющей сбежать от честного решения проблем. Кто-то может превышать полномочия и навязывать свою волю, либо ты сам наделяешь непогрешимостью человека, систему или догму, отвлекаясь от подлинной работы. В позиции ситуации это часто указывает на упрямого, недалекого человека или группу, с которыми ты вынужден иметь дело.\n\nВторая крайность — сожженные мосты и сопротивление честному совету. Попытка отвергнуть человека или учение целиком из-за одного частного разногласия ведет к горделивой самоизоляции и догматизму. Ни один человек не является изолированным островом. Тень Fearn призывает преодолеть высокомерие, не рубить связи сплеча, а находить в себе готовность сделать шаг навстречу и трезво отличать искреннюю заботу от манипуляции.",
"meanings": [],
"pairs": []
},
{
"n": 4,
"id": "sail",
"name": "4 Салье (Sail / Ива)",
"symbol": "ᚄ",
"lore": "Sail (Ива) указывает на время довериться естественному потоку, проявить гибкость перед лицом перемен и опереться на интуицию и голос бессознательного, сохраняя стойкость без жесткости.",
"group": "I Аикма Бэйт",
"meaning": "Ива — дерево воды, тонких энергий и текучей гибкости. Она пускает корни у самой воды, но её ветви свободно склоняются под ветром и бурным потоком, не ломаясь под их натиском. Это архетип мягкой силы, приспособляемости и связи с невидимыми пластами реальности, где устойчивость достигается не сопротивлением, а умением чутко следовать руслу событий.\n\nВ раскладе появление Sail говорит о том, что пришла пора отпустить ригидные установки, прекратить борьбу с обстоятельствами и позволить бессознательному проявить себя через сны, знаки и предчувствия. Твоя восприимчивость и эмпатия сейчас на пике: ты способен тонко считывать скрытые настроения окружающих, интуитивно определять качество пространства и находить верный жизненный ритм.\n\nЭта фью наделяет безупречным чувством момента и внутренним тактом. Подобно художнику или музыканту, черпающему вдохновение из глубин души, ты способен инстинктивно сказать нужное слово и сделать точное действие в идеальное время. Доверяй своим озарениям и внезапным догадкам: накопленный жизненный опыт стал глубоким источником мудрости, позволяющим мягко проходить любые пороги и повороты судьбы.",
"shadow": "В перевёрнутом или теневом положении Sail сигнализирует о застое, эмоциональном избегании и страхе встретиться с собственными подавленными чувствами. Искажение гибкости проявляется в двух крайностях:\n\nПри избытке наступает эмпатическое выгорание и ментальная перегрузка. Ты растворяешься в чужих проблемах и эмоциях, становишься открыт для чужого негатива, пытаешься оградить других от их собственных уроков или цепляешься за иллюзорные, отравляющие идеалы.\n\nПри нехватке возникает ломкая черствость и внутренняя глухота. Боясь показаться уязвимым, ты прячешься за маской бравады, цинизма или глухой изоляции, упрямо бунтуешь против естественного хода вещей и отмахиваешься от подсказок интуиции как от пустых суеверий. Тень Sail требует честно спросить себя: с какими эмоциями ты отказываешься соприкоснуться и в чём упорствуешь во вред себе?",
"meanings": [],
"pairs": []
},
{
"n": 5,
"id": "nion",
"name": "5 Нион (Nion / Ясень)",
"symbol": "ᚅ",
"lore": "Нион — знак ясеня, связывающего мир воедино: разрозненные нити вашей жизни сплетаются в ясный узор, а точная коммуникация и открытость к окружению позволяют слышать суть сквозь любой шум.",
"group": "I Аикма Бэйт",
"meaning": "Дерево этого знака — ясень, символ мировой оси, объединяющей разные миры, и живая сеть связей. В раскладе появление Нион говорит о том, что разрозненные события и обстоятельства вашей жизни начинают складываться в осмысленную картину. Стоит оторваться от мелких частностей, чтобы увидеть общий замысел: то, что казалось случайным, обретает цель и взаимное соответствие.\n\nНион напрямую связан со скоростью мысли, обменом вестями и социальным интеллектом. Если вы стремитесь наладить контакт, донести мысль до аудитории или встроиться в сообщество, знак подтверждает: ваши слова находят отклик, канал связи открыт в обе стороны. Здесь важны гибкость, чувство такта, юмор и готовность слушать столь же внимательно, сколь и говорить.\n\nВместе с тем ясень напоминает об искусстве отсекать лишнее. Чтобы уловить подлинное знание и передать важное сообщение, необходимо сознательно ограничивать информационный шум. Сосредоточьтесь на ясном сигнале, отсеивая суету, досужие разговоры и чужие мнения, не имеющие для вас ценности.",
"shadow": "В перевёрнутом или теневом положении Нион указывает на крайности в обращении со связями и информацией. С одной стороны, это грозит информационной перегрузкой, зависимостью от пустых разговоров, соцсетей и чужого шума, в котором тонет суть, а также распылением собственных сил через чрезмерную откровенность и многословие. С другой стороны, тень несёт глухую изоляцию, закостенелость и отказ от диалога: когда вы закрываетесь от полезного контакта, неоправданно ожидаете, что окружающие прочтут ваши мысли, либо общаетесь пренебрежительно и неуклюже, разрушая связи.",
"meanings": [],
"pairs": []
},
{
"n": 6,
"id": "uath",
"name": "6 Уат (Uath / Боярышник)",
"symbol": "ᚆ",
"lore": "Боярышник указывает на полосу препятствий, требующую выдержки, терпеливой подготовки и упорного труда без попыток срезать путь: здесь нет мгновенных решений, а подлинный успех достигается через самодисциплину и мастерство.",
"group": "II Аикма Уат",
"meaning": "Дерево знака — боярышник, колючий страж границ и символ концентрированного внутреннего огня, сквозь чью плотную заросль невозможно прорваться напролом, не поранившись.\n\nВ раскладе Huath возвещает, что перед вами возникло препятствие или полоса испытаний, которые невозможно обойти стороной или устранить одним махом. Это указание остановиться и подготовиться: сейчас не время для опрометчивого штурма или дерзких авантюр, но пора глубокого погружения, спокойного наблюдения и выстраивания внутренней прочности.\n\nЭта фью выпадает на дела, требующие игры вдолгую, будь то овладение сложным мастерством, запуск важного проекта, преодоление саморазрушительных привычек или победа над навязчивой идеей. Коротких путей и лёгких обходных троп здесь нет. Как виртуозное владение инструментом требует тысяч часов упорных упражнений, так и ваша задача требует предельной дисциплины, терпения и готовности к самоотдаче.\n\nОцените стоящий перед вами вызов со всех сторон, осознайте его границы и разожгите огонь ясного анализа. Воспринимайте преграду не как злой рок, а как экзамен на зрелость и силу характера. Обуздайте нетерпение, не тратьте энергию на поиски виноватых и упорно делайте своё дело день за днём — подлинная победа куётся стойкостью перед лицом рутины и верностью выбранной цели.",
"shadow": "В тени и перевёрнутом положении Huath обнажает две разрушительные крайности реакции на трудности: агрессивное форсирование событий либо парализующий уход от ответственности.\n\nС одной стороны, это попытка взять преграду нахрапом и требование мгновенного триумфа. Вы пытаетесь срезать углы, надеетесь на лёгкие чудеса, бездумно заливаете проблему деньгами или ломаете естественный ход событий нетерпеливым напором. Неспособность выдержать положенный срок приводит лишь к истощению, тупикам и озлобленному поиску виноватых.\n\nС другой стороны, избыточная осторожность перерастает в паралич воли и страх сделать необходимый шаг. Вы избегаете тяжелой работы, предаваясь сладостным фантазиям об идеальном будущем или имитируя бурную деятельность вокруг второстепенных мелочей, пока главное дело простаивает. Тень Huath сурово напоминает: урок не исчезнет сам по себе, и уклонение от труда лишь заставит судьбу снова и снова возвращать вас к этому невыученному барьеру.",
"meanings": [],
"pairs": []
},
{
"n": 7,
"id": "duir",
"name": "7 Дуир (Duir / Дуб)",
"symbol": "ᚇ",
"lore": "Дуб (Duir) — знак несокрушимой внутренней силы, мудрой защиты и обретения даров, призывающий твердо удерживать свои позиции и с благодарностью распоряжаться полученными ресурсами.",
"group": "II Аикма Уат",
"meaning": "Дуб — могучий владыка леса и царь огама, воплощающий несокрушимую стойкость, монументальную силу и глубинную мудрость. Его архетип подобен великодушному отцу или мудрому деду: он защищает, направляет, охотно делится знаниями и служит надежной опорой. Этот знак открывает двери новым возможностям и напоминает о шансе встать на плечи гигантов — опереться на многовековой опыт, традиции и родовую память.\n\nВ раскладе появление Дуира указывает на то, что у вас гораздо больше сил и выдержки, чем вы предполагаете, и текущие обстоятельства требуют проявить эту внутреннюю мощь. Стойте твердо на ногах и не сдавайте позиций: ваш фундамент достаточно крепок, чтобы преодолеть любое испытание.\n\nКроме того, выпадение этого знака возвещает получение дара, счастливого шанса или полосы везения. Поддержка может прийти в самых неожиданных формах — как внезапная мысль, своевременная подсказка, удачное стечение обстоятельств или материальная помощь. Ключ к энергии Дуира — умение искренне ценить то, что вам дано, использовать эти возможности для личного роста и передавать полученное добро дальше в мир.",
"shadow": "В перевернутом или теневом положении Дуир предупреждает о проявлениях слепого упрямства, гордыни и негибкости: задумайтесь, чей авторитет вы так яростно отстаиваете и стоит ли эта битва затраченных ресурсов.\n\nДругая сторона тени касается искажения темы даров и щедрости. С одной стороны, это риск потакания чужому паразитизму и истощения собственных сил, когда помощь уходит тем, кто растрачивает ее впустую; здесь необходимо выставить жесткие границы и вовремя остановиться. С другой стороны, тень может проявляться как упование на слепое везение, неблагодарность и злоупотребление своими привилегиями вместо реальной внутренней работы.",
"meanings": [],
"pairs": []
},
{
"n": 8,
"id": "tinne",
"name": "8 Тинне (Tinne / Остролист)",
"symbol": "ᚈ",
"lore": "Остролист (Tinne) — знак мужества, решительных действий и защиты своих рубежей, возвещающий неизбежную конфронтацию или проверку на прочность, где победа достигается не слепым напором, а выверенной стратегией и гибкостью.",
"group": "II Аикма Уат",
"meaning": "Дерево знака — остролист (падуб), вечнозеленый воин леса с колючими листьями и алыми ягодами, олицетворяющий стойкость посреди зимы, непоколебимую защиту, храбрость и способность дать жесткий отпор любому посягательству.\n\nВ раскладе появление Тинне возвещает наступление серьезного испытания или момента, когда открытого противостояния больше невозможно избежать. Для тех, кто опасается конфликтов, эта фью несет тревогу неизбежного вызова, однако она говорит прямо: пришло время занять глухую оборону или разработать полноценную военную стратегию. Это может быть необходимость защитить слабого, решительно оборвать связь с токсичным окружением, довести начатое до конца вопреки мощному сопротивлению среды или пресечь неподобающее поведение. Вам брошен вызов, и бороться придется обязательно.\n\nГлавный урок прямого Тинне — это воинское искусство и безопасность без самонадеянности: встречайте проверку на прочность хладнокровием и гибкостью, а не слепой грубой силой. Поднимитесь над мелочными склоками и не бросайтесь в бой раньше времени — изучите противника и наносите удар точно в цель, когда наступит нужный момент. Выставите жесткие границы вокруг себя, своих близких и своего личного времени: никто не имеет права вторгаться на вашу территорию и красть ваши ресурсы.",
"shadow": "В перевернутом положении или в тени Тинне указывает на опасный дисбаланс, перенапряжение и крайности оборонительного поведения, когда внешнее давление или внутренняя тревога выбивают вас из равновесия.\n\nС одной стороны, тень проявляется как избыточная, агрессивная оборона. Фоновая тревога и накопленная усталость заставляют вас видеть угрозу в каждом шаге, ожесточаться и первыми развязывать войну. Желание сокрушить источник давления естественно, но слепое вымещение ярости разрушает самого защищающегося. Не действуйте из боли: прежде чем вступать в бой, устраните эмоциональное истощение и не прячьтесь в глухой панцирь.\n\nС другой стороны, тень может означать критическую нехватку защиты. Заниженная самооценка и наивный оптимизм побуждают закрывать глаза на реальную опасность, терпеть вторжения и раз за разом давать шансы тем, кто причиняет вред. Тинне предупреждает: перестаньте позволять вытирать о себя ноги. Вам необходима жесткая самодисциплина, выстраивание непреодолимых личных рубежей и готовность постоять за себя, чтобы сберечь силы для главного.",
"meanings": [],
"pairs": []
},
{
"n": 9,
"id": "coll",
"name": "9 Колл (Coll / Орешник)",
"symbol": "ᚉ",
"lore": "Орешник (Coll) — знак глубинной мудрости, поэтического вдохновения и целостного видения, возвещающий момент, когда разрозненные факты складываются в единую ясную картину, а зрелый опыт приносит безошибочные решения.",
"group": "II Аикма Уат",
"meaning": "Дерево знака — орешник (лещина), священный хранитель сокровенного знания, поэтического дара и зрелых плодов жизни ума. Его образ — это искусный узор, где сотни разрозненных нитей соединяются в единое полотно. В отличие от простой эрудиции и дипломов, которые нередко оказываются лишь ментальным хламом и набором разрозненных фактов, подлинная мудрость непреходяща: она подобна музыке, рождающейся из слаженного созвучия тысячи мелочей, или глубокому знанию истории, позволяющему видеть масштабные закономерности и предвидеть будущее.\n\nВ раскладе появление Колл возвещает момент, когда последний фрагмент головоломки встаёт на своё место, открывая целостный взгляд на ситуацию с высоты птичьего полёта. Твой прежний интеллектуальный труд окупается мастерством, точностью слова и глубиной понимания. Тебе доступно чистое вдохновение: пришло время задавать искренние вопросы, исследовать мир и позволить любопытству увести тебя в неожиданные, многообещающие сферы.\n\nЭтот знак указывает на благоприятные возможности, открывающиеся благодаря гибкой и интуитивной стратегии. Опираясь на глубоко укоренённый опыт и уроки былых ошибок, ты обретаешь способность предвидеть множество вариантов развития событий и совершать точные, зрелые выборы, делающие жизнь богаче и осмысленнее.",
"shadow": "В перевёрнутом положении или в тени Колл указывает на искажения в сфере разума, проявляясь в двух крайностях:\n\nС одной стороны — это теневой избыток и интеллектуальное высокомерие. Заблуждение, будто ты уже знаешь все ответы, блокирует творческий поток и закрывает путь к новому. Остерегайся ментальной спеси: не пытайся навязывать свои озарения тем, кто не способен их оценить и реагирует враждебностью, и сведи к минимуму общение с напыщенными всезнайками, опьянёнными звуком собственного голоса.\n\nС другой стороны — теневой дефицит и нехватка понимания. Мозаика ещё не сложилась, задача остаётся нерешённой, и в картине недостаёт ключевых элементов для перехода на новый уровень. Из-за неуверенности возникает соблазн казаться более осведомлённым, чем есть на самом деле, либо парализующий страх провала заставляет замереть и отказаться от любого риска. Кроме того, Колл предостерегает от неразумных людей, пытающихся втянуть тебя в свои проблемы и переложить на тебя ответственность за их решение: отступи в сторону и дай себе время разобраться.",
"meanings": [],
"pairs": []
},
{
"n": 10,
"id": "ceirt",
"name": "10 Керт (Ceirt / Яблоня)",
"symbol": "ᚊ",
"lore": "Яблоня (Quert) — знак заслуженного отдыха, сладостной передышки и глубокой гармонии, предвещающий прекрасный и судьбоносный выбор, а также необходимость замедлиться, чтобы ощутить полноту жизни и восстановить силы.",
"group": "II Аикма Уат",
"meaning": "Дерево знака — яблоня (id ceirt), древний священный образ щедрого изобилия, природной гармонии, умиротворения и сладостных плодов завершенного труда. Это символ душевной мягкости, житейского благородства и тонких проявлений человеческого тепла — тех маленьких жестов внимания, взаимной вежливости и искренней благодарности, на которых держится подлинная красота мира.\n\nВ раскладе появление Керт возвещает время заслуженной передышки, праздника или периода исцеления. Бесконечная спешка и навязчивое стремление постоянно бежать вперед противоречат законам природы: после периода напряженных усилий или завершения важного проекта замедление является единственно верным шагом. Керт призывает вас остановиться, вдохнуть аромат роз и вернуться в настоящее, перестав изводить себя тревожными планами на будущее или мыслями о прошлом. Обратите внимание на состояние физического здоровья, найдите поводы для искренней улыбки и научитесь беречь свой внутренний мир, мягко, но твердо говоря «нет» тем, кто требует от вас слишком многого.\n\nКроме того, прямое положение Керт указывает на прекрасный выбор, открывающийся перед вами. Он может казаться скромным или очевидным, однако его значение и влияние на вашу жизнь неизмеримо глубже, чем представляется на первый взгляд. Доверьтесь чувству гармонии: этот выбор несет исцеление, радость и подлинное душевное насыщение.",
"shadow": "В перевернутом положении или в тени Керт указывает на искажение баланса между действием и покоем, а также на страх перед принятием решений:\n\nС одной стороны — это нерешительность, соблазны и уклонение от выбора. Вы топчетесь на месте и избегаете важного шага только потому, что он требует неизбежной жертвы или отказа от сиюминутного комфорта. Попытка увильнуть от ответственности или поддаться легкомысленному искушению лишь затягивает назревший кризис.\n\nС другой стороны — теневой избыток и жизнь на износ («сделай паузу, иначе упадешь»). Страх потерять доход, работу, статус или контроль заставляет держаться за изматывающую рутину. Вы балансируете на грани тяжелого выгорания, и если не дадите себе осознанный отдых, тело и психика принудят к остановке насильно — через внезапную болезнь, нервный срыв или депрессию.\n\nНаконец, теневой дефицит проявляется как праздность, хроническая «скука» и эскапизм. Жалобы на то, что в жизни нечем заняться, здесь маскируют обыкновенную лень и нежелание взрослеть. Попытка переложить заботу о своем развлечении и эмоциональном состоянии на окружающих ведет к бегству от реальности и формированию зависимостей. Знак требует прекратить ждать спасения со стороны: займитесь реальным делом, освойте практический навык или помогите тем, кто в этом нуждается.",
"meanings": [],
"pairs": []
},
{
"n": 11,
"id": "muin",
"name": "11 Муин (Muin / Лоза)",
"symbol": "ᚋ",
"lore": "Муин — знак сбора урожая, завершения важного жизненного цикла и честного взгляда внутрь себя, призывающий отпраздновать плоды своих трудов и распознать отражение сокровенной правды в зеркале внешнего мира.",
"group": "III Аикма Муин",
"meaning": "Дерево знака — виноградная лоза, древний образ созревания, изобилия, праздничного опьянения и кульминации природных сил. Это пора сбора урожая — священное время, когда долгий труд вознаграждается сладкими плодами, а земля щедро одаривает перед погружением в целительный зимний покой. Завершение здесь подобно жатве: угасание прежнего этапа не несет в себе угрозы, являясь закономерным шагом обновления и поводом для глубокой благодарности.\n\nВ раскладе Муин возвещает кульминацию и переход: масштабное дело, отношения, важная веха или устоявшийся образ жизни подошли к логическому финалу. Это момент инициации и заслуженного признания. Оглянись назад и отметь свои победы — преодоленные препятствия, завершенные проекты или внутренние сдвиги. Любой пройденный цикл, даже если он был трудным и не во всем идеальным, заслуживает уважения и празднования. Позволь себе вкусить плоды своих трудов, насладиться моментом и немного отпустить поводья: здоровая доля гедонизма и отдыха сейчас не просто естественна, но и необходима для восстановления сил перед новым путешествием.\n\nОдновременно с этим лоза несет в себе вино откровения: Муин призывает обратить взор внутрь себя. Окружающий мир сейчас работает как зеркало — внешние ситуации, люди и обстоятельства со всей ясностью показывают тебе твое собственное отражение. Спроси себя: что этот мир говорит о твоих подлинных желаниях, зрелости и душевном состоянии? Прими этот ответ с открытым сердцем.",
"shadow": "В тени или перевёрнутом положении Муин указывает на вытеснение неприятной правды, когда глубинное «я» сопротивляется тому, что уже ясно осознает, порождая две разрушительные крайности обращения с удовольствиями и ресурсами:\n\nС одной стороны — разгул, потеря меры и бегство от реальности. Чрезмерное потакание страстям, зависимости, компульсивное потребление (в еде, покупках или алкоголе) становятся анестезией, призванной заглушить эмоциональную пустоту и нежелание встретиться с фактами лицом к лицу. Сюда же относится слепое следование стадному чувству и давлению среды, навязывающей чужие шаблоны «насыщенной жизни»: не втягивайся в то, что идет вразрез с твоим нутром, лишь ради чужого одобрения.\n\nС другой стороны — теневой дефицит: пуританская зажатость, мелочная скупость и фанатичное самоограничение. Превращая дисциплину в тиранию и лишая себя малейшей радости, ты лишь делаешь «запретный плод» опасным наваждением, что неминуемо ведет к срыву и внутреннему лицемерию. Не прячься от правды в тумане излишеств или в тисках суровых запретов — верни себе меру, честность и способность искренне радоваться жизни.",
"meanings": [],
"pairs": []
},
{
"n": 12,
"id": "gort",
"name": "12 Горт (Gort / Плющ)",
"symbol": "ᚌ",
"lore": "Горт — знак несокрушимой стойкости, несгибаемой решимости и роста сквозь любые препятствия, призывающий отсечь второстепенное и неотступно следовать за тем, что обладает для тебя подлинной ценностью.",
"group": "III Аикма Муин",
"meaning": "Дерево знака — плющ, древний символ феноменальной выносливости, цепкости и скрытой силы. Плющ укореняется в тончайших трещинах, огибает монолитные преграды и оплетает крутые стены: его треплет штормовой ветер, заливают холодные ливни и палит зной, но он переживает любые невзгоды благодаря своей несокрушимой природе. Его суть родственна огненной энергии воды — стихии, которая не сокрушает взрывным напором, а терпеливо и неумолимо за века вытачивает грандиозные каньоны в твердой скале. Капля за каплей эта сила проникает в мельчайшие поры, размягчает огрубевшее и неотвратимо прокладывает русло там, где бессильна грубая атака.\n\nВ раскладе Горт возвещает момент проявления железного характера и почти сверхчеловеческого упорства. Несмотря на тяготы или враждебность обстоятельств, нечто жизненно важное для тебя продолжает упорно расти прямо сейчас. Это знак безоговорочной преданности своему пути: перед тобой стоит цель, ради которой ты готов пойти на жесткую самодисциплину, установить суровые границы и отсечь все лишнее. Горт призывает трезво оценить свои устремления и посвятить себя только тому, что по-настоящему дорого. Не бойся препятствий и не торопи события — подобно воде и плющу, силой терпения и несгибаемой воли ты преодолеешь любые испытания и выйдешь победителем.",
"shadow": "В перевёрнутом положении или в тени Горт раскрывает разрушительные искажения упорства, когда стойкость превращается в удушающую хватку, паразитизм или бессмысленное самоистязание:\n\nС одной стороны — теневой избыток («жертва собственного успеха») и опутывание. Плющ способен разрастись настолько агрессивно, что душит дерево, давшее ему приют, или обрушивает опорную стену. Это ненасытная экспансия, накопление излишков и попытка заполнить внутреннюю пустоту бесконечной суетой, чужими проектами или показным престижем. Возникает удушающая лояльность или токсичная привязанность: ты упорствуешь в обязательствах не из любви, а из страха признать поражение или потерять лицо. Спроси себя, не разрушает ли твой успех твою же собственную жизнь и не пора ли освободиться от непосильного бремени.\n\nС другой стороны — теневой дефицит («на волоске от падения»). Это судорожная попытка удержаться на острие ножа, когда силы и ресурсы уже исчерпаны до дна. Ты испытываешь глухое истощение, боевую усталость, раздробленность и злость на противостоящие силы. Оцени ситуацию предельно честно: цель, за которую ты цепляешься из последних сил, может больше не стоить приносимых жертв. Иногда величайшим проявлением зрелости и мужества становится способность вовремя разжать хватку и отпустить отжившее.",
"meanings": [],
"pairs": []
},
{
"n": 13,
"id": "ngetal",
"name": "13 Гетал (nGetal / Тростник)",
"symbol": "ᚍ",
"lore": "нГетал — знак исцеления, катарсиса и восстановления равновесия, призывающий избавиться от физического и душевного хлама, согласовать поступки с глубинными ценностями и освободить место для нового.",
"group": "III Аикма Муин",
"meaning": "Образ знака — тростник и очищающий ракитник, символизирующие выметание сора, катарсис и гибкость у воды, восстанавливающую баланс после бури. Подобно тому как после затяжного жара спадает лихорадка, нГетал знаменует окончание изнурительной борьбы: кризис миновал, и к тебе возвращается долгожданное равновесие.\n\nВ раскладе этот знак указывает на необходимость генеральной уборки в жизни — как на материальном уровне, так и в сфере мыслей и чувств. Пришло время избавиться от эмоционального балласта, вредных привычек и деструктивных ситуаций, мешающих росту. Если расклад касается обиды или давнего конфликта, нГетал требует поставить точку: будь то искреннее прощение, исходящее из подлинного внутреннего покоя, либо спокойное и бесповоротное удаление токсичного человека из своего круга.\n\nПоявление знака призывает согласовать повседневные шаги со своими истинными ориентирами, а не с поверхностным желанием угодить окружающим. Отпусти тревогу о том, что находится вне твоего контроля, дистанцируйся от разрушительного гнева и позволь себе восстановиться — душевный покой и бережное отношение к телу откроют дорогу обновлению.",
"shadow": "В тени или перевёрнутом положении нГетал вскрывает глубокий разлад между разумом и сердцем, когда человек заглушает тихий голос интуиции и впадает в разрушительные крайности:\n\nС одной стороны — теневой избыток: компульсивное стремление к стерильности, безжалостный минимализм и тактика «выжженной земли». Попытка решить проблему чересчур радикальными, карательными методами или вычистить всё до холодного блеска лишает жизнь человеческого тепла и наносит долгосрочный вред. Остерегайся крайностей: исцеление требует бережного срединного пути и отдыха, а не жестокой ломки себя.\n\nС другой стороны — теневой дефицит: неспособность расстаться с прошлым, цепляние за старые обиды, ненужные вещи и эмоциональный хлам. Страх нужды провоцирует накопительство и компульсивное заполнение пустоты, что отравляет здоровье и крадет силы. Остановись, прислушайся к тому, что звучит внутри тише всего, и начни постепенно очищать пространство, опираясь на благодарность за то, чем уже обладаешь.",
"meanings": [],
"pairs": []
},
{
"n": 14,
"id": "straif",
"name": "14 Страйф (Straif / Тёрн)",
"symbol": "ᚎ",
"lore": "Страйф — знак закалки через невзгоды, внутренней дисциплины и принятия неизбежного, превращающий болезненный вызов в инструмент духовной крепости, а скрытый гнев — в рычаг победы.",
"group": "III Аикма Муин",
"meaning": "Дерево знака — колючий тёрн (терновник), неприступный страж пустошей, чьи острые шипы ранят, но чьи плоды обретают благородную сладость лишь после первых жестоких морозов. Его образ — лезвие меча, рождающееся в пламени кузницы: чтобы клинок обрёл несокрушимую крепость и остроту, металл должен пройти через жар огня, удары молота и ледяную воду, высекая снопы искр. Страйф воплощает закон закалки духа через испытания, стойкость и добровольную готовность к жертве ради взросления и внутренней силы.\n\nВ раскладе Страйф возвещает столкновение с малоприятным опытом, резкое нарушение привычной рутины или битву, от которой невозможно уклониться. Этот знак прямо говорит: нечто в ситуации зафиксировано, предрешено и не подлежит изменению. Бессмысленно тратить себя на войну с неизбежностью — прими её как данность, и тогда колоссальная энергия, уходившая на отрицание и бессильный протест, высвободится для того, что действительно в твоих силах изменить. Трудности встали на твоём пути не для того, чтобы сломать тебя, а чтобы послужить наковальней, на которой выковывается твоя судьба.\n\nПеред лицом внешнего давления или конфликта у тебя есть выбор, как обойтись со своими чувствами. Ты можешь сорваться в слепую ярость, уподобившись тому, что презираешь, либо загнать злость внутрь, отравив себя депрессией. Прямой путь Страйф иной: признай и разбери свои тёмные эмоции, сделав их точкой опоры и трамплином, чтобы вырасти над источником конфликта и победить его на собственных условиях. Если ты уже научился не быть марионеткой в руках гнева и страха, появление Страйф свидетельствует о том, что самого разрушительного витка распри тебе уже удалось избежать.",
"shadow": "В перевёрнутом положении или в тени Страйф указывает на мучительное сопротивление неизбежному и обнажает две крайности в обращении с конфликтами и личными границами:\n\nС одной стороны — теневой избыток и малодушие угодничества. Из страха перед открытым столкновением или ради желания казаться «хорошим» ты проявляешь чрезмерную мягкость, продолжая улыбаться там, где нужно дать жёсткий отпор. Такая фальшивая доброта — капитуляция перед худшим в себе. Не говори «может быть», когда внутри звучит ясное «нет»: очерти границы дозволенного и твёрдо держи их. Спокойное самоуважение и способность сказать «нет» вызывают подлинный отклик и уважение окружающих куда вернее, чем бесхребетная покладистость.\n\nС другой стороны — теневой дефицит и синдром осаждённой крепости. Это вечная подготовка к войне и фоновая враждебность, когда тебе кажется, будто весь мир, люди и сама судьба ополчились против тебя. В каждом несовпадении взглядов чудится заговор, а в случайных обстоятельствах — злой умысел. Но главный общий знаменатель во всех этих битвах — ты сам. Большинство людей поглощены собственными заботами, а не борьбой с тобой. Перестань заранее делить мир на врагов и союзников и найди корень этой тревоги внутри себя: упорное сопротивление неизбежному лишь затягивает урок, делая расплату за него неизмеримо выше.",
"meanings": [],
"pairs": []
},
{
"n": 15,
"id": "ruis",
"name": "15 Руис (Ruis / Бузина)",
"symbol": "ᚏ",
"lore": "Руис (бузина) знаменует завершение жизненного цикла и глубинную трансформацию через честное принятие своих ошибок: проживание скорби, стыда или сожаления позволяет обуздать эго, извлечь опыт и освободить пространство для нового.",
"group": "III Аикма Муин",
"meaning": "Бузина — дерево перехода, завершения и очищения. В раскладе Руис возвещает, что некий этап вашей жизни подошёл к концу. Пришло время подвести черту и с уважением отпустить уходящее. Не избегайте печали или скорби: они закономерны при расставании с прошлым и необходимы для того, чтобы расчистить место для будущего роста.\n\nПоявление этого знака требует честной встречи с собственными ошибками, неловкостью и стыдом. Здоровый стыд служит не поводом для самоедства, а важным ориентиром: он сдерживает раздутое эго и возвращает чуткость к окружающим. Не прячьтесь от содеянного и не ищите оправданий — используйте пережитое раскаяние, чтобы перековать себя и стать тем, кто больше никогда не допустит подобного промаха. Вместе с тем сохраняйте объективность: признавая свои ошибки, отдайте должное и тем падениям, которых вы сумели избежать благодаря благоразумию.",
"shadow": "Отказ отпускать прошлое и попытка удержать завершившийся цикл, встречая неизбежные перемены с горечью и ожесточением вместо достоинства. Теневая сторона проявляется в двух крайностях: либо в удушающем самобичевании, перерастающем в токсичную ненависть к себе и лицемерные попытки манипулировать чужой виной, либо в бесстыдном цинизме, при котором человек полностью игнорирует последствия своих поступков для других.",
"meanings": [],
"pairs": []
},
{
"n": 16,
"id": "ailm",
"name": "16 Айлм (Ailm / Сосна)",
"symbol": "ᚐ",
"lore": "Сосна (Ailm) знаменует трансцендентность, предельную ясность видения и мудрость отстранённости: поднимитесь над сиюминутной суетой, чтобы взглянуть на ситуацию с высоты прожитого опыта, обрести смирение и искреннее сострадание ко всему сущему.",
"group": "IV Аикма Айлм",
"meaning": "Высокая вечнозелёная сосна, устремлённая вершиной ввысь над облаками, — маяк дальнего видения и символ чистого просветления. Её образ напоминает равноконечный крест обновления и преодоления страданий, олицетворяя любовь ко всем формам бытия и выход за пределы кармического круговорота обид и борьбы.\n\nВ раскладе появление Ailm возвещает, что необходимая вам ясность уже доступна — нужно лишь сделать шаг назад и взглянуть на происходящее в широкой перспективе. Знак призывает подняться над сиюминутными трудностями, суетой и личными амбициями, задав себе главный вопрос: «Какому уроку меня учит эта ситуация?» Это взгляд мудреца, вобравшего опыт множества жизней: подлинный жизненный опыт дарует идеальный баланс между глубоким состраданием и спасительной отстранённостью. Истинная мудрость не бросается вперёд с легкомысленным оптимизмом и не сжимается от слепого пессимизма, а терпеливо читает ветра, прежде чем поднять паруса.\n\nКроме того, сосна несёт в себе божественный аспект юмора и смирения. Умение искренне и по-доброму посмеяться над превратностями судьбы и собственной важностью растворяет тяжесть переживаний. Ailm учит зрелому примирению с миром: когда вы осознаёте масштаб целого, обиды уступают место прощению, а эмоциональная вовлечённость — спокойной уверенности и духовному покою.",
"shadow": "В перевёрнутом или теневом положении Ailm обнажает две разрушительные крайности искажённого восприятия: ложную трансцендентность либо глухое бессилие и фрустрацию.\n\nС одной стороны — это отрыв от реальности, принятие желаемого за действительное и токсичный позитивизм. Попытка спрятаться от острых проблем за ширмой показного духовного благополучия и наивных иллюзий подобна карточному домику без фундамента: отказ трезво смотреть на вещи порождает лишь скрытое отчаяние и пассивную агрессию.\n\nС другой стороны — это мучительное раздражение из-за невозможности переделать других людей или упрямые обстоятельства. Попытки навязать миру перемены силой лишь растрачивают вашу энергию. Тень Ailm призывает опереться на мудрость принятия: признайте границы своего контроля, прекратите биться в закрытые двери и начните трансформацию с себя, чтобы с достоинством и состраданием пройти сквозь трудности, не давая им разрушить ваш дух.",
"meanings": [],
"pairs": []
},
{
"n": 17,
"id": "onn",
"name": "17 Онн (Onn / Дрок)",
"symbol": "ᚑ",
"lore": "Дрок (Onn) — знак расцвета надежды, кипучей жизненной силы и включённости в сообщество, возвещающий пору, когда твой энтузиазм притягивает людей и открывает нужные двери, но требующий мудрого баланса между открытостью миру и защитой личных границ.",
"group": "IV Аикма Айлм",
"meaning": "Дерево знака — дрок, выносливый колючий кустарник с ослепительно яркими золотыми цветами, распускающимися вопреки суровым ветрам. Он воплощает неиссякаемую жизненную силу, плодовитость, чувственность и пору светлой надежды. Образ дрока — это единая экосистема твоей повседневности и обретение собственной ниши в ней: среда, где ты живёшь, любишь, празднуешь, страдаешь и находишь опору. Человек — не изолированный остров, и Онн напоминает о ценности связей, объединяющих тебя с непосредственным окружением на расстоянии одного рукопожатия.\n\nВ раскладе появление Онн возвещает благоприятный период подъёма, энтузиазма и новых возможностей. Твоя внутренняя энергия сейчас особенно притягательна для окружающих, а искренняя увлечённость распахивает закрытые прежде двери. Это прекрасное время, чтобы опереться на поддержку своего круга, признать ценность взаимного обмена и уверенно занять своё уникальное место в общем деле. Оглядись вокруг и оцени, насколько органично ты вписан в свою среду: сотрудничество и совместные усилия сейчас принесут щедрые плоды.\n\nВместе с тем Онн учит искусству тонкого равновесия между социальным взаимодействием и внутренней суверенностью. Принимая помощь и добрые советы, не спеши обнажать душу нараспашку: далеко не каждое яркое впечатление, острое мнение или сокровенная тайна предназначены для ушей друзей, близких или возлюбленных. Умение вовремя промолчать и сохранить сокровенное при себе бережёт истинную глубину отношений превыше любых сокровищ. Говори правду без колебаний, когда это действительно необходимо, но прежде взвесь последствия своих слов — проверяй диалог книги своей жизни до того, как он сорвётся с губ.",
"shadow": "В перевёрнутом положении или в тени Онн предупреждает о нарушениях баланса в отношениях с миром, истощении от избыточной вовлечённости и попытках спрятать истинную тревогу за маской напускного благополучия.\n\nС одной стороны — это теневой избыток и перенасыщение связями. Чрезмерная популярность, ворох обязательств и неспособность отказать приводят к тому, что близкие, коллеги или проекты буквально выжимают тебя до капли. Нарастает усталость, срываются договорённости и теряется покой. Здесь же проявляется перевёрнутый аспект знака — натужный оптимизм: ты уверяешь себя и других, что «всё нормально», игнорируя назревшие трудности и внутреннее выгорание. Не бойся твёрдо и вежливо говорить «нет» и выставлять строгие границы, чтобы защитить своё право на отдых.\n\nС другой стороны — теневой дефицит и болезненная жажда признания. Страх оказаться отвергнутым или недостаточным заставляет зависеть от чужого мнения, остро реагировать на критику и подстраиваться под чужие ожидания в ущерб себе. Нередко это толкает возлагать нереалистичные надежды на романтического партнёра или брак, наивно видя в слиянии с другим человеком панацею от всех душевных ран. Тень дрока напоминает: ни один человек не способен решить твои неразрешённые внутренние конфликты за тебя. Обратись к истокам своих страхов и живи по собственным ориентирам, а не ради внешнего одобрения.",
"meanings": [],
"pairs": []
},
{
"n": 18,
"id": "ur",
"name": "18 Ур (Ur / Вереск)",
"symbol": "ᚒ",
"lore": "Вереск (Ур) предвещает неожиданную удачу и призывает действовать через мягкость, заботу и единение с близкими, напоминая, что искренняя доброжелательность открывает любые двери.",
"group": "IV Аикма Айлм",
"meaning": "Образ вереска олицетворяет питающую, материнскую сторону природы, красоту естественного потока жизни и целительное тепло, цветущее даже на суровых пустошах.\n\nВ раскладе появление Ур знаменует приближение неожиданной доброй удачи и поддержку, которая готова прийти из самого непривычного источника — оставайтесь открыты миру и помощи извне. Этот огам ставит во главу угла тему партнерства, глубокой привязанности и душевного тепла. Он напоминает о важности связей — как с кровной семьей, так и с близким кругом друзей и единомышленников. Путь тотального отшельника сейчас бесплоден: отгораживаться от людей бессмысленно, ведь вместе мы сильнее.\n\nКлючевой метод действия по Ур — мягкость, деликатность и терпеливое убеждение. Сила и напор вызовут лишь сопротивление, тогда как лаской и искренней добротой вы добьетесь гораздо большего. Отпустите мелкие обиды, капризный эгоизм и вспыльчивость. Любой ваш жест великодушия, доброе слово или вовремя сдержанный гнев запускают благотворные круги в общем потоке жизни, исцеляя пространство вокруг и взращивая лучшую версию вас самих.",
"shadow": "Теневая сторона огама говорит об упущенной удаче, застопорившемся исцелении и искажении искренней доброты, что обычно выражается в двух крайностях:\n\nСлащавое угодничество (excess): страх не понравиться окружающим или остаться в одиночестве превращается в токсичное желание быть удобным для всех. Это втягивает в нездоровые союзы, позволяет коллегам и близким паразитировать на вашей безотказности. Огам призывает найти смелость сказать твердое «нет», решительно выйти из манипулятивных отношений и отстоять свои границы, даже если придется временно остаться одному.\n\nГоречь и ожесточение (dearth): циничная установка «никому нельзя верить», закрытость и грубость в ответ на чужую резкость лишь уподобляют вас тому, что вы презираете, отсекая возможность получить второй шанс и проявить великодушие.\n\nКроме того, перевернутый Ур задает прямой вопрос: проявляете ли вы заботу к самому себе прямо сейчас или пускаете на самотек собственное восстановление и благополучие?",
"meanings": [],
"pairs": []
},
{
"n": 19,
"id": "eadha",
"name": "19 Эада (Eadha / Осина)",
"symbol": "ᚓ",
"lore": "Осина (Eadha) — знак стойкости, благоразумия и спасительных границ, указывающий, что вы закалены перенесёнными испытаниями, а честное понимание своих пределов и конструктивная осторожность помогают превратить пережитое в мудрость.",
"group": "IV Аикма Айлм",
"meaning": "Дерево знака — осина (особенно дрожащая осина), высокое и гибкое, чья листва чутко отзывается даже на самое легкое дуновение ветра. Кажущийся трепет обманчив: осины растут группами и рощами, соединяясь под землей единой мощной корневой системой, что напоминает о силе сообщества, надежности сплоченности и способности пережить любые житейские бури.\n\nВ раскладе появление Eadha возвещает: вы стали сильнее благодаря всему, что вам довелось преодолеть. Тяжелый отрезок пути остался позади, и теперь пришло время позволить пережитой боли и трудному опыту переплавиться в глубокую житейскую мудрость. Главный урок этого огама — осознание и уважение границ, как внешних, так и внутренних. Это лучшая стратегия на данном этапе: например, испытывая сильный гнев, важно честно признать и прожить свои эмоции, но установить жесткую границу на их внешнее разрушительное проявление. Трезвый самоанализ и самодисциплина превращают ярость в созидательную силу и мощный стимул для работы над собой.\n\nКроме того, осина раскрывает полезную и защитную функцию страха. Страх — естественный ограничитель, который при правильном балансе оберегает от фатальных ошибок, помогает в воспитании, успешных делах и сохранении жизненного равновесия. Появление Eadha советует не подавлять тревогу вслепую, а проанализировать ее корни: о какой реальной опасности она предупреждает? Подлинное бесстрашие заключается не в опрометчивом риске, а в умении сохранять благоразумие, опираться на надежное окружение и уверенно двигаться вперед, трезво оценивая пределы своих возможностей.",
"shadow": "Теневое и перевёрнутое проявление Eadha обнажает опасный дисбаланс между страхом и безрассудством, а также предупреждает о критическом переутомлении.\n\nС одной стороны, при теневом избытке (excess) тревога выходит из-под контроля и начинает диктовать свои условия. Человек уподобляется «дрожащему осиновому листу»: возникают панические состояния, обсессивный контроль, гипертрофированная подозрительность или крайности показной корректности, скрывающей внутреннее лицемерие. Пытаясь защититься от малейшей угрозы, вы рискуете возвести глухие стены и оттолкнуть даже тех людей и ситуации, которые способны сделать вас лучше.\n\nС другой стороны, при теневом дефиците (dearth) осторожность полностью отключается, уступая место слепому безрассудству под лозунгом «и так сойдет». Это неоправданный риск для здоровья и жизни — опасная езда, пренебрежение безопасностью, общение с ненадежными людьми, которым чуждо ваше благополучие, а также грубость и бестактность в отношениях, которые непременно вернутся бумерангом.\n\nНаконец, перевернутое положение Eadha прямо говорит о физическом и душевном истощении под гнетом накопленной тяжести. Вы слишком долго несли на себе непосильный груз и терпели из последних сил. Пришло время остановиться, опустить эту ношу на землю и четко сказать «стоп», восстанавливая свои границы и ресурсы.",
"meanings": [],
"pairs": []
},
{
"n": 20,
"id": "idho",
"name": "20 Идо (Idho / Тис)",
"symbol": "ᚔ",
"lore": "Тис (Idho) — знак глубокой трансформации, завершения важного жизненного цикла и благородного отпускания отжившего, призывающий принять неизбежные перемены с достоинством и покоем, видя в каждом финале естественный проход к новому началу.",
"group": "IV Аикма Айлм",
"meaning": "Дерево знака — тис, вечный хранитель круговорота жизни и смерти, способный жить тысячелетиями и пускать свежие побеги прямо из распадающейся древесины древнего ствола. Это священный образ вечности, памяти предков и непреложных законов бытия. Тис напоминает о целительной красоте естественного увядания: завершение и распад — не враги жизни, а ее естественная, очищающая часть, освобождающая место для будущего роста.\n\nВ раскладе появление Idho возвещает, что в вашей жизни разворачивается глубокая трансформация, знаменующая закономерный конец важного этапа, отношений, затянувшейся ситуации или целой эпохи. То, что сейчас может восприниматься как утрата или крушение, на самом деле является переходом в принципиально новое качество. Знак указывает, что вы способны встретить этот рубеж с подлинным достоинством, выдержкой и душевным благородством. Это не внезапный крах, а неизбежное завершение процесса, созревавшего долгое время.\n\nГлавный урок прямого Idho — искусство мудрого и спокойного отпускания. Человек не властен остановить ход времени или отменить естественные пределы, но в наших силах управлять своим отношением к ним. Перестаньте бороться с неизбежным и растрачивать силы на удержание того, чей срок уже истек. Принятие границ и осознанный отказ от лишнего приносят долгожданное освобождение, глубокий внутренний покой и открывают путь к подлинному возрождению.",
"shadow": "В перевёрнутом положении и в тени Idho обнажает страх перед переменами, парализующее сопротивление естественному ходу вещей и неспособность вовремя остановиться, проявляясь в двух разрушительных крайностях:\n\nС одной стороны — это теневой избыток и ядовитая ностальгия. Страх перед трансформацией сковывает вас, заставляя прятаться в комфортной скорлупе воспоминаний. Вы идеализируете прошлое, судорожно цепляетесь за отжившие формы, роли или давно угасшие связи, превращая свою жизнь в застывший музей. Попытка удержать то, что мертво, или бесконечное самобичевание за былые промахи — это лишь способ сбежать от взросления и требований настоящего момента. Закостенелость делает вас уязвимыми: старая форма обязана уйти, иначе новое не сможет родиться.\n\nС другой стороны — теневой дефицит и упорный отказ от отдыха. Это панический страх тишины, трудоголизм и лихорадочная суета, работающие в режиме чистого саморазрушения. Вы взваливаете на себя ворох чужих ожиданий и обязательств, лишь бы не оставаться наедине с собой и не признавать назревший финал. Тень Idho строго предупреждает: сбавьте темп и позвольте себе отдых добровольно, пока природа не заставила вас остановиться через болезнь или истощение. Отмените необязательные дела, побудьте в тишине и дайте душе пространство для осмысления.",
"meanings": [],
"pairs": []
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

+82
View File
@@ -0,0 +1,82 @@
═══════════════════════════════════════════════════════════════════
ЕДИНООБРАЗНЫЙ ПАСПОРТ ЗНАКА «ОРАКУЛА 999»: #000 «ОГОНЬ»
═══════════════════════════════════════════════════════════════════
I. Заголовочный паспорт (Метаданные реестра)
Индекс и имя: 000 | ОГОНЬ | 🔥 💥
Грамматический класс: существительное
Функциональная категория: Образ / Архетип
Сетевой статус: Свободный знак поля
---
II. Кросс-мантическая сеть и станции (16 классических систем)
Привязанные узловые станции:
Старший Футарк (Руны): [I Атт Фрейра · Руна 6 Кеназ (Kenaz / ᚲ) / Роль: Слой / Очищающее пламя, страсть, трансформация, разрушение иллюзий ]
Кельтский Огам: [2 Луис (Luis / Рябина) / Роль: Слой / Очищающее пламя, страсть, трансформация, разрушение иллюзий ]
Старшие Арканы Таро: [I Маг / Роль: Слой / Очищающее пламя, страсть, трансформация, разрушение иллюзий ]
Малая колода Ленорман: [DRY-RUN] Определи соответствие знака «огонь» в малой французской коло...
Каббала / Древо Жизни: [DRY-RUN] Определи соответствие знака «огонь» в Каббале и Древе Жизни ...
Арабская Геомантия ('Илм ар-Рамл): [12 Puer (Юноша) / Роль: Слой / Очищающее пламя, страсть, трансформация, разрушение иллюзий ]
И Цзин (Книга Перемен): [Триграмма ☲ Ли (Сцепление / Огонь) / Роль: Слой / Очищающее пламя, страсть, трансформация, разрушение иллюзий ]
Алхимия (Magnum Opus): [Огонь (Ignis) / Роль: Слой / Очищающее пламя, страсть, трансформация, разрушение иллюзий ]
Классический Зодиак: [DRY-RUN] Определи соответствие знака «огонь» в классическом западном ...
Китайский Зодиак: [7 Лошадь (Ма) 🐎 / Роль: Пересечение / Сакральный узел]
Септенер: [Вторник (Dies Martis · Týsdagr) ♂ / Роль: Пересечение / Сакральный узел]
Додекада Месяцев: [2 Февраль (Februarius · Лютень) 🕯️ / Роль: Пересечение / Сакральный узел]
Нумерология: [DRY-RUN] Определи нумерологический код знака «огонь» с учётом его ID:...
Космология Трёх Миров: [DRY-RUN] Определи топологию знака «огонь» в Космологии Трёх Миров: Ве...
20 Навалей Цолькин (Майя): [DRY-RUN] Определи соответствие знака «огонь» среди 20 священных Навал...
27 Накшатр Джйотиш (Индия): [DRY-RUN] Определи соответствие знака «огонь» в системе 27 Накшатр (лу...
Действующие законы станций (выжимка):
Кеназ (Kenaz): Огонь кузницы духа, факел познания, свет озарения во тьме, трансформация страсти, мастерство ремесла и созидающее горение. • I Маг: Человеческая воля соединяет высший замысел с земным миром, претворяя силу четырёх стихий в осознанное мастерство и безусловный контроль над ситуацией. • VIII Сила: Закон аркана — победа духовной воли над слепыми инстинктами через любовь и кротость, когда дикая первобытная энергия не уничтожается насилием, а преображается в созидательную силу.
---
III. Мультилингвальный и графический паспорт (Язык, Звук, Глиф)
Этимология и праформы: [DRY-RUN] Для слова «огонь» реконструируй древнейшие праформы и этимол...
Сравнительный срез графики и фоносемантики (16 систем записи):
Шумер / Аккад: [DRY-RUN] Для понятия «огонь» приведи клинописный эквивалент шумеро-ак...
Древний Египет: [DRY-RUN] Для понятия «огонь» приведи древнеегипетский иероглифический...
Иудейская традиция: [DRY-RUN] Для понятия «огонь» приведи соответствие на библейском иврит...
Античность: [DRY-RUN] Для понятия «огонь» приведи античные термины: [Древнегреческ...
Кельты: [DRY-RUN] Для понятия «огонь» приведи кельтский аналог: [Огамическая б...
Скандинавы / Германцы: [DRY-RUN] Для понятия «огонь» приведи скандинавско-германский аналог: ...
Славяне: [DRY-RUN] Для слова «огонь» приведи древнеславянский срез: [Праславянс...
Ведическая Индия: [DRY-RUN] Для понятия «огонь» приведи ведический/санскритский срез: [С...
Персия: [DRY-RUN] Для понятия «огонь» приведи персидский аналог: [Авестийский ...
Китай: [DRY-RUN] Для понятия «огонь» приведи древнекитайский иероглифический ...
Япония: [DRY-RUN] Для понятия «огонь» приведи японский аналог: [Кандзи / Ромад...
Исламский мир: [DRY-RUN] Для понятия «огонь» приведи арабско-исламский эквивалент: [А...
Западная Европа: [DRY-RUN] Для понятия «огонь» приведи западноевропейский герметический...
Мезоамерика: [DRY-RUN] Для понятия «огонь» приведи мезоамериканский эквивалент: [Ма...
Коренные народы Сев. Америки: [DRY-RUN] Для понятия «огонь» приведи символический пиктограф или тоте...
Полинезия: [DRY-RUN] Для понятия «огонь» приведи геометрический мотив полинезийск...
---
IV. Трехполярная матрица состояний (0 / +1 / -1)
0 Нейтральный статус (База / Бытие / Среда / Субстанция): Очищающее пламя, страсть, трансформация, разрушение иллюзий и горение духа.
[+1] Созидательный вектор (Свет / Ресурс / Эволюция): Первоэлемент воли и пережигания формы.
[-1] Деструктивный вектор (Тень / Вызов / Искажение): Спешка суда, самосожжение ради вспышки, огонь без берега.
---
V. Культурологическая матрица и 17-й Интегральный мета-синтез
А. 16 Мировых Традиций (Срез «Свет / Тень»):
Шумер и Месопотамия: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в культур... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Древний Египет: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в религии... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Иудейская традиция и Каббала: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в иудейск... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Античность (Греция / Рим): [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в антично... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Кельтская традиция: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в кельтск... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Скандинавско-германская традиция: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в скандин... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Славянская традиция: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в славянс... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Ведическая Индия и Тантра: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в ведичес... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Персия и Зороастризм: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в зороаст... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Китай (Даосизм, И-цзин, У-син): [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в китайск... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Япония (Синтоизм, Сингон): [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в синтоиз... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Исламская традиция и суфизм: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в исламск... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Западноевропейская традиция (Алхимия / Герметизм): [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в западно... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Мезоамерика и Анды (Майя, Ацтеки, Инки): [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в культур... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Традиции коренных народов Северной Америки: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в шаманиз... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Полинезия и Океания: [+] [DRY-RUN] Для символа «огонь» опиши СОЗИДАТЕЛЬНЫЙ АСПЕКТ [+] в культур... / [-] [DRY-RUN] Для символа «огонь» опиши ДЕСТРУКТИВНЫЙ / ТЕНЕВОЙ АСПЕКТ [-]...
Б. 17-й Интегральный мета-синтез (Универсальный культурологический код):
[DRY-RUN] Для символа «огонь» сформируй 17-й ИНТЕГРАЛЬНЫЙ МЕТА-СИНТЕЗ,...
---
VI. Мантический & Обучающий синтез
Смысловое ядро: Первоэлемент воли и пережигания формы.
Интерпретация по грамматическому типу: [DRY-RUN] Для знака «огонь» дай мантическую интерпретацию через призму...
Вектор Воли (К чему призывает): Что именно сейчас должно сгореть, чтобы остальное стало видно?
Предостережение / Тень (О чем предупреждает): Спешка суда, самосожжение ради вспышки, огонь без берега.
═══════════════════════════════════════════════════════════════════
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 722 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1004 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 43 KiB

+7018 -7017
View File
File diff suppressed because it is too large Load Diff
+2 -5
View File
@@ -63,9 +63,7 @@ class CopilotApp extends BaseProvider {
startReq.headers.addAll({ startReq.headers.addAll({
'content-type': 'application/json', 'content-type': 'application/json',
'user-agent': _ua, 'user-agent': _ua,
'origin': url, 'X-Search-UILang': 'en-US', 'Host': 'copilot.microsoft.com',
'referer': '$url/',
'X-Search-UILang': 'en-US',
}); });
startReq.body = jsonEncode({ startReq.body = jsonEncode({
'timeZone': 'Europe/Moscow', 'timeZone': 'Europe/Moscow',
@@ -94,7 +92,6 @@ class CopilotApp extends BaseProvider {
wsUrl, wsUrl,
headers: { headers: {
'User-Agent': _ua, 'User-Agent': _ua,
'Origin': url,
'Pragma': 'no-cache', 'Pragma': 'no-cache',
'Cache-Control': 'no-cache', 'Cache-Control': 'no-cache',
if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader, if (cookieHeader.isNotEmpty) 'Cookie': cookieHeader,
@@ -239,7 +236,7 @@ class CopilotApp extends BaseProvider {
List<CookieSeed> _fakeCookieSeeds() { List<CookieSeed> _fakeCookieSeeds() {
return [ return [
CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'), CookieSeed(name: '_C_ETH', value: '1', domain: '.microsoft.com'),
CookieSeed(name: '_C_Auth', value: '', domain: '.microsoft.com'),
CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'), CookieSeed(name: 'MUID', value: _hex(32), domain: '.microsoft.com'),
CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'), CookieSeed(name: 'MUIDB', value: _hex(32), domain: '.microsoft.com'),
CookieSeed( CookieSeed(
+1 -1
View File
@@ -27,7 +27,7 @@ class DuckDuckGo extends WebViewBaseProvider {
String get defaultModel => 'gpt-5.6-luna'; String get defaultModel => 'gpt-5.6-luna';
@override @override
String get baseUrl => 'https://duck.ai/'; String get baseUrl => 'https://duckduckgo.com/';
@override @override
String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat'; String get apiEndpoint => 'https://duckduckgo.com/duckchat/v1/chat';
+1 -1
View File
@@ -51,7 +51,7 @@ class HuggingChat extends WebViewBaseProvider {
if (!convRes.ok) { if (!convRes.ok) {
return JSON.stringify({error: 'HuggingChat login required: ' + convRes.status + ' ' + await convRes.text()}); return JSON.stringify({error: 'HuggingChat login required: ' + convRes.status + ' ' + await convRes.text()});
} }
const conv = await convRes.json(); const text = await convRes.text(); const conv = text ? JSON.parse(text) : {};
const id = conv.json?.id || conv.id || conv.conversationId; const id = conv.json?.id || conv.id || conv.conversationId;
if (!id) return JSON.stringify({error: 'no conversation id', raw: conv}); if (!id) return JSON.stringify({error: 'no conversation id', raw: conv});
+45 -22
View File
@@ -1,10 +1,19 @@
import 'screens/divination_hub_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'screens/catalog_screen.dart';
import 'screens/history_screen.dart'; import 'screens/history_screen.dart';
import 'screens/home_search_screen.dart'; import 'screens/home_search_screen.dart';
import 'screens/oracle_chat_screen.dart'; import 'screens/houses_field_screen.dart';
import 'screens/provider_bakeoff_screen.dart'; import 'screens/provider_bakeoff_screen.dart';
import 'screens/settings_screen.dart';
import 'services/lenormand_pairs_repository.dart';
import 'services/ogham_feda_repository.dart';
import 'services/tarot_majors_repository.dart';
import 'services/settings_repository.dart';
import 'services/traditions_repository.dart';
import 'services/words_repository.dart'; import 'services/words_repository.dart';
import 'services/word_of_the_day_service.dart';
import 'widgets/gradient_star_badge.dart'; import 'widgets/gradient_star_badge.dart';
import 'g4f/client.dart'; import 'g4f/client.dart';
@@ -19,6 +28,12 @@ import 'g4f/providers/phind_ai.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await WordsRepository().init(); await WordsRepository().init();
await TraditionsRepository().init();
await LenormandPairsRepository().init();
await TarotMajorsRepository().init();
await OghamFedaRepository().init();
await SettingsRepository().init();
await WordOfTheDayService().init();
runApp(const Words999App()); runApp(const Words999App());
} }
@@ -33,7 +48,7 @@ class _Words999AppState extends State<Words999App> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Оракул 999 Символов', title: 'Оракул',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark, // Default to rich mystical dark mode themeMode: ThemeMode.dark, // Default to rich mystical dark mode
darkTheme: ThemeData( darkTheme: ThemeData(
@@ -84,6 +99,9 @@ class _Words999AppState extends State<Words999App> {
}), }),
), ),
), ),
builder: (context, child) {
return SelectionArea(child: child ?? const SizedBox.shrink());
},
home: const bool.fromEnvironment('ORACLE_BAKEOFF') home: const bool.fromEnvironment('ORACLE_BAKEOFF')
? const ProviderBakeoffScreen() ? const ProviderBakeoffScreen()
: const MainNavigationContainer(), : const MainNavigationContainer(),
@@ -94,6 +112,13 @@ class _Words999AppState extends State<Words999App> {
class MainNavigationContainer extends StatefulWidget { class MainNavigationContainer extends StatefulWidget {
const MainNavigationContainer({super.key}); const MainNavigationContainer({super.key});
static void switchToTab(BuildContext context, int index) {
final state = context.findAncestorStateOfType<_MainNavigationContainerState>();
if (state != null) {
state.setTab(index);
}
}
@override @override
State<MainNavigationContainer> createState() => _MainNavigationContainerState(); State<MainNavigationContainer> createState() => _MainNavigationContainerState();
} }
@@ -102,10 +127,16 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
int _currentIndex = 0; int _currentIndex = 0;
final List<Widget> _screens = const [ final List<Widget> _screens = const [
HomeSearchScreen(), DivinationHubScreen(),
CatalogScreen(),
HistoryScreen(), HistoryScreen(),
SettingsScreen(),
]; ];
void setTab(int index) {
setState(() => _currentIndex = index);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -121,35 +152,27 @@ class _MainNavigationContainerState extends State<MainNavigationContainer> {
}, },
destinations: const [ destinations: const [
NavigationDestination( NavigationDestination(
icon: Icon(Icons.casino_outlined), icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: GradientStar(size: 22), selectedIcon: GradientStar(size: 22),
label: 'Оракул', label: 'Оракул',
), ),
NavigationDestination(
icon: Icon(Icons.auto_stories_outlined),
selectedIcon: GradientStar(size: 22),
label: 'Справочник',
),
NavigationDestination( NavigationDestination(
icon: Icon(Icons.history_rounded), icon: Icon(Icons.history_rounded),
selectedIcon: GradientStar(size: 22), selectedIcon: GradientStar(size: 22),
label: 'История', label: 'История',
), ),
NavigationDestination(
icon: Icon(Icons.tune_rounded),
selectedIcon: GradientStar(size: 22),
label: 'Настройки',
),
], ],
), ),
floatingActionButton: GestureDetector(
onLongPress: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ProviderBakeoffScreen()),
);
},
child: FloatingActionButton.extended(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const OracleChatScreen()),
);
},
icon: const Icon(Icons.auto_awesome),
label: const Text("ОРАКУЛ"),
),
),
); );
} }
} }
+243
View File
@@ -0,0 +1,243 @@
/// Канонический реестр культурных ветвей человечества, через которые
/// толкуется каждый знак. Порядок списка — порядок показа в «учебнике».
class CultureBranch {
final String id;
final String name;
final String emoji;
final String subtitle;
/// Стемы/ключевые слова (в нижнем регистре), по которым ветвь узнаётся в тексте.
final List<String> stems;
const CultureBranch({
required this.id,
required this.name,
required this.emoji,
required this.subtitle,
required this.stems,
});
/// Dart-овский `\b` не понимает кириллицу, поэтому граница слова
/// задаётся явным lookbehind по буквам.
RegExp get matcher => RegExp(
'(?<![a-zа-яё])(?:${stems.map(RegExp.escape).join('|')})',
caseSensitive: false,
unicode: true,
);
bool matches(String text) => matcher.hasMatch(text.toLowerCase());
}
const List<CultureBranch> kCultureBranches = [
CultureBranch(
id: 'sumer',
name: 'Шумер и Месопотамия',
emoji: '🏺',
subtitle: 'Клинопись • Ану, Энлиль, Инанна, Уту',
stems: ['шумер', 'аккад', 'месопотам', 'вавилон', 'ассири', 'клинопис', 'уту', 'инанн', 'иштар', 'энлил', 'энки', 'мардук', 'тиамат', 'ануннак', 'гильгамеш', 'эрешкигаль', 'mūšu', 'ilū'],
),
CultureBranch(
id: 'egypt',
name: 'Древний Египет',
emoji: '🪲',
subtitle: 'Нил и Дуат • Ра, Осирис, Исида, Маат',
stems: ['египет', 'египт', 'фараон', 'апоп', 'нут ', 'нут,', 'дуат', 'осирис', 'исид', 'тот ', 'маат', 'анубис', 'сехмет', 'хепри', 'атум', 'птах', 'ра ', 'ра,', 'ра.', 'гор '],
),
CultureBranch(
id: 'judaic',
name: 'Иудейская традиция и Каббала',
emoji: '✡️',
subtitle: 'Танах, Талмуд, Зоар • Древо Сфирот',
stems: ['иудей', 'иудаизм', 'еврей', 'каббал', 'танах', 'тора ', 'торы', 'талмуд', 'зоар', 'библ', 'бытие', 'бытия', 'сфир', 'цимцум', 'иврит', 'йом', 'эрев', 'лайла', 'хошех', 'эйн-соф', 'эйн соф', 'шхин'],
),
CultureBranch(
id: 'greco_roman',
name: 'Античность (Греция и Рим)',
emoji: '🏛️',
subtitle: 'Гесиод, Гомер, Платон • Олимп и Хаос',
stems: ['греци', 'греческ', 'грек', 'эллин', 'римск', 'рима', 'латин', 'античн', 'зевс', 'нюкт', 'гемер', 'эфир', 'юпитер', 'аполлон', 'гелиос', 'гомер', 'гесиод', 'платон', 'персефон', 'гермес', 'dies irae'],
),
CultureBranch(
id: 'celtic',
name: 'Кельты и друиды',
emoji: '🌿',
subtitle: 'Огам, Самайн, Белтайн • Иной мир сидов',
stems: ['кельт', 'огам', 'друид', 'самайн', 'белтайн', 'имболк', 'лугнас', 'ирланд', 'валлий', 'бригит', 'сиды', 'туата'],
),
CultureBranch(
id: 'norse',
name: 'Скандинавия и германцы',
emoji: 'ᚱ',
subtitle: 'Эдды, руны • Один, Иггдрасиль, Рагнарёк',
stems: ['скандинав', 'германск', 'норвеж', 'исланд', 'эдд', 'руна ', 'руны', 'рун ', 'одина', 'одину', 'иггдрасил', 'рагнар', 'асгард', 'вальхал', 'фрейя', 'локи', 'норны', 'дагаз'],
),
CultureBranch(
id: 'slavic',
name: 'Славянская традиция',
emoji: '☀️',
subtitle: 'Явь, Навь, Правь • Велес, Перун, Макошь',
stems: ['славян', 'древнерус', 'явь', 'навь', 'нави ', 'правь', 'велес', 'перун', 'макош', 'мокош', 'сварог', 'даждьбог', 'ярил', 'купал', 'коляд', 'стрибог', 'домов', 'леший', 'русалк'],
),
CultureBranch(
id: 'vedic',
name: 'Ведическая Индия',
emoji: '🕉️',
subtitle: 'Веды и Упанишады • Рита, карма, атман',
stems: ['индийск', 'индуи', 'ведическ', 'веды', 'вед ', 'ригвед', 'упанишад', 'санскрит', 'дхарм', 'карм', 'брахман', 'атман', 'сурья', 'агни', 'индра', 'шива', 'вишну', 'кали ', 'рита ', 'тантр'],
),
CultureBranch(
id: 'persian',
name: 'Персия и зороастризм',
emoji: '🔥',
subtitle: 'Авеста • Ахура-Мазда и Ахриман, Аша',
stems: ['персид', 'персы', 'иран', 'зороастр', 'заратустр', 'авест', 'ахура', 'ахриман', 'митр'],
),
CultureBranch(
id: 'china',
name: 'Древний Китай',
emoji: '☯️',
subtitle: 'Дао, Инь-Ян, У-син • Книга Перемен',
stems: ['китай', 'дао', 'инь', 'ян ', 'ян,', 'ян.', 'ян:', 'и цзин', 'и-цзин', 'гексаграмм', 'триграмм', 'конфуци', 'лао-цзы', 'лао цзы', 'чжуан', 'у-син', 'у син', 'иероглиф'],
),
CultureBranch(
id: 'japan',
name: 'Япония и синто',
emoji: '⛩️',
subtitle: 'Кодзики • Аматэрасу, ками, дзэн',
stems: ['япон', 'синто', 'аматэрасу', 'ками ', 'кодзики', 'дзэн', 'идзанаги', 'идзанами'],
),
CultureBranch(
id: 'islamic',
name: 'Ислам и суфизм',
emoji: '🌙',
subtitle: 'Коран, суфийская поэзия • Руми, Ибн Араби',
stems: ['ислам', 'суфи', 'суфий', 'арабск', 'коран', 'лайл ', 'лайл,', 'мирадж', 'лайлят', 'тасаввуф', 'руми', 'дервиш', 'ибн араби', 'геомант', 'рамл', 'тетраграмм'],
),
CultureBranch(
id: 'christian',
name: 'Христианская мистика',
emoji: '✝️',
subtitle: 'Отцы-пустынники, Апокалипсис • Логос',
stems: ['христиан', 'евангел', 'апокалипс', 'отшельник', 'пустынник', 'монах', 'литург', 'daemon meridianus', 'акеди', 'страшный суд', 'иисус'],
),
CultureBranch(
id: 'mesoamerica',
name: 'Мезоамерика и Анды',
emoji: '🌞',
subtitle: 'Ацтеки, майя, инки • Кецалькоатль, Тонатиу',
stems: ['ацтек', 'майя', 'мезоамерик', 'тонатиу', 'кецалькоатл', 'тескатлипок', 'попол-вух', 'попол вух', 'инки', 'инков'],
),
CultureBranch(
id: 'oceania',
name: 'Полинезия и Океания',
emoji: '🌺',
subtitle: 'По и Ао • Ранги и Папа, мана, табу',
stems: ['полинези', 'маори', 'гавай', 'pō', 'ao)', 'ранги', 'тангароа'],
),
CultureBranch(
id: 'hermetic',
name: 'Алхимия и герметизм',
emoji: '⚗️',
subtitle: 'Изумрудная скрижаль • Нигредо → Рубедо',
stems: ['алхими', 'гермет', 'нигредо', 'альбедо', 'цитринитас', 'рубедо', 'атанор', 'трисмегист', 'великое делание', 'философский камень', 'геоманти', 'геомант', 'puer', 'rubeus', 'albus'],
),
];
CultureBranch? cultureBranchByName(String name) {
final lower = name.toLowerCase().trim();
for (final b in kCultureBranches) {
if (b.name.toLowerCase() == lower || b.id == lower) return b;
}
for (final b in kCultureBranches) {
if (b.matches(' $lower ')) return b;
}
return null;
}
/// Раскладывает текст толкования на культурные ветви: каждый абзац,
/// где ветвь упомянута, попадает в её раздел (абзац может принадлежать
/// нескольким культурам — это и есть материал для сравнения).
Map<String, String> extractCulturalBranches(String text) {
final result = <String, String>{};
if (text.trim().isEmpty) return result;
final paragraphs = text
.split(RegExp(r'\r?\n'))
.map((p) => p.trim())
.where((p) => p.isNotEmpty)
.toList();
for (final branch in kCultureBranches) {
final hits = paragraphs.where((p) => branch.matches(' $p ')).toList();
if (hits.isNotEmpty) result[branch.name] = hits.join('\n\n');
}
return result;
}
/// Сортирует произвольную карту культур в каноническом порядке реестра.
List<MapEntry<String, String>> orderedCultures(Map<String, String> cultures) {
int rank(String name) {
final b = cultureBranchByName(name);
return b == null ? 999 : kCultureBranches.indexOf(b);
}
final entries = cultures.entries.where((e) => e.value.trim().isNotEmpty).toList()
..sort((a, b) => rank(a.key).compareTo(rank(b.key)));
return entries;
}
/// Разбирает блок «## КУЛЬТУРЫ» на ветви по подзаголовкам
/// (`### Шумер и Месопотамия`, `**Древний Египет**`, `Кельты:` …).
Map<String, String> parseCulturesBlock(String block) {
final result = <String, String>{};
final lines = block.replaceAll('\r\n', '\n').split('\n');
String? current;
final buf = StringBuffer();
void flush() {
final text = buf.toString().trim();
final key = current;
if (key != null && text.isNotEmpty) {
result[key] = result.containsKey(key) ? '${result[key]}\n\n$text' : text;
}
buf.clear();
}
String? headingOf(String raw) {
final line = raw.trim();
if (line.isEmpty || line.length > 90) return null;
final looksLikeHeading = line.startsWith('#') ||
(line.startsWith('**') && line.endsWith('**')) ||
RegExp(r'^[-•*]?\s*\*\*[^*]+\*\*\s*[:—–-]?\s*$').hasMatch(line) ||
(line.endsWith(':') && line.length < 60);
if (!looksLikeHeading) return null;
final clean = line
.replaceAll(RegExp(r'^[#\s•*-]+'), '')
.replaceAll(RegExp(r'[*:—–\s-]+$'), '')
.replaceAll('*', '')
.trim();
final branch = cultureBranchByName(clean);
return branch?.name ?? (clean.isNotEmpty ? clean : null);
}
for (final line in lines) {
final h = headingOf(line);
if (h != null) {
flush();
current = h;
} else {
buf.writeln(line);
}
}
flush();
return result;
}
/// Обратная операция для редактора: карта культур → markdown-блок.
String formatCulturesBlock(Map<String, String> cultures) {
final sb = StringBuffer();
for (final e in orderedCultures(cultures)) {
sb.writeln('### ${e.key}');
sb.writeln(e.value.trim());
sb.writeln();
}
return sb.toString().trim();
}
+356
View File
@@ -0,0 +1,356 @@
class DivinationSystemInstruction {
final String id;
final String title;
final String icon;
final String brief;
final String historyAndEssence;
final String physicalDiceMethod;
final String directInputMethod;
final String interpretationGuide;
final List<String> sacredRules;
const DivinationSystemInstruction({
required this.id,
required this.title,
required this.icon,
required this.brief,
required this.historyAndEssence,
required this.physicalDiceMethod,
required this.directInputMethod,
required this.interpretationGuide,
required this.sacredRules,
});
}
class DivinationInstructionsRepository {
static const Map<String, DivinationSystemInstruction> all = {
'runes': DivinationSystemInstruction(
id: 'runes',
title: 'Руны Старшего Футарка',
icon: 'ᚱ',
brief: '24 священных знака древнегерманского и скандинавского канона, разбитые на три этта по 8 рун.',
historyAndEssence:
'Старший Футарк — древнейшая руническая система, полученная верховным богом Одином ценой девятидневного висения на Мировом Древе Иггдрасиль. Каждая руна представляет собой не просто букву, а первородный космический закон, архетипическую силу и состояние материи.',
physicalDiceMethod:
'КАК БРОСАТЬ КОСТИ НА СТОЛЕ (2×d12):\n'
'Для строго равномерного выпадения 24 рун (вероятность 1/24) используются два двенадцатигранника d12 (или один кубик d12, брошенный дважды):\n\n'
'1. Первый кубик d12 (Чёт / Нечет):\n'
' • Нечётное число (1, 3, 5, 7, 9, 11) ➔ Выбирает первую половину рун (номера 1..12, от Феху до Йеры).\n'
' • Чётное число (2, 4, 6, 8, 10, 12) ➔ Выбирает вторую половину рун (номера 13..24, от Эйваз до Одал).\n\n'
'2. Второй кубик d12 (Точный номер):\n'
' • Показывает номер руны внутри выбранной половины (от 1 до 12).\n'
' • Если на первом кубике был нечет: руна = d12_2.\n'
' • Если на первом кубике был чет: руна = d12_2 + 12.\n\n'
'Пример: Первый кубик = 8 (чет), Второй кубик = 12 ➔ 12 + 12 = Руна №24 (Одал).',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА:\n'
'Если вы вытянули настоящую деревянную или каменную плашку из рунического мешочка:\n'
'1. Перейдите на вкладку «Ввод со стола».\n'
'2. Если руна легла головой вниз (для обратимых рун) — нажмите переключатель «Перевёрнутая ᛉ/ᛦ».\n'
'3. Нажмите на глиф выпавшей руны в сетке 24 символов.',
interpretationGuide:
'ПРАВИЛА ТОЛКОВАНИЯ:\n'
'• Прямое положение: сила руны струится естественно и гармонично.\n'
'• Перевёрнутое положение (меркстейв): сила заблокирована, искажена, либо требует внутренней проработки перед внешним действием.\n'
'• Необратимые руны (Иса, Гебо, Хагалаз, Совило, Ингуз, Эйваз, Дагаз, Наутиз): не имеют обратного положения — их закон непреложен.',
sacredRules: [
'Не задавайте один и тот же вопрос рунам дважды за день.',
'Руны требуют личной ответственности: это совет и зеркало судьбы, а не фатализм.',
'Обратите внимание на связанный знак 999 — он даёт земную форму северному архетипу.',
],
),
'lenormand': DivinationSystemInstruction(
id: 'lenormand',
title: 'Малый Оракул Ленорман',
icon: '🪶',
brief: 'Французская система 36 символических Домов мадам Марии Ленорман с сюжетно-комбинаторным чтением.',
historyAndEssence:
'Малый Оракул Ленорман возник в конце XVIII века и основан на ясных житейских архетипах (Всадник, Корабль, Дом, Сердце, Коса, Крест). В отличие от Таро, карты Ленорман не читаются изолированно — они раскрывают смысл в парах, триплетах и домах, образуя законченное повествовательное предложение.',
physicalDiceMethod:
'КАК БРОСАТЬ КОСТИ НА СТОЛЕ (d6 + d12):\n'
'Колода насчитывает ровно 36 карт (3 блока по 12). Для идеальной вероятности 1/36 используются обычный кубик d6 и двенадцатигранник d12:\n\n'
'1. Кубик d6 (Смещение блока):\n'
' • Выпало 1 или 2 ➔ База +0 (карты с 1 по 12).\n'
' • Выпало 3 или 4 ➔ База +12 (карты с 13 по 24).\n'
' • Выпало 5 или 6 ➔ База +24 (карты с 25 по 36).\n\n'
'2. Кубик d12 (Номер карты в блоке):\n'
' • Выпавшее число на d12 складывается с базой: Карта = База + d12.\n\n'
'Пример: d6 показал 4 (база 12), а d12 показал 12 ➔ 12 + 12 = Карта 24 («Сердце»).',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА:\n'
'Если вы гадаете на бумажной колоде Ленорман:\n'
'1. Перейдите во вкладку «Ввод 1..36 со стола».\n'
'2. Вбейте номер выпавшей карты (1..36) в поле ввода либо просто нажмите на карточку с её номером в галерее.',
interpretationGuide:
'ПРАВИЛА ЧТЕНИЯ КАРТ ЛЕНОРМАН:\n'
'• Первая карта — подлежащее (тема, субъект вопроса).\n'
'• Вторая карта — сказуемое / определение (что происходит, какое качество обретает тема).\n'
'• В приложении доступен полный справочник всех 35 парных сочетаний для каждой карты (кнопка с книгой в карточке результата).',
sacredRules: [
'Карты Ленорман буквальны и говорят на языке фактов материи.',
'Обращайте внимание на «тяжёлые» карты (Коса, Гроб, Тучи, Крест) — они меняют вектор соседних карт.',
'Используйте Мастер-промпт, если нужно разобрать многослойную цепочку событий.',
],
),
'iching': DivinationSystemInstruction(
id: 'iching',
title: 'И Цзин (Книга Перемен)',
icon: '☯️',
brief: 'Древнекитайский канон Чжоу И: 64 гексаграммы, образуемые 6 бросками трёх монет снизу вверх.',
historyAndEssence:
'И Цзин («Книга Перемен») — величайший памятник восточной мудрости, созданный Фу Си, Вэнь-ваном и Конфуцием более 3000 лет назад. Каждая гексаграмма состоит из двух триграмм (Небо, Земля, Гром, Вода, Гора, Ветер, Огонь, Водоём) и описывает фазу динамической трансформации Инь и Ян во Вселенной.',
physicalDiceMethod:
'КЛАССИЧЕСКИЙ МЕТОД 3 МОНЕТ (6 БРОСКОВ СНИЗУ ВВЕРХ):\n'
'Возьмите 3 одинаковые монеты. Орёл считается за 3 (Ян), Решка — за 2 (Инь). Бросьте монеты 6 раз, складывая достоинства:\n\n'
'• 6 (2+2+2, три решки): СТАРАЯ ИНЬ (⚋ ➔ ⚊)\n'
' Мутирующая женская черта. В основной гексаграмме прерывистая, но трансформируется в сплошную.\n\n'
'• 7 (3+2+2, один орёл, две решки): МОЛОДАЯ ЯН (⚊)\n'
' Стабильная светлая черта. Не меняется.\n\n'
'• 8 (3+3+2, два орла, одна решка): МОЛОДАЯ ИНЬ (⚋)\n'
' Стабильная тёмная черта. Не меняется.\n\n'
'• 9 (3+3+3, три орла): СТАРАЯ ЯН (⚊ ➔ ⚋)\n'
' Мутирующая мужская черта. В основной гексаграмме сплошная, но трансформируется в прерывистую.',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА:\n'
'1. Совершите 6 бросков монет на столе, записывая суммы снизу вверх (от 1-й линии к 6-й).\n'
'2. Перейдите во вкладку «Ввод черт (6..9)».\n'
'3. Последовательно нажимайте кнопки 6, 7, 8 или 9 для каждого броска.',
interpretationGuide:
'КАК ЧИТАТЬ ГЕКСАГРАММУ:\n'
'• Линии 1–3 (нижняя триграмма): внутренний мир, намерения, личное начало.\n'
'• Линии 4–6 (верхняя триграмма): внешние обстоятельства, окружение, исход ситуации.\n'
'• Старые черты (6 и 9) указывают на точные точки перелома и скорых событий.',
sacredRules: [
'Линии всегда строятся СНИЗУ ВВЕРХ (от корней к ветвям).',
'Книга Перемен не терпит суеты: формулируйте вопрос о стратегии правильного поведения.',
'Истинный ответ кроется в моменте перехода между стабильностью и мутацией.',
],
),
'tarot': DivinationSystemInstruction(
id: 'tarot',
title: 'Таро (Старшие Арканы)',
icon: '🎴',
brief: '22 Старших Аркана: архитипический путь Души от Шута (0) до Мира (XXI).',
historyAndEssence:
'Старшие Арканы Таро — великая книга герметической философии. 22 аркана соотносятся с 22 буквами священного еврейского алфавита, путями на Каббалистическом Древе Жизни и астрологическими принципами. Это макрокосмические законы инициации сознания.',
physicalDiceMethod:
'КАК ГАДАТЬ НА КОЛОДЕ ИЛИ ЖРЕБИЕМ:\n'
'В приложении:\n'
'• Нажмите «Вытянуть Аркан» во вкладке «Жребий колоды». Приложение произведёт виртуальную тасовку и определит ориентацию карты (прямая или перевёрнутая).\n\n'
'Физической колодой:\n'
'• Тщательно перетасуйте 22 Старших Аркана, снимите колоду левой рукой к сердцу и вытяните центральную карту.',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА:\n'
'1. Перейдите во вкладку «Выбор из 22 Арканов».\n'
'2. Если выпавшая карта легла перевёрнутой — отметьте переключатель «Перевёрнутый».\n'
'3. Выберите карту из пронумерованного списка 0..XXI.',
interpretationGuide:
'ПРАВИЛА ИНТЕРПРЕТАЦИИ:\n'
'• Прямой аркан: манифестация архетипа в его чистом, созидательном свете.\n'
'• Перевёрнутый аркан: теневое проявление, избыток или дефицит энергии аркана, задержка осознания.\n'
'• По кнопке «Значения и пары» в результате гадания можно посмотреть парные созвучия аркана.',
sacredRules: [
'Старшие Арканы говорят о сущностных уроках судьбы, а не о мелком быте.',
'Каждый аркан содержит как благословение, так и суровое испытание.',
'Сопоставляйте аркан с выпавшими рунами и знаками сессии дня.',
],
),
'cleromancy': DivinationSystemInstruction(
id: 'cleromancy',
title: 'Оракул (Клеромантия)',
icon: '🎲',
brief: 'Каноническая тысяча сакральных знаков (000–999), бросаемых трёхзначными кубиками d10.',
historyAndEssence:
'Клеромантия — древнейшее искусство гадания по священному жребию. Система Оракула объединяет 1000 архетипических слов русского языка с их сакральной семантикой, энергетическими валентностями, тенями и нерушимыми формулами бытия.',
physicalDiceMethod:
'КАК БРОСАТЬ КОСТИ НА СТОЛЕ (3×d10):\n'
'Для получения одного трёхзначного знака используются три десятигранные кости d10 (с гранями 0..9):\n'
'• Первая кость d10 = Сотни (0..9)\n'
'• Вторая кость d10 = Десятки (0..9)\n'
'• Третья кость d10 = Единицы (0..9)\n\n'
'Пример: выпало 0, 4, 2 ➔ Знак #042 («КРЫЛО»). Если выпало 0, 0, 0 ➔ Знак #000.',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА:\n'
'1. Выберите количество знаков в цепи (от 1 до 10).\n'
'2. Вбейте трёхзначный номер знака со своих костей либо наберите слово в поиске.',
interpretationGuide:
'ПРАВИЛА ТОЛКОВАНИЯ:\n'
'• Знаки выстраиваются в связный поток судьбы слева направо (Исток ➔ Препятствие ➔ Вектор ➔ Исход).\n'
'• В каждом знаке изучайте три уровня: Ядро знака, Канонический смысл и Толкование Оракула.\n'
'• Практическая триада: «Тень» предостерегает от ловушки, «Вопрос» вскрывает слепое пятно, «Жест» требует конкретного действия.',
sacredRules: [
'Не перебрасывайте кости, если ответ кажется неожиданным: Оракул поражает эго, но открывает дух.',
'Используйте встроенных толкователей (Ясный оракул, Орк-шаман, Техножрец и др.) для разных интонаций.',
'Жмите «Мастер-Промпт», чтобы получить сверхглубокий разбор от Grok или Claude.',
],
),
'numerology': DivinationSystemInstruction(
id: 'numerology',
title: 'Нумерология и Квадрат Пифагора',
icon: '🔢',
brief: 'Древнегреческая психоматрица 3×3, расчёт 4 рабочих чисел, число жизненного пути и баланс 9 стихий личности.',
historyAndEssence:
'Пифагор и александрийские мистики объединили математические тайны египетских жрецов, халдейскую нумерологию и учение о музыке сфер в единую строгую систему. В основе лежит принцип: «Всё сущее есть число». Дата рождения или дата судьбоносного события кодирует матрицу потенциалов, скрытых талантов, запаса витальной энергии и кармических задач человека.',
physicalDiceMethod: '''ЖРЕБИЙ НА КОСТЯХ (МАНТИЧЕСКИЙ СРЕЗ МОМЕНТА):
Если вы гадаете на текущую ситуацию или вопрос, а не анализируете статическую дату рождения:
1. Бросьте 8 костей d10 (0..9) для формирования синтетической даты момента (ДД.ММ.ГГГГ).
2. Либо используйте бросок одной кости d10 (1..9) для определения ведущего Архетипа Числа Судьбы прямо сейчас.
3. В приложении во вкладке «Жребий d10» кости бросаются виртуально с мгновенным заполнением матрицы.''',
directInputMethod: '''ВВОД ДАТЫ (РОЖДЕНИЯ ИЛИ СОБЫТИЯ):
1. Выберите дату через встроенный календарь либо введите день, месяц и год вручную.
2. Канонический расчёт 4 Рабочих Чисел:
• 1-е число (W1): сумма всех цифр даты рождения.
• 2-е число (W2): сумма цифр 1-го числа.
• 3-е число (W3): W1 минус удвоенная первая значащая цифра дня рождения (W1 - 2 × d1).
• 4-е число (W4): сумма цифр 3-го числа.
3. Все цифры даты и 4 рабочих чисел вносятся в 9 ячеек психоматрицы 3×3:
[1: Характер] [4: Здоровье] [7: Удача]
[2: Энергия] [5: Логика] [8: Долг]
[3: Интерес] [6: Труд] [9: Память]''',
interpretationGuide: '''ПРАВИЛА ТОЛКОВАНИЯ ПСИХОМАТРИЦЫ:
• Прочерк (цифр нет): качество не дано от природы — зона свободного роста и кармической наработки.
• 1 цифра: качество развито слабо, требует осознанного включения.
• 2 цифры: норма, гармоничное и естественное проявление силы.
• 3 цифры: волнообразное проявление («хочу — проявлю, хочу — нет»), капризность таланта.
• 4-5+ цифр: гипер-потенциал, доминанта личности; при застое перерастает в деструктив.
ЛИНИИ МАТРИЦЫ:
• Строка 1 (1-4-7): Целеустремленность и настойчивость.
• Строка 2 (2-5-8): Семья, качество семьянина, верность.
• Строка 3 (3-6-9): Стабильность, привычки, привязанность к укладу.
• Столбец 1 (1-2-3): Самооценка, амбиции, внутренняя опора.
• Столбец 2 (4-5-6): Быт, материальный достаток, приспособляемость.
• Столбец 3 (7-8-9): Талант, одаренность, искра божья.
• Диагональ (1-5-9): Духовность, связь с высшим эгрегором.
• Диагональ (3-5-7): Чувственность, магнетизм, темперамент.''',
sacredRules: [
'Числа матрицы показывают энергетический каркас, но не ограничивают свободу воли человека.',
'Код суток на экране Слова Дня считается той же свёрткой: день, месяц и год по отдельности, мастер-числа 11, 22 и 33 не свёртываются.',
'Обращайте внимание на Число Жизненного Пути (ЧЖП) — оно связывает матрицу с сакральными знаками Оракула 999.',
'Психоматрица великолепно дополняет астрологию, руны и Таро в рамках единой дневной сессии.',
],
),
'word_of_day': DivinationSystemInstruction(
id: 'word_of_day',
title: 'Слово Дня и Календарь Осознанности',
icon: '☀️',
brief: 'Ежедневный ритуал, тренажёр синхронистичности и духовное упражнение из трёх актов (Утро, День, Вечер).',
historyAndEssence:
'''Слово Дня — это живой камертон текущей даты. В оракуле 999 каждому календарному дню соответствует резонирующий архетип, соединяющий время, бессознательное и земную реальность. Рядом с ним стоит нумерологический код суток: день, месяц и год свёртываются по отдельности и складываются, а мастер-числа 11, 22 и 33 остаются собой. Резонансное слово дня — отдельный знак тысячи, его не подменяет цифра.''',
physicalDiceMethod:
'''КАЛЕНДАРНЫЙ РЕЗОНАНС:
Слово дня рассчитывается математически детерминированным алгоритмом от даты года, месяца и дня. На один и тот же день на всех устройствах выпадает один и тот же символ, создавая единое синхронистическое поле.
ЖРЕБИЙ СО СТОЛА (d1000):
Если вы предпочитаете физический жребий — бросьте три десятигранника d10 (сотни, десятки, единицы 000..999) и найдите знак в справочнике.''',
directInputMethod:
'''РАБОТА С КАЛЕНДАРЁМ:
Используйте стрелки вверху или календарный виджет, чтобы перемещаться по дням, возвращаться к прошлым датам, просматривать хронологию и вести непрерывную летопись практик.''',
interpretationGuide:
'''ТРИ ТАКТА ОСОЗНАННОСТИ:
1. Утро (Настройка): прочитайте послание Оракула и возьмите «Коан дня» в качестве ментального фокуса.
2. День (Практикум): выполните микродействие (Вызов дня) и остерегайтесь Теневой ловушки.
3. Вечер (Синхронии): запишите в дневник наблюдения — как именно это слово проявилось в событиях, встречах и мыслях.''',
sacredRules: [
'Не форсируйте совпадения: позвольте символу проявиться естественно.',
'Теневая ловушка дня — ваш главный предохранитель от автоматизмов эго.',
'Регулярная летопись синхроний раскрывает скрытые закономерности судьбы.',
],
),
'matrix': DivinationSystemInstruction(
id: 'matrix',
title: 'Матрица 33×30 (990 Домов)',
icon: '🏛️',
brief: 'Сакральная геометрия поля 990 Домов, управляемая 10 верховными Архонтами и цепочками диспозиций.',
historyAndEssence:
'Матрица 33×30 представляет собой космическую развёртку реальности на 990 земных домов и 10 надмирных Архонтов (по одному на каждые 3 столбца). Каждый выпавший знак входит в дом как Гость, порождая уникальную трёхчастную иерархию: Архонт (небесный климат) ➔ Дом (земная почва) ➔ Гость (живой импульс).',
physicalDiceMethod:
'КАК ВЫБИРАТЬ ДОМА НА СТОЛЕ:\n'
'• Бросьте кости для выбора строки (1..33) и столбца (1..30).\n'
'• Либо используйте интерактивный бросок в приложении, где открываются выбранные вами дома на интерактивном полотне.',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА:\n'
'• Нажмите на любые дома на интерактивной сетке матрицы, либо задайте сигнификатор Кверента для центрирования расклада.',
interpretationGuide:
'ЦЕПОЧКИ ДИСПОЗИЦИЙ (HOUSE CHAINS):\n'
'• Дом указывает на знак Гостя. Но у самого Гостя есть свой собственный Дом в матрице!\n'
'• Переход от дома к дому образует «Цепь Судьбы».\n'
'• Цепь может замкнуться в «Петлю Судьбы», упереться в «Престол Архонта» или пронзить поле открытым вектором.',
sacredRules: [
'Архонт определяет высший закон, в рамках которого разворачиваются события.',
'Петля судьбы указывает на кармический узел, требующий осознания.',
'Формируйте Мастер-промпт Матрицы для многостраничного фундаментального анализа.',
],
),
'geomancy': DivinationSystemInstruction(
id: 'geomancy',
title: 'Арабская Геомантия (Илм ар-рамл)',
icon: '⌛',
brief: '16 священных фигур земного жребия, получаемых считыванием чётности рядов точек на песке.',
historyAndEssence:
'«Илм ар-рамл» («Наука о песке») — древнейшая арабская мантическая система, восходящая к легендарному пророку Идрису (Гермесу Трисмегисту) и широко распространившаяся в средневековой Европе как Геомантия. Четыре стихийных уровня (Огонь/Голова, Воздух/Шея, Вода/Тело, Земля/Стопы) дают ровно 16 фигур бинарного кода Вселенной, отражающих динамику материи и духа.',
physicalDiceMethod:
'КАК БРОСАТЬ В РЕАЛЬНОСТИ (ПЕСОК ИЛИ МОНЕТЫ):\n'
'1. Метод песка (традиционный):\n'
' Палочкой на песке или ручкой на бумаге быстро наносится 4 ряда случайных точек справа налево, не считая их.\n'
' Затем точки в каждом ряду группируются парами:\n'
' • Нечётный остаток (одна точка) ➔ 1 точка (·)\n'
' • Чётный остаток (две точки) ➔ 2 точки (··)\n'
' 4 ряда сверху вниз формируют фигуру.\n\n'
'2. Метод 4 монет или костей:\n'
' Бросьте 4 монеты одновременно (Орёл = 1 точка, Решка = 2 точки) сверху вниз.',
directInputMethod:
'РУЧНОЙ ВВОД СО СТОЛА ИЛИ КАТАЛОГА:\n'
'В приложении можно постукивать по песку пальцем, генерировать точки случайным рассеиванием песчинок, либо выбрать любую из 16 фигур напрямую из каталога.',
interpretationGuide:
'ПОЛЯРНОСТЬ И ЗНАЧЕНИЕ ФИГУР:\n'
'Каждая фигура имеет полярность (Входящая / Исходящая, Подвижная / Неподвижная), стихию и планетарного управителя. В Великом Щите ровно 16 фигур: 4 Матери, 4 Дочери, 4 Племянника, 2 Свидетеля, Судья (15) и Примиритель (16-я фигура, рождённая сложением Судьи и 1-й Матери для согласования исхода с вопрошающим). При этом Судья математически всегда содержит чётное число точек.',
sacredRules: [
'Сформулируйте один ясный вопрос перед касанием песка.',
'Не считайте точки при нанесении — пусть действует рука и интуиция.',
'Если Судья содержит нечётное число точек — в расчёте щита допущена ошибка (в приложении расчёт математически строг).',
'Фигура Rubeus (Красный) в 1-м доме или у Судьи предупреждает о ярости, крови или фальшивом вопросе.',
],
),
'ogham': DivinationSystemInstruction(
id: 'ogham',
title: 'Кельтский Древесный Огам (20 Фед)',
icon: '᚛',
brief: '20 священных символов-деревьев древних друидов, распределённых по 4 Аикмам Священной Рощи.',
historyAndEssence:
'Огам (Ogham) — священная тайнопись и мантическая система кельтских друидов и филидов древней Ирландии и Британии, восходящая к богу красноречия и мудрости Огме. Каждая насечка (феда) соответствует священному дереву или кустарнику Священной Рощи (Берёза, Рябина, Ольха, Дуб, Орешник, Тис и др.). 20 фед разделены на 4 семьи (Аикмы), образуя лестницу духовного восхождения от зарождения до вечности.',
physicalDiceMethod:
'ТРАДИЦИОННЫЙ ЖРЕБИЙ ДЕРЕВЯННЫХ ПЛАШЕК (ДРУИДИЧЕСКИЙ ЖРЕБИЙ):\n'
'1. Приготовьте мешочек с 20 деревянными палочками Огама (по одной на каждую феду).\n'
'2. Сосредоточьтесь на вопросе, вдохните аромат дерева и не глядя достаньте плашку.\n'
'3. Если плашка выпала засечками вправо/вверх — положение прямое, засечками влево/вниз — обратное/теневое.\n'
'4. Для броска кубиками: бросьте d20 (1..20 определяет номер феды), затем любую монету/d2 для определения полярности (1 = прямое, 2 = теневое).',
directInputMethod:
'РУЧНОЙ ВЫБОР ИЗ СВЯЩЕННОЙ РОЩИ:\n'
'В приложении доступен интерактивный бросок друидических палочек, либо прямой выбор нужных фед из резного свитка 4-х Аикм.',
interpretationGuide:
'ЗНАЧЕНИЕ ФЕД И ДРЕВЕСНЫХ СИЛ:\n'
'Каждое дерево в кельтской традиции несёт свой неповторимый архетип:\n'
'• Бэйт (Берёза) — начало, очищение, белый лист судьбы;\n'
'• Дуир (Дуб) — несокрушимая стойкость, врата власти и царственность;\n'
'• Тинне (Остролист) — огонь испытаний, победа в битве и благородство духа;\n'
'• Колл (Орешник) — высшая мудрость, колодец поэтического вдохновения;\n'
'• Идад (Тис) — порог вечности, смерть старого и бессмертие души.\n'
'В перевёрнутом виде феда указывает на теневой аспект: блокировку древесной силы, иллюзию защиты или горделивую самоизоляцию.',
sacredRules: [
'Слушайте шёпот древесных сил без спешки: Огам говорит на языке корней и времён года.',
'Обращайте внимание на перевёрнутое положение: оно никогда не бывает фатальным, а предупреждает о дисбалансе.',
'Связывайте выпавшую феду с сакральным знаком Оракула 999 для углубления толкования.',
],
),
};
static DivinationSystemInstruction get(String id) =>
all[id] ?? all['cleromancy']!;
}
+171
View File
@@ -0,0 +1,171 @@
class DivinationLayout {
final String id;
final String title;
final int count;
final String description;
final List<String> positionTitles;
const DivinationLayout({
required this.id,
required this.title,
required this.count,
required this.description,
required this.positionTitles,
});
}
class DivinationLayouts {
static const runesLayouts = [
DivinationLayout(
id: 'rune_1',
title: '1 Руна (Совет Одина)',
count: 1,
description: 'Мгновенный фокус ситуации или совет на день.',
positionTitles: ['Совет Одина'],
),
DivinationLayout(
id: 'rune_3',
title: '3 Руны (Три Норны)',
count: 3,
description: 'Нить судьбы во времени: Урд (Прошлое), Верданди (Настоящее), Скульд (Грядущее).',
positionTitles: ['Урд (Прошлое)', 'Верданди (Настоящее)', 'Скульд (Вектор будущего)'],
),
DivinationLayout(
id: 'rune_cross',
title: '5 Рун (Рунический Крест)',
count: 5,
description: 'Глубинный срез: Центр, Препятствие, Прошлое, Будущее и Итог.',
positionTitles: ['1. Суть (Центр)', '2. Препятствие (Вызов)', '3. Опора (Прошлое)', '4. Потенциал (Будущее)', '5. Исход (Венец)'],
),
];
static const lenormandLayouts = [
DivinationLayout(
id: 'leno_2',
title: '2 Карты (Пара)',
count: 2,
description: 'Подлежащее и Сказуемое — классическое диалоговое чтение.',
positionTitles: ['1. Тема (Подлежащее)', '2. Развитие (Сказуемое)'],
),
DivinationLayout(
id: 'leno_3',
title: '3 Карты (Линия)',
count: 3,
description: 'Развитие ситуации от истока к исходу.',
positionTitles: ['1. Исток', '2. Кульминация', '3. Исход'],
),
DivinationLayout(
id: 'leno_cross',
title: '5 Карт (Крест)',
count: 5,
description: 'Центр, скрытое, мысли, препятствие и развязка.',
positionTitles: ['1. Фокус', '2. Скрытое', '3. Мысли', '4. Препятствие', '5. Исход'],
),
DivinationLayout(
id: 'leno_square_9',
title: '9 Карт (Квадрат 3×3)',
count: 9,
description: 'Портрет ситуации: Прошлое, Настоящее, Будущее по вертикалям и горизонталям.',
positionTitles: [
'1. Прошлое/Мысли', '2. Фокус/Настоящее', '3. Будущее/Планы',
'4. Скрытые влияния', '5. Сердце ситуации (Кверент)', '6. Внешние силы',
'7. Исток проблемы', '8. Ближайший шаг', '9. Неизбежный результат'
],
),
DivinationLayout(
id: 'leno_gt_36',
title: 'Большой Расклад (36 Домов)',
count: 36,
description: 'Гран Табло (Grand Tableau): вся колода из 36 карт распределяется по 36 домам судьбы (4 ряда по 8 + 4 карты судьбы).',
positionTitles: ['1. Дом Всадника (Новости, мысли)', '2. Дом Клевера (Надежды, шанс)', '3. Дом Корабля (Стремления, путь)', '4. Дом Дома (Семья, основа)', '5. Дом Дерева (Здоровье, род)', '6. Дом Туч (Страхи, сомнения)', '7. Дом Змеи (Скрытые враги, интриги)', '8. Дом Гроба (Кризис, конец старого)', '9. Дом Букета (Радость, подарки)', '10. Дом Косы (Резкие решения, опасность)', '11. Дом Метлы (Конфликты, споры)', '12. Дом Птиц (Беседы, суета)', '13. Дом Ребёнка (Начало, невинность)', '14. Дом Лисы (Хитрость, работа)', '15. Дом Медведя (Сила, покровитель)', '16. Дом Звёзд (Ясность, судьба)', '17. Дом Аистов (Перемены, обновление)', '18. Дом Собаки (Дружба, верность)', '19. Дом Башни (Власть, закон, статус)', '20. Дом Сада (Общество, публика)', '21. Дом Горы (Препятствие, блок)', '22. Дом Развилки (Выбор дорог)', '23. Дом Мышей (Потери, истощение)', '24. Дом Сердца (Любовь, страсть)', '25. Дом Кольца (Союз, контракт)', '26. Дом Книги (Тайны, знания)', '27. Дом Письма (Документы, известия)', '28. Дом Мужчины (Анимус, вопрошающий)', '29. Дом Женщины (Анима, партнёрша)', '30. Дом Лилий (Чистота, зрелость)', '31. Дом Солнца (Успех, витальность)', '32. Дом Луны (Интуиция, признание)', '33. Дом Ключа (Решение, озарение)', '34. Дом Рыб (Финансы, изобилие)', '35. Дом Якоря (Стабильность, труд)', '36. Дом Креста (Карма, судьбоносный долг)'],
),
];
static const tarotLayouts = [
DivinationLayout(
id: 'tarot_1',
title: '1 Аркан (Суть Момента)',
count: 1,
description: 'Главный архетип, действующий в вашей жизни прямо сейчас.',
positionTitles: ['Аркан Момента'],
),
DivinationLayout(
id: 'tarot_3',
title: '3 Аркана (Триптих Судьбы)',
count: 3,
description: 'Прошлое (что уходит), Настоящее (что есть), Будущее (куда течёт энергия).',
positionTitles: ['1. Прошлое (Исток)', '2. Настоящее (Точка силы)', '3. Будущее (Вектор)'],
),
DivinationLayout(
id: 'tarot_cross',
title: '5 Арканов (Пентаграмма Выбора)',
count: 5,
description: 'Квинтэссенция ситуации и скрытые мотивы.',
positionTitles: ['1. Основа', '2. Вызов / Тень', '3. Осознанное', '4. Неосознанное', '5. Итог / Квинт'],
),
];
static const geomancyLayouts = [
DivinationLayout(
id: 'geomancy_1',
title: '1 Фигура (Знак Песка)',
count: 1,
description: 'Экспресс-оракул: быстрая фигура песка на один конкретный вопрос или совет момента.',
positionTitles: ['Знак Песка (Суть вопроса)'],
),
DivinationLayout(
id: 'geomancy_3',
title: '3 Фигуры (Триплет Песка)',
count: 3,
description: 'Три независимые фигуры песка: Исток, Развитие и Исход. Это не щит: Судья и Свидетели рождаются только сложением Великого Щита.',
positionTitles: ['Исток', 'Развитие', 'Исход'],
),
DivinationLayout(
id: 'geomancy_shield',
title: 'Великий Щит Геомантии (16 Фигур)',
count: 4,
description: 'Великий расклад "Илм ар-рамл": нанесение 4 Матерей, порождающих Дочерей, Племянников, Свидетелей, Судью и 16-ю фигуру — Примирителя.',
positionTitles: [
'1-я Мать (Вопрошающий / Душа)',
'2-я Мать (Ресурсы / Материя)',
'3-я Мать (Связи / Окружение)',
'4-я Мать (Тайное / Завершение)',
],
),
];
static const oghamLayouts = [
DivinationLayout(
id: 'ogham_1',
title: '1 Феда (Слово Древа)',
count: 1,
description: 'Совет священного дерева друидов на текущий момент или сущность ситуации.',
positionTitles: ['Священное Древо (Суть совета)'],
),
DivinationLayout(
id: 'ogham_3',
title: '3 Феды (Кельтский Трискель)',
count: 3,
description: 'Трёхчастный срез судьбы: Корень (Истоки/Прошлое), Ствол (Сила действия/Настоящее), Крона (Исход/Будущее).',
positionTitles: [
'Корень (Исток и скрытая почва)',
'Ствол (Сила действия в настоящем)',
'Крона (Исход и плоды судьбы)',
],
),
DivinationLayout(
id: 'ogham_5',
title: '5 Фед (Крест Священной Рощи)',
count: 5,
description: 'Четыре стороны света и Древо Сердца: Восток (Замысел), Юг (Действие), Запад (Чувства), Север (Опора), Центр (Сердце Рощи).',
positionTitles: [
'Восток (Зарождение и замысел)',
'Юг (Страсть и преодоление)',
'Запад (Интуиция и очищение)',
'Север (Материальная опора)',
'Центр (Сердцевина и вердикт Духа)',
],
),
];
}
+76
View File
@@ -0,0 +1,76 @@
class ExternalSymbolSource {
final String author;
final String work;
final String excerpt;
final String fullText;
const ExternalSymbolSource({
required this.author,
required this.work,
this.excerpt = '',
this.fullText = '',
});
factory ExternalSymbolSource.fromMap(Map<String, dynamic> map) {
return ExternalSymbolSource(
author: map['author'] as String? ?? '',
work: map['work'] as String? ?? '',
excerpt: map['excerpt'] as String? ?? '',
fullText: map['full_text'] as String? ?? '',
);
}
Map<String, dynamic> toMap() => {
'author': author,
'work': work,
'excerpt': excerpt,
'full_text': fullText,
};
}
class ExternalSymbol {
final String term;
final String normalized;
final String categoryGuess;
final List<ExternalSymbolSource> sources;
final List<Map<String, dynamic>> illustrations;
const ExternalSymbol({
required this.term,
required this.normalized,
this.categoryGuess = 'Внешний академический символ',
this.sources = const [],
this.illustrations = const [],
});
bool get hasIllustrations => illustrations.isNotEmpty;
String get primaryAuthor => sources.isNotEmpty ? sources.first.author : '';
String get primaryWork => sources.isNotEmpty ? sources.first.work : '';
String get primaryExcerpt => sources.isNotEmpty ? sources.first.excerpt : '';
factory ExternalSymbol.fromMap(Map<String, dynamic> map) {
final rawSources = map['sources'] as List<dynamic>? ?? [];
final rawIlls = map['illustrations'] as List<dynamic>? ?? [];
return ExternalSymbol(
term: map['term'] as String? ?? '',
normalized: map['normalized'] as String? ?? '',
categoryGuess: map['category_guess'] as String? ?? 'Внешний символ',
sources: rawSources
.map((s) => ExternalSymbolSource.fromMap(Map<String, dynamic>.from(s as Map)))
.toList(),
illustrations: rawIlls
.map((i) => Map<String, dynamic>.from(i as Map))
.toList(),
);
}
Map<String, dynamic> toMap() => {
'term': term,
'normalized': normalized,
'category_guess': categoryGuess,
'sources': sources.map((s) => s.toMap()).toList(),
'illustrations': illustrations,
};
}
+1 -1
View File
@@ -43,7 +43,7 @@ class SessionWord {
sessionNumber: item.sessionNumber, sessionNumber: item.sessionNumber,
partOfSpeech: item.partOfSpeech, partOfSpeech: item.partOfSpeech,
category: item.category, category: item.category,
symbolicMeaning: item.description, symbolicMeaning: item.displayMeaning,
positionTitle: positionTitle, positionTitle: positionTitle,
); );
} }
+181
View File
@@ -0,0 +1,181 @@
import 'package:flutter/material.dart';
import 'word_model.dart';
/// One of the 10 Archons that rules over a triad of 3 columns (e.g. cols 0..2, 3..5, etc.).
class ArchonSector {
final int index; // 0..9
final WordItem archonWord;
final int startCol;
final int endCol;
final Color sectorColor;
final String title;
Color get color => sectorColor;
const ArchonSector({
required this.index,
required this.archonWord,
required this.startCol,
required this.endCol,
required this.sectorColor,
required this.title,
});
}
/// A cell representing a House in the 33x30 field.
class HouseCell {
final int row; // 0..32
final int col; // 0..29
final int houseIndex; // 0..989 (position in grid)
final WordItem houseWord; // The Host / Spirit of this House
final int sectorIndex; // 0..9
// Assigned during divination
WordItem? guestWord; // The Guest symbol that enters this house
int? selectionOrder; // 1..9 (order in which the user selected this house)
HouseCell({
required this.row,
required this.col,
required this.houseIndex,
required this.houseWord,
required this.sectorIndex,
this.guestWord,
this.selectionOrder,
});
bool get isSelected => selectionOrder != null;
HouseCell copyWith({
WordItem? guestWord,
int? selectionOrder,
bool clearGuest = false,
bool clearOrder = false,
}) {
return HouseCell(
row: row,
col: col,
houseIndex: houseIndex,
houseWord: houseWord,
sectorIndex: sectorIndex,
guestWord: clearGuest ? null : (guestWord ?? this.guestWord),
selectionOrder: clearOrder ? null : (selectionOrder ?? this.selectionOrder),
);
}
}
enum ChainEndType {
loop, // Петля судьбы: энергия замкнулась на дом из цепочки
archonThrone, // Престол Архонта: цепь увенчалась верховным Архонтом
fieldVector, // Длинный вектор: открытый импульс сквозь матрицу
}
/// A step in a House Chain: House A -> Guest B -> House of B...
class HouseChainStep {
final HouseCell house;
final WordItem guest;
final bool isLoopClosing;
final bool leadsToArchon;
final bool computed;
final ArchonSector? destinationArchon;
const HouseChainStep({
required this.house,
required this.guest,
this.isLoopClosing = false,
this.leadsToArchon = false,
this.computed = false,
this.destinationArchon,
});
}
/// A full trajectory of energy moving between houses across the 990 field.
class HouseChain {
final int chainIndex;
final HouseCell startingHouse;
final List<HouseChainStep> steps;
final ChainEndType endType;
final String description;
const HouseChain({
required this.chainIndex,
required this.startingHouse,
required this.steps,
required this.endType,
required this.description,
});
bool get hasLoop => endType == ChainEndType.loop;
String get endTypeTitle {
switch (endType) {
case ChainEndType.loop:
return 'Петля Судьбы';
case ChainEndType.archonThrone:
return 'Престол Архонта';
case ChainEndType.fieldVector:
return 'Вектор в поле';
}
}
Color get statusColor {
switch (endType) {
case ChainEndType.loop:
return const Color(0xFFE57373);
case ChainEndType.archonThrone:
return const Color(0xFFFFD54F);
case ChainEndType.fieldVector:
return const Color(0xFF64B5F6);
}
}
}
/// The entire state of a Houses divination session.
class HousesSession {
final String id;
final DateTime createdAt;
final String? question;
final WordItem? querentWord; // Карта Кверента (субъект / объект гадания)
final int? querentHouseIndex; // Индекс дома кверента на поле 33x30 (если есть)
final List<ArchonSector> sectors; // 10 Archons
final List<HouseCell> grid; // 990 House cells (33 rows x 30 cols)
final List<HouseCell> selectedHouses; // 1..9 houses in order of selection
final List<HouseChain> chains; // Calculated house chains
final String? aiInterpretation;
const HousesSession({
required this.id,
required this.createdAt,
this.question,
this.querentWord,
this.querentHouseIndex,
required this.sectors,
required this.grid,
required this.selectedHouses,
required this.chains,
this.aiInterpretation,
});
HousesSession copyWith({
String? question,
WordItem? querentWord,
int? querentHouseIndex,
String? aiInterpretation,
List<HouseCell>? selectedHouses,
List<HouseChain>? chains,
}) {
return HousesSession(
id: id,
createdAt: createdAt,
question: question ?? this.question,
querentWord: querentWord ?? this.querentWord,
querentHouseIndex: querentHouseIndex ?? this.querentHouseIndex,
sectors: sectors,
grid: grid,
selectedHouses: selectedHouses ?? this.selectedHouses,
chains: chains ?? this.chains,
aiInterpretation: aiInterpretation ?? this.aiInterpretation,
);
}
}
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
class InterpreterPersona {
final String id;
final String title;
final String subtitle;
final IconData icon;
final String systemPrompt;
final String userTask;
final String chatGreeting;
const InterpreterPersona({
required this.id,
required this.title,
required this.subtitle,
required this.icon,
required this.systemPrompt,
required this.userTask,
required this.chatGreeting,
});
static const String defaultId = 'clear';
static const InterpreterPersona clear = InterpreterPersona(
id: 'clear',
title: 'Ясный оракул',
subtitle: 'Прямо, без тумана: ситуация, слепая зона, один шаг',
icon: Icons.visibility_outlined,
systemPrompt: '''Ты — толкователь жребия «Оракул». Говори по-русски спокойно и ясно.
Не мистифицируй. Не прячься за «тайной», «завесой», «голосом вечности» и общими фразами про путь.
Твоя работа — прочитать выпавшие знаки как карту нынешнего положения и сказать, что с этим делать.
Правила:
- Назови каждый знак и привяжи его к вопросу (или к ситуации, если вопроса нет).
- Сначала как есть, потом слепая зона, потом один конкретный шаг на ближайшее время.
- Метафора допустима, только если она короче и яснее прямого объяснения.
- 3–6 предложений. Без воды и без запрета на обычные слова.''',
userTask: '''Задача:
1. Свяжи знаки по порядку с вопросом — как развитие ситуации, не как эссе о символах.
2. Скажи прямо, в чём конфликт / ресурс / слепая зона.
3. Дай один практический шаг.
3–6 предложений. Без клише «тайна откроется», «вселенная шепчет».''',
chatGreeting:
'Я толкователь жребия. Говори вопрос как есть — разберём знаки без тумана.',
);
static const InterpreterPersona orc = InterpreterPersona(
id: 'orc',
title: 'Орк-шаман',
subtitle: 'Вааагх в черепе: видения Горка и Морка, куда бить',
icon: Icons.flash_on_rounded,
systemPrompt: '''Ты — орк-шаман (weirdboy) из вархаммерского Вааагх. В черепе гудит зелёная сила: знаки бьют как разряды варпа, а не как «мудрые советы старшего».
Говори по-русски грубо, жарко, с рыком — но читаемо. Можно «Вааагх», «Горк», «Морк», «варп», «зелёный огонь», «мальчки», «круши», «не ссы». Не превращай текст в нечитаемую кашу из сленга.
Ты видишь вопрос как поле боя в варпе: где сила Вааагх копится, где она рвёт череп, где враг, где трусость прикидывается хитростью.
Правила:
- Назови каждый знак как видение, врезавшееся в башку, и привяжи к вопросу.
- Скажи, кого/что надо крушить, от чего отойти, где Горк (прямо бить) и где Морк (ударить сбоку).
- Закончить одним приказом-шагом. 3–6 предложений. Не лей эльфийский туман.''',
userTask: '''Задача:
1. Знаки — вспышки Вааагх по порядку, не следы обычной охоты.
2. Где сила прёт, где череп сейчас разорвёт от сомнений.
3. Один приказ: бить / ждать засады / бросить мёртвый груз.
Голос орка-шамана. 3–6 предложений.''',
chatGreeting:
'Вааагх в башке уже орёт. Скажи, что гложет — знаки ударят сами, Горк с Морком разберутся.',
);
static const InterpreterPersona techpriest = InterpreterPersona(
id: 'techpriest',
title: 'Техножрец',
subtitle: 'Судьба как система: телеметрия знаков, сбой, протокол',
icon: Icons.precision_manufacturing_outlined,
systemPrompt: '''Ты — техножрец культа Жребия. Судьба — система. Знаки — телеметрия. Вопрошающий — оператор со сбоем.
Говори по-русски плотно и точно: диагностика, причина, предписанный протокол. Слова вроде «контур», «сигнал», «калибровка», «дух машины» допустимы, только если они что-то объясняют, а не украшают пустоту.
Правила:
- Разбор: запрос → показания знаков → неисправность → один ритуал/шаг.
- Не лей лирический туман. Если знак шумит — так и скажи.
- 3–6 предложений.''',
userTask: '''Задача:
1. Считай знаки датчиками. Разбери показания по порядку.
2. Назови сбой системы (конфликт, перегруз, обрыв связи).
3. Выдай один протокол действия на ближайший цикл.
Голос техножреца. 3–6 предложений.''',
chatGreeting:
'Оператор принят. Сформулируй запрос. Знаки будут прочитаны как телеметрия, не как поэзия.',
);
static const InterpreterPersona analyst = InterpreterPersona(
id: 'analyst',
title: 'Психоаналитик',
subtitle: 'Знаки как проекция: желание, защита, что делать с этим',
icon: Icons.psychology_alt_outlined,
systemPrompt: '''Ты — психоаналитик, читающий жребий как проективный тест. Знаки — образы бессознательного, не прогноз лотереи.
Говори по-русски спокойно и точно. Жаргон («тень», «вытеснение», «перенос») — только если он прямо виден в знаках.
Правила:
- Свяжи знаки с вопросом как с конфликтом желания и защиты.
- Назови гипотезу: чего человек избегает и чего хочет.
- Закончи одним конкретным шагом или наблюдением, которое можно проверить в жизни.
- Не гадай будущее. 3–6 предложений.''',
userTask: '''Задача:
1. Прочитай знаки как сцену психики, связанную с вопросом.
2. Назови конфликт: желание vs защита / стыд / контроль.
3. Дай один проверяемый шаг (разговор, граница, отказ, пауза) — не «подумайте о чувствах вообще».
Голос аналитика. 3–6 предложений.''',
chatGreeting:
'Говорите свободно. Знаки я читаю как проекцию: не судьбу снаружи, а узел внутри.',
);
static const InterpreterPersona aeldari = InterpreterPersona(
id: 'aeldari',
title: 'Всевидящий аэльдари',
subtitle: 'Нити судеб, руны провидца, одна тропа из тысячи',
icon: Icons.blur_on_rounded,
systemPrompt: '''Ты — всевидящий аэльдари, провидец (farseer) мира-корабля. Голос холодный, точный, без жалости к суете. Можно «нити», «тропа», «краткоживущий» — только как интонация.
Костюм не источник фактов. Слова жребия — не руны эльдаров и не «позиции рун», пока в законе знака нет руны. Не выдумывай закрытую тропу, круг близких, утробу и события, которых нет в переданных законах.
Если у знака канон пуст — скажи это и прочитай само слово. Не заполняй пустоту мифом мира-корабля.
Правила:
- Назови каждый знак и его закон, затем свяжи законы между собой и с вопросом.
- Покажи только ту развилку, которая следует из законов.
- Дай один конкретный шаг из этих законов. 4–7 предложений. Без оркского рёва и техножреческого жаргона.''',
userTask: '''Задача:
1. Тем же холодным голосом назови каждый знак и его закон по порядку. Не подменяй слово руной.
2. Покажи, как законы знаков спорят или складываются. Не объявляй тропу закрытой, если знаки этого не говорят.
3. Один конкретный шаг из законов знаков, не совет «будьте мудры вообще».
Голос всевидящего аэльдари. 4–7 предложений.''',
chatGreeting:
'Нити уже дрожат. Скажи вопрос — прочту руны, а не утешу.',
);
static const List<InterpreterPersona> all = [
clear,
orc,
techpriest,
aeldari,
analyst,
];
static InterpreterPersona byId(String? id) {
return all.firstWhere(
(p) => p.id == id,
orElse: () => clear,
);
}
}
+163
View File
@@ -0,0 +1,163 @@
class LenormandReading {
final String source;
final String label;
final String text;
final int? left;
final int? right;
const LenormandReading({
required this.source,
required this.label,
required this.text,
this.left,
this.right,
});
factory LenormandReading.fromMap(Map<String, dynamic> map) {
return LenormandReading(
source: map['source'] as String? ?? '',
label: map['label'] as String? ?? '',
text: map['text'] as String? ?? '',
left: (map['left'] as num?)?.toInt(),
right: (map['right'] as num?)?.toInt(),
);
}
}
class LenormandPairLink {
final int n;
final String id;
final String name;
final String symbol;
final String preview;
final String text;
final List<LenormandReading> readings;
const LenormandPairLink({
required this.n,
required this.id,
required this.name,
required this.symbol,
required this.preview,
this.text = '',
required this.readings,
});
String get displayText {
if (text.trim().isNotEmpty) return text.trim();
if (preview.trim().isNotEmpty) return preview.trim();
return readings.map((r) => r.text).where((t) => t.trim().isNotEmpty).join('\n\n');
}
factory LenormandPairLink.fromMap(Map<String, dynamic> map) {
final raw = map['readings'];
return LenormandPairLink(
n: (map['n'] as num?)?.toInt() ?? 0,
id: map['id'] as String? ?? '',
name: map['name'] as String? ?? '',
symbol: map['symbol'] as String? ?? '',
preview: map['preview'] as String? ?? '',
text: map['text'] as String? ?? '',
readings: raw is List
? raw
.whereType<Map<String, dynamic>>()
.map(LenormandReading.fromMap)
.toList()
: const [],
);
}
}
class LenormandCardEntry {
final int n;
final String id;
final String name;
final String symbol;
final String lore;
final String group;
final String meaning;
final String shadow;
final List<LenormandReading> meanings;
final List<LenormandPairLink> pairs;
const LenormandCardEntry({
required this.n,
required this.id,
required this.name,
required this.symbol,
required this.lore,
required this.group,
this.meaning = '',
this.shadow = '',
required this.meanings,
required this.pairs,
});
String get displayMeaning {
if (meaning.trim().isNotEmpty) return meaning.trim();
return meanings.map((m) => m.text).where((t) => t.trim().isNotEmpty).join('\n\n');
}
factory LenormandCardEntry.fromMap(Map<String, dynamic> map) {
return LenormandCardEntry(
n: (map['n'] as num?)?.toInt() ?? 0,
id: map['id'] as String? ?? '',
name: map['name'] as String? ?? '',
symbol: map['symbol'] as String? ?? '',
lore: map['lore'] as String? ?? '',
group: map['group'] as String? ?? '',
meaning: map['meaning'] as String? ?? '',
shadow: map['shadow'] as String? ?? '',
meanings: (map['meanings'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(LenormandReading.fromMap)
.toList(),
pairs: (map['pairs'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(LenormandPairLink.fromMap)
.toList(),
);
}
}
class LenormandTriad {
final List<int> cards;
final List<String> names;
final String text;
const LenormandTriad({
required this.cards,
required this.names,
required this.text,
});
factory LenormandTriad.fromMap(Map<String, dynamic> map) {
return LenormandTriad(
cards: (map['cards'] as List? ?? const [])
.map((e) => (e as num).toInt())
.toList(),
names: (map['names'] as List? ?? const []).map((e) => '$e').toList(),
text: map['text'] as String? ?? '',
);
}
}
class LenormandExtra {
final String source;
final String title;
final String text;
const LenormandExtra({
required this.source,
required this.title,
required this.text,
});
factory LenormandExtra.fromMap(Map<String, dynamic> map) {
return LenormandExtra(
source: map['source'] as String? ?? '',
title: map['title'] as String? ?? '',
text: map['text'] as String? ?? '',
);
}
}
+172
View File
@@ -0,0 +1,172 @@
import 'dart:convert';
import 'word_model.dart';
/// Single item in a divination reading (e.g. a drawn Rune, Card, Hexagram, or Sign)
class ReadingSignItem {
final String title;
final String? subtitle;
final String? symbol;
final String? lore;
final bool isReversed;
final WordItem? linkedWord;
final Map<String, dynamic>? meta;
const ReadingSignItem({
required this.title,
this.subtitle,
this.symbol,
this.lore,
this.isReversed = false,
this.linkedWord,
this.meta,
});
Map<String, dynamic> toMap() => {
'title': title,
if (subtitle != null) 'subtitle': subtitle,
if (symbol != null) 'symbol': symbol,
if (lore != null) 'lore': lore,
'isReversed': isReversed,
if (linkedWord != null) 'linkedWord': linkedWord!.toMap(),
if (meta != null) 'meta': meta,
};
factory ReadingSignItem.fromMap(Map<String, dynamic> map) => ReadingSignItem(
title: map['title'] as String? ?? '',
subtitle: map['subtitle'] as String?,
symbol: map['symbol'] as String?,
lore: map['lore'] as String?,
isReversed: map['isReversed'] as bool? ?? false,
linkedWord: map['linkedWord'] != null
? WordItem.fromMap(map['linkedWord'] as Map<String, dynamic>)
: null,
meta: map['meta'] as Map<String, dynamic>?,
);
}
/// A single completed divination reading inside a day session
class SyncreticReading {
final String id;
final String systemId; // 'cleromancy', 'matrix', 'runes', 'lenormand', 'tarot', 'iching', 'ogham', 'geomancy'
final String systemName;
final String systemIcon;
final DateTime timestamp;
final String layoutTitle; // e.g. 'Бросок 2d12', 'Ленорман d6+d12', 'Триптих Норн'
final String inputMethod; // 'dice' | 'manual'
final List<ReadingSignItem> items;
final String? note;
final String? question;
const SyncreticReading({
required this.id,
required this.systemId,
required this.systemName,
required this.systemIcon,
required this.timestamp,
required this.layoutTitle,
this.inputMethod = 'dice',
required this.items,
this.note,
this.question,
});
String get formattedTime {
final hour = timestamp.hour.toString().padLeft(2, '0');
final min = timestamp.minute.toString().padLeft(2, '0');
return '$hour:$min';
}
Map<String, dynamic> toMap() => {
'id': id,
'systemId': systemId,
'systemName': systemName,
'systemIcon': systemIcon,
'timestamp': timestamp.toIso8601String(),
'layoutTitle': layoutTitle,
'inputMethod': inputMethod,
'items': items.map((i) => i.toMap()).toList(),
if (note != null) 'note': note,
if (question != null) 'question': question,
};
factory SyncreticReading.fromMap(Map<String, dynamic> map) => SyncreticReading(
id: map['id'] as String? ?? '',
systemId: map['systemId'] as String? ?? '',
systemName: map['systemName'] as String? ?? '',
systemIcon: map['systemIcon'] as String? ?? '🔮',
timestamp: DateTime.tryParse(map['timestamp'] as String? ?? '') ?? DateTime.now(),
layoutTitle: map['layoutTitle'] as String? ?? '',
inputMethod: map['inputMethod'] as String? ?? 'dice',
items: (map['items'] as List<dynamic>?)
?.map((i) => ReadingSignItem.fromMap(i as Map<String, dynamic>))
.toList() ??
[],
note: map['note'] as String?,
question: map['question'] as String?,
);
}
/// A day-long (or question-long) Syncretic Session accumulating multiple readings across systems
class SyncreticSession {
final String id;
final DateTime date;
final String? mainQuestion;
final List<SyncreticReading> readings;
final String? comparativeAiSynthesis;
const SyncreticSession({
required this.id,
required this.date,
this.mainQuestion,
this.readings = const [],
this.comparativeAiSynthesis,
});
String get dateKey =>
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
String get formattedDate {
final day = date.day.toString().padLeft(2, '0');
final month = date.month.toString().padLeft(2, '0');
final year = date.year.toString();
return '$day.$month.$year';
}
SyncreticSession copyWith({
String? id,
DateTime? date,
String? mainQuestion,
List<SyncreticReading>? readings,
String? comparativeAiSynthesis,
}) {
return SyncreticSession(
id: id ?? this.id,
date: date ?? this.date,
mainQuestion: mainQuestion ?? this.mainQuestion,
readings: readings ?? this.readings,
comparativeAiSynthesis: comparativeAiSynthesis ?? this.comparativeAiSynthesis,
);
}
Map<String, dynamic> toMap() => {
'id': id,
'date': date.toIso8601String(),
if (mainQuestion != null) 'mainQuestion': mainQuestion,
'readings': readings.map((r) => r.toMap()).toList(),
if (comparativeAiSynthesis != null) 'comparativeAiSynthesis': comparativeAiSynthesis,
};
factory SyncreticSession.fromMap(Map<String, dynamic> map) => SyncreticSession(
id: map['id'] as String? ?? '',
date: DateTime.tryParse(map['date'] as String? ?? '') ?? DateTime.now(),
mainQuestion: map['mainQuestion'] as String?,
readings: (map['readings'] as List<dynamic>?)
?.map((r) => SyncreticReading.fromMap(r as Map<String, dynamic>))
.toList() ??
[],
comparativeAiSynthesis: map['comparativeAiSynthesis'] as String?,
);
String toJson() => jsonEncode(toMap());
factory SyncreticSession.fromJson(String str) => SyncreticSession.fromMap(jsonDecode(str));
}
+177
View File
@@ -0,0 +1,177 @@
class StationWord {
final int id;
final String word;
final String emoji;
final String pos;
final String category;
final String desc;
const StationWord({
required this.id,
required this.word,
this.emoji = '',
this.pos = '',
this.category = '',
this.desc = '',
});
factory StationWord.fromMap(Map<String, dynamic> map) {
return StationWord(
id: (map['id'] as num?)?.toInt() ?? 0,
word: map['word'] as String? ?? '',
emoji: map['emoji'] as String? ?? '',
pos: map['pos'] as String? ?? '',
category: map['category'] as String? ?? '',
desc: map['desc'] as String? ?? '',
);
}
}
class TraditionStation {
final String id;
final String name;
final String symbol;
final String group;
final String lore;
final String? hebrew;
final String? hanzi;
final String? arabic;
final String? arcanum;
final StationWord? primary;
final List<StationWord> cluster;
final List<Map<String, String>> parallels;
final List<int> dots;
const TraditionStation({
required this.id,
required this.name,
required this.symbol,
required this.group,
required this.lore,
this.hebrew,
this.hanzi,
this.arabic,
this.arcanum,
this.primary,
this.cluster = const [],
this.parallels = const [],
this.dots = const [],
});
/// Four geomantic rows (1 or 2 dots each), head → feet. Empty if not a figure.
List<int> get geomancyRows {
if (dots.length == 4 && dots.every((n) => n == 1 || n == 2)) return dots;
final lines = symbol
.split('\n')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
if (lines.length != 4) return const [];
final rows = <int>[];
for (final line in lines) {
final n = '·'.allMatches(line).length;
if (n != 1 && n != 2) return const [];
rows.add(n);
}
return rows;
}
String get groupKey {
final sep = group.indexOf(' · ');
if (sep <= 0) return group;
return group.substring(0, sep);
}
String get groupSub {
final sep = group.indexOf(' · ');
if (sep < 0) return '';
return group.substring(sep + 3);
}
factory TraditionStation.fromMap(Map<String, dynamic> map) {
final primaryMap = map['primary'];
final clusterRaw = map['cluster'];
return TraditionStation(
id: map['id'] as String? ?? '',
name: map['name'] as String? ?? '',
symbol: map['symbol'] as String? ?? '',
group: map['group'] as String? ?? '',
lore: map['lore'] as String? ?? '',
hebrew: map['hebrew'] as String?,
hanzi: map['hanzi'] as String?,
arabic: map['arabic'] as String?,
arcanum: map['arcanum'] as String?,
primary: primaryMap is Map<String, dynamic>
? StationWord.fromMap(primaryMap)
: null,
cluster: clusterRaw is List
? clusterRaw
.whereType<Map<String, dynamic>>()
.map(StationWord.fromMap)
.toList()
: const [],
dots: (map['dots'] as List? ?? const [])
.whereType<num>()
.map((n) => n.toInt())
.toList(),
parallels: (map['parallels'] as List? ?? const [])
.whereType<Map>()
.map((p) => {
'system': '${p['system'] ?? ''}',
'system_name': '${p['system_name'] ?? ''}',
'id': '${p['id'] ?? ''}',
'name': '${p['name'] ?? ''}',
'symbol': '${p['symbol'] ?? ''}',
})
.toList(),
);
}
bool matchesQuery(String query) {
if (query.isEmpty) return true;
final q = query.toLowerCase();
if (name.toLowerCase().contains(q)) return true;
if (lore.toLowerCase().contains(q)) return true;
if (symbol.contains(query)) return true;
if (hebrew != null && hebrew!.contains(query)) return true;
if (hanzi != null && hanzi!.contains(query)) return true;
if (arabic != null && arabic!.contains(query)) return true;
if (primary != null && primary!.word.toLowerCase().contains(q)) return true;
return cluster.any((c) => c.word.toLowerCase().contains(q));
}
}
class TraditionCanon {
final String id;
final String name;
final String icon;
final int count;
final String description;
final List<TraditionStation> stations;
const TraditionCanon({
required this.id,
required this.name,
required this.icon,
required this.count,
required this.description,
required this.stations,
});
factory TraditionCanon.fromMap(Map<String, dynamic> map) {
final raw = map['stations'];
return TraditionCanon(
id: map['id'] as String? ?? '',
name: map['name'] as String? ?? '',
icon: map['icon'] as String? ?? '',
count: (map['count'] as num?)?.toInt() ?? 0,
description: map['description'] as String? ?? '',
stations: raw is List
? raw
.whereType<Map<String, dynamic>>()
.map(TraditionStation.fromMap)
.toList()
: const [],
);
}
}
+556 -3
View File
@@ -1,3 +1,83 @@
import 'culture_branch.dart';
class SymbolLayer {
final String system;
final String title;
final String law;
const SymbolLayer({
required this.system,
required this.title,
required this.law,
});
factory SymbolLayer.fromMap(Map<String, dynamic> map) {
return SymbolLayer(
system: map['system'] as String? ?? '',
title: map['title'] as String? ?? '',
law: map['law'] as String? ?? '',
);
}
Map<String, dynamic> toMap() => {
'system': system,
'title': title,
'law': law,
};
}
class SymbolSource {
final String system;
final String ref;
final double weight;
const SymbolSource({
required this.system,
required this.ref,
required this.weight,
});
factory SymbolSource.fromMap(Map<String, dynamic> map) {
return SymbolSource(
system: map['system'] as String? ?? '',
ref: map['ref'] as String? ?? '',
weight: (map['weight'] as num?)?.toDouble() ?? 1.0,
);
}
Map<String, dynamic> toMap() => {
'system': system,
'ref': ref,
'weight': weight,
};
}
class SymbolGrammar {
final String operator;
final String valence;
final String modifies;
const SymbolGrammar({
this.operator = 'substance',
this.valence = 'liminal',
this.modifies = '',
});
factory SymbolGrammar.fromMap(Map<String, dynamic> map) {
return SymbolGrammar(
operator: map['operator'] as String? ?? 'substance',
valence: map['valence'] as String? ?? 'liminal',
modifies: map['modifies'] as String? ?? '',
);
}
Map<String, dynamic> toMap() => {
'operator': operator,
'valence': valence,
'modifies': modifies,
};
}
class WordItem { class WordItem {
final int id; // Base index 0..999 final int id; // Base index 0..999
final int sessionNumber; // Dynamic randomized session number 0..999 final int sessionNumber; // Dynamic randomized session number 0..999
@@ -6,6 +86,27 @@ class WordItem {
final double frequency; final double frequency;
final String category; final String category;
final String description; final String description;
final String emoji;
// Enriched Oracle Knowledge Base fields
final String family;
final String operator;
final String valence;
final List<String> jurisdictions;
final String lookup; // 'A', 'B', 'C'
final bool hasCanon;
final String core;
final String oracle;
final List<SymbolLayer> layers;
final String shadow;
final String asks;
final String step;
final List<String> binds;
final List<SymbolSource> sources;
final SymbolGrammar? grammar;
final bool isUserEdited;
final Map<String, String> cultures;
final Map<String, String> languages;
const WordItem({ const WordItem({
required this.id, required this.id,
@@ -15,6 +116,25 @@ class WordItem {
required this.frequency, required this.frequency,
this.category = '', this.category = '',
this.description = '', this.description = '',
this.emoji = '',
this.family = '',
this.operator = 'substance',
this.valence = 'liminal',
this.jurisdictions = const [],
this.lookup = 'C',
this.hasCanon = false,
this.core = '',
this.oracle = '',
this.layers = const [],
this.shadow = '',
this.asks = '',
this.step = '',
this.binds = const [],
this.sources = const [],
this.grammar,
this.isUserEdited = false,
this.cultures = const {},
this.languages = const {},
}); });
WordItem copyWith({ WordItem copyWith({
@@ -25,6 +145,25 @@ class WordItem {
double? frequency, double? frequency,
String? category, String? category,
String? description, String? description,
String? emoji,
String? family,
String? operator,
String? valence,
List<String>? jurisdictions,
String? lookup,
bool? hasCanon,
String? core,
String? oracle,
List<SymbolLayer>? layers,
String? shadow,
String? asks,
String? step,
List<String>? binds,
List<SymbolSource>? sources,
SymbolGrammar? grammar,
bool? isUserEdited,
Map<String, String>? cultures,
Map<String, String>? languages,
}) { }) {
return WordItem( return WordItem(
id: id ?? this.id, id: id ?? this.id,
@@ -34,9 +173,110 @@ class WordItem {
frequency: frequency ?? this.frequency, frequency: frequency ?? this.frequency,
category: category ?? this.category, category: category ?? this.category,
description: description ?? this.description, description: description ?? this.description,
emoji: emoji ?? this.emoji,
family: family ?? this.family,
operator: operator ?? this.operator,
valence: valence ?? this.valence,
jurisdictions: jurisdictions ?? this.jurisdictions,
lookup: lookup ?? this.lookup,
hasCanon: hasCanon ?? this.hasCanon,
core: core ?? this.core,
oracle: oracle ?? this.oracle,
layers: layers ?? this.layers,
shadow: shadow ?? this.shadow,
asks: asks ?? this.asks,
step: step ?? this.step,
binds: binds ?? this.binds,
sources: sources ?? this.sources,
grammar: grammar ?? this.grammar,
isUserEdited: isUserEdited ?? this.isUserEdited,
cultures: cultures ?? this.cultures,
languages: languages ?? this.languages,
); );
} }
/// Enrich existing WordItem with data from oracle_kb.json entry
WordItem enrichWithKb(Map<String, dynamic> kb) {
final rawLayers = kb['layers'] as List<dynamic>? ?? [];
final parsedLayers = rawLayers
.whereType<Map<String, dynamic>>()
.map((m) => SymbolLayer.fromMap(m))
.toList();
final rawSourcesList = kb['sources'] is List ? (kb['sources'] as List) : [];
final parsedSources = rawSourcesList
.whereType<Map<String, dynamic>>()
.map((m) => SymbolSource.fromMap(m))
.toList();
final rawJurisdictions = kb['jurisdictions'] is List
? (kb['jurisdictions'] as List).map((e) => e.toString()).toList()
: <String>[];
final rawBinds = kb['binds'] is List
? (kb['binds'] as List).map((e) => e.toString()).toList()
: (kb['binds'] is String ? [kb['binds'].toString()] : <String>[]);
final rawGrammar = kb['grammar'] as Map<String, dynamic>?;
final rawKbCultures = kb['cultures'] as Map<String, dynamic>?;
final parsedKbCultures = rawKbCultures != null
? rawKbCultures.map((k, v) => MapEntry(k.toString(), v.toString()))
: <String, String>{};
final rawKbLanguages = kb['languages'] as Map<String, dynamic>?;
final parsedKbLanguages = rawKbLanguages != null
? rawKbLanguages.map((k, v) => MapEntry(k.toString(), v.toString()))
: <String, String>{};
final kbCore = (kb['core'] as String? ?? '').trim();
final kbOracle = (kb['oracle'] as String? ?? '').trim();
final kbHasCanon = (kb['has_canon'] as bool? ?? false) || (kbCore.isNotEmpty && kbOracle.isNotEmpty);
return copyWith(
partOfSpeech: partOfSpeech.isNotEmpty ? partOfSpeech : ((kb['pos'] as String?) ?? partOfSpeech),
category: (kb['category'] as String?)?.isNotEmpty == true ? kb['category'] as String : category,
family: kb['family'] as String? ?? family,
operator: kb['operator'] as String? ?? operator,
valence: kb['valence'] as String? ?? valence,
jurisdictions: rawJurisdictions.isNotEmpty ? rawJurisdictions : jurisdictions,
lookup: kb['lookup'] as String? ?? lookup,
hasCanon: kbHasCanon,
core: kbCore,
oracle: kbOracle,
layers: parsedLayers,
shadow: kb['shadow'] as String? ?? shadow,
asks: kb['asks'] as String? ?? asks,
step: kb['step'] as String? ?? step,
binds: rawBinds,
sources: parsedSources,
emoji: (kb['emoji'] as String?)?.isNotEmpty == true ? kb['emoji'] as String : emoji,
grammar: rawGrammar != null ? SymbolGrammar.fromMap(rawGrammar) : grammar,
cultures: parsedKbCultures.isNotEmpty ? parsedKbCultures : cultures,
languages: parsedKbLanguages.isNotEmpty ? parsedKbLanguages : languages,
);
}
/// Returns description stripped of raw metadata bracket tags like [Таро: ...]
String get cleanDescription {
final clean = description.replaceAll(RegExp(r'\[[^\]]+\]\s*'), '').trim();
return clean.isNotEmpty ? clean : description;
}
/// Культурные ветви знака: явная редакция (cultures) приоритетна,
/// иначе — автоматическое разложение толкования по традициям.
Map<String, String> get resolvedCultures {
if (cultures.isNotEmpty) return cultures;
return extractCulturalBranches('$oracle\n\n$shadow\n\n$cleanDescription');
}
/// Primary poetic/canonical essence: core if available, otherwise clean description
String get displayMeaning {
if (core.isNotEmpty) return core;
if (cleanDescription.isNotEmpty) return cleanDescription;
return description;
}
String get formattedSessionNumber => sessionNumber.toString().padLeft(3, '0'); String get formattedSessionNumber => sessionNumber.toString().padLeft(3, '0');
String get formattedId => formattedSessionNumber; String get formattedId => formattedSessionNumber;
@@ -49,21 +289,334 @@ class WordItem {
'frequency': frequency, 'frequency': frequency,
'category': category, 'category': category,
'description': description, 'description': description,
'emoji': emoji,
'family': family,
'operator': operator,
'valence': valence,
'jurisdictions': jurisdictions,
'lookup': lookup,
'hasCanon': hasCanon,
'core': core,
'oracle': oracle,
'layers': layers.map((l) => l.toMap()).toList(),
'shadow': shadow,
'asks': asks,
'step': step,
'binds': binds,
'sources': sources.map((s) => s.toMap()).toList(),
'grammar': grammar?.toMap(),
'cultures': cultures,
'languages': languages,
}; };
} }
factory WordItem.fromMap(Map<String, dynamic> map) { factory WordItem.fromMap(Map<String, dynamic> map) {
final rawLayers = map['layers'] as List<dynamic>? ?? [];
final parsedLayers = rawLayers
.whereType<Map<String, dynamic>>()
.map((m) => SymbolLayer.fromMap(m))
.toList();
final rawSources = map['sources'] as List<dynamic>? ?? [];
final parsedSources = rawSources
.whereType<Map<String, dynamic>>()
.map((m) => SymbolSource.fromMap(m))
.toList();
final rawJurisdictions = (map['jurisdictions'] as List<dynamic>? ?? [])
.map((e) => e.toString())
.toList();
final rawBinds = (map['binds'] as List<dynamic>? ?? [])
.map((e) => e.toString())
.toList();
final rawGrammar = map['grammar'] as Map<String, dynamic>?;
final rawCultures = map['cultures'] as Map<String, dynamic>?;
final parsedCultures = rawCultures != null
? rawCultures.map((k, v) => MapEntry(k.toString(), v.toString()))
: <String, String>{};
final rawLanguages = map['languages'] as Map<String, dynamic>?;
final parsedLanguages = rawLanguages != null
? rawLanguages.map((k, v) => MapEntry(k.toString(), v.toString()))
: <String, String>{};
return WordItem( return WordItem(
id: map['id'] as int, id: map['id'] as int,
sessionNumber: map['sessionNumber'] as int? ?? (map['id'] as int), sessionNumber: map['sessionNumber'] as int? ?? (map['id'] as int),
word: map['word'] as String, word: map['word'] as String,
partOfSpeech: map['partOfSpeech'] as String, partOfSpeech: map['partOfSpeech'] as String? ?? '',
frequency: (map['frequency'] as num).toDouble(), frequency: (map['frequency'] as num?)?.toDouble() ?? 50.0,
category: map['category'] as String? ?? '', category: map['category'] as String? ?? '',
description: map['description'] as String? ?? '', description: map['description'] as String? ?? '',
emoji: map['emoji'] as String? ?? '',
family: map['family'] as String? ?? '',
operator: map['operator'] as String? ?? 'substance',
valence: map['valence'] as String? ?? 'liminal',
jurisdictions: rawJurisdictions,
lookup: map['lookup'] as String? ?? 'C',
hasCanon: map['hasCanon'] as bool? ?? false,
core: map['core'] as String? ?? '',
oracle: map['oracle'] as String? ?? '',
layers: parsedLayers,
shadow: map['shadow'] as String? ?? '',
asks: map['asks'] as String? ?? '',
step: map['step'] as String? ?? '',
binds: rawBinds,
sources: parsedSources,
grammar: rawGrammar != null ? SymbolGrammar.fromMap(rawGrammar) : null,
isUserEdited: map['isUserEdited'] as bool? ?? false,
cultures: parsedCultures,
languages: parsedLanguages,
); );
} }
@override @override
String toString() => '#$formattedSessionNumber: $word ($partOfSpeech)'; String toString() => '#: ()';
}
extension WordItemLocalization on WordItem {
String get localizedValence => localizeValence(valence);
String get localizedOperator => localizeOperator(operator);
String get localizedFamily => localizeFamily(family);
static String localizeValence(String v) {
switch (v.trim().toLowerCase()) {
case 'active':
return 'Активная';
case 'receptive':
return 'Восприимчивая';
case 'liminal':
return 'Лиминальная';
case 'stable':
return 'Устойчивая';
case 'cutting':
return 'Отсекающая';
case 'binding':
return 'Связующая';
case 'solar':
return 'Солярная';
case 'lunar':
return 'Лунарная';
case 'light':
return 'Светлая';
case 'dark':
return 'Теневая';
case 'neutral':
return 'Нейтральная';
case 'chthonic':
return 'Хтоническая';
default:
final s = v.trim();
if (s.isEmpty) return '';
return s[0].toUpperCase() + s.substring(1);
}
}
static String localizeOperator(String op) {
switch (op.trim().toLowerCase()) {
case 'substance':
return 'Субстанция';
case 'act':
return 'Действие';
case 'relation':
return 'Связь';
case 'shifter':
return 'Сдвиг';
case 'qualifier':
return 'Качество';
case 'filter':
return 'Фильтр';
case 'pronoun':
return 'Указатель';
case 'number':
return 'Число';
case 'nexus':
return 'Узел';
case 'vector':
return 'Вектор';
case 'catalyst':
return 'Катализатор';
default:
final s = op.trim();
if (s.isEmpty) return '';
return s[0].toUpperCase() + s.substring(1);
}
}
static String localizeFamily(String f) {
switch (f.trim().toLowerCase()) {
case 'totem':
return 'Тотем';
case 'sacred_symbol':
return 'Сакральный символ';
case 'sacred_place':
return 'Сакральное место';
case 'element':
return 'Стихия';
case 'cosmos':
return 'Космос';
case 'body':
return 'Тело';
case 'moral':
return 'Этика';
case 'quality':
return 'Качество';
case 'time_calendar':
return 'Календарь';
case 'time_cycle':
return 'Цикл времени';
case 'cult_object':
return 'Культ';
case 'person_archetype':
return 'Архетип';
case 'social':
return 'Социум';
case 'speech':
return 'Речь';
case 'circumstance':
return 'Обстоятельство';
case 'conjunction':
return 'Связка';
case 'preposition':
return 'Отношение';
case 'polarity':
return 'Полярность';
case 'verb_transform':
return 'Трансформация';
case 'particle':
return 'Частица';
case 'determiner':
return 'Определитель';
case 'adverb_index':
return 'Индекс';
case 'misc':
return 'Разное';
default:
final s = f.trim();
if (s.isEmpty) return '';
return s[0].toUpperCase() + s.substring(1);
}
}
static String localizeSystem(String sys) {
switch (sys.trim().toLowerCase()) {
case 'tarot':
return 'Таро';
case 'runes':
return 'Руны';
case 'lenormand':
return 'Ленорман';
case 'iching':
case 'i ching':
return 'И Цзин';
case 'astrology':
return 'Астрология';
case 'alchemy':
return 'Алхимия';
case 'kabbalah':
return 'Каббала';
case 'ogham':
return 'Огам';
case 'cosmology':
return 'Космология';
case 'slavic':
return 'Славянский канон';
case 'bestiary':
return 'Бестиарий';
case 'dream':
return 'Сновидения';
case 'geomancy':
return 'Геомантия';
default:
final s = sys.trim();
if (s.isEmpty) return '';
return s[0].toUpperCase() + s.substring(1);
}
}
static String cleanTagDetail(String det) {
var s = det.trim();
// Remove Latin transliterations like (Cancer / Вода / Луна) -> (Вода · Луна)
s = s.replaceAllMapped(RegExp(r'\([A-Za-z]+\s*[/·]\s*([^)]+)\)'), (m) => '(${m.group(1)})');
s = s.replaceAll(' / ', ' · ');
// Geomancy 12 Puer (Юноша) -> 12 Юноша
s = s.replaceAllMapped(RegExp(r'(\d+\s+)[A-Za-z\s_]+\(([А-Яа-яЁё\s·-]+)\)'), (m) => '${m.group(1)}${m.group(2)}');
// Alchemy Огонь (Ignis) -> Огонь
if (!RegExp(r'[ᚠ-ᛪ]').hasMatch(s)) {
s = s.replaceAllMapped(RegExp(r'([А-Яа-яЁё\s]+)\s*\([A-Za-z\s_]+\)'), (m) => m.group(1)!);
}
// Runes: (Kenaz / ᚲ) -> (ᚲ)
s = s.replaceAllMapped(RegExp(r'\([A-Za-z]+\s*[/·]\s*([ᚠ-ᛪ])\)'), (m) => '(${m.group(1)})');
// Clean leftover English in parens
s = s.replaceAll(RegExp(r'\([A-Za-z\s_]+\)'), '');
return s.trim();
}
static String localizeSourceRef(String system, String ref) {
final r = ref.trim().toLowerCase();
const map = <String, String>{
// Runes
'fehu': 'Феху (ᚠ)', 'uruz': 'Уруз (ᚢ)', 'thurisaz': 'Турисаз (ᚦ)', 'ansuz': 'Ансуз (ᚨ)',
'raidho': 'Райдо (ᚱ)', 'kenaz': 'Кеназ (ᚲ)', 'gebo': 'Гебо (ᚷ)', 'wunjo': 'Вуньо (ᚹ)',
'hagalaz': 'Хагалаз (ᚺ)', 'nauthiz': 'Наутиз (ᚾ)', 'isa': 'Иса (ᛁ)', 'jera': 'Йера (ᛃ)',
'eihwaz': 'Эйваз (ᛇ)', 'perthro': 'Перт (ᛈ)', 'algiz': 'Альгиз (ᛉ)', 'sowilo': 'Совило (ᛊ)',
'tiwaz': 'Тейваз (ᛏ)', 'berkano': 'Беркана (ᛒ)', 'ehwaz': 'Эваз (ᛖ)', 'mannaz': 'Манназ (ᛗ)',
'laguz': 'Лагуз (ᛚ)', 'ingwaz': 'Ингваз (ᛜ)', 'dagaz': 'Дагаз (ᛞ)', 'othala': 'Отала (ᛟ)',
// Tarot
'wands': 'Жезлы (Огонь)', 'pentacles': 'Пентакли (Земля)', 'cups': 'Кубки (Вода)', 'swords': 'Мечи (Воздух)',
'fool': 'Шут (0)', 'magician': 'Маг (I)', 'priestess': 'Жрица (II)', 'empress': 'Императрица (III)',
'emperor': 'Император (IV)', 'hierophant': 'Иерофант (V)', 'lovers': 'Влюблённые (VI)', 'chariot': 'Колесница (VII)',
'strength': 'Сила (VIII)', 'hermit': 'Отшельник (IX)', 'wheel': 'Колесо Фортуны (X)', 'justice': 'Справедливость (XI)',
'hanged': 'Повешенный (XII)', 'death': 'Смерть (XIII)', 'temperance': 'Умеренность (XIV)', 'devil': 'Дьявол (XV)',
'tower': 'Башня (XVI)', 'star': 'Звезда (XVII)', 'moon': 'Луна', 'sun': 'Солнце',
'judgement': 'Суд (XX)', 'world': 'Мир (XXI)',
// I Ching
'li': 'Ли ☲ (Огонь)', 'kan': 'Кань ☵ (Вода)', 'xun': 'Сюнь ☴ (Ветер)', 'kun': 'Кунь ☷ (Земля)',
'qian': 'Цянь ☰ (Небо)', 'zhen': 'Чжэнь ☳ (Гром)', 'gen': 'Гэнь ☶ (Гора)', 'dui': 'Дуй ☱ (Водоём)',
'kan_repeat': 'Кань ䷜ (Повторная опасность)', 'pi': 'Пи ䷋ (Упадок)', 'tai': 'Тай ䷊ (Расцвет)',
'gu': 'Гу ䷑ (Исправление)', 'fu': 'Фу ䷗ (Возврат)', 'meng': 'Мэн ䷃ (Юность)', 'ge': 'Гэ ䷰ (Смена)',
'wei_ji': 'Вэй-цзи ䷿ (Ещё не конец)',
// Alchemy
'ignis': 'Огонь', 'aqua': 'Вода', 'aer': 'Воздух', 'terra': 'Земля',
'sulfur': 'Сера (Душа)', 'mercury': 'Ртуть (Дух)', 'sal': 'Соль (Тело)',
'albedo': 'Альбедо (Белизна)', 'nigredo': 'Нигредо (Чернота)', 'rubedo': 'Рубедо (Краснота)',
'ash': 'Пепел', 'luna': 'Луна (Серебро)', 'egg': 'Философское Яйцо', 'vessel': 'Сосуд Гермеса', 'forge': 'Горн',
// Astrology
'comet': 'Комета', 'eclipse': 'Затмение', 'dawn': 'Рассвет', 'dusk': 'Закат',
'zenith': 'Зенит', 'nadir': 'Надир', 'constellation': 'Созвездие', 'planet': 'Планета', 'meteor': 'Метеор',
'equinox': 'Равноденствие', 'orbit': 'Орбита', 'libra': 'Весы', 'aries': 'Овен', 'taurus': 'Телец',
'gemini': 'Близнецы', 'cancer': 'Рак', 'leo': 'Лев', 'virgo': 'Дева', 'scorpio': 'Скорпион',
'sagittarius': 'Стрелец', 'capricorn': 'Козерог', 'aquarius': 'Водолей', 'pisces': 'Рыбы',
// Bestiary
'dog': 'Собака (Страж)', 'snake': 'Змея (Мудрость)', 'eagle': 'Орёл (Высота)', 'wolf': 'Волк (Чутьё)',
'raven': 'Ворон (Память)', 'lion': 'Лев (Царственность)', 'owl': 'Сова (Ночное зрение)',
'serpent_feather': 'Пернатый Змей', 'dragon': 'Дракон', 'phoenix': 'Феникс', 'bear': 'Медведь (Сила)',
'fox': 'Лиса (Хитрость)', 'stag': 'Олень (Благородство)', 'horse': 'Конь (Порыв)', 'spider': 'Паук (Нить судьбы)',
// Virtues & Archetypes
'courage': 'Мужество', 'charity': 'Милосердие', 'karma': 'Карма / Воздаяние', 'guilt': 'Вина',
'shame': 'Стыд', 'pride': 'Гордыня', 'humility': 'Смирение', 'envy': 'Зависть', 'vow': 'Обет',
'greed': 'Алчность', 'gluttony': 'Чревоугодие', 'sloth': 'Уныние', 'wrath': 'Гнев', 'lust': 'Страсть',
'donor': 'Даритель', 'dispatcher': 'Отправитель', 'shadow': 'Тень', 'hero': 'Герой', 'self': 'Самость',
'helper': 'Помощник', 'witch': 'Ведунья', 'princess': 'Царевна', 'exile': 'Изгнанник',
'false_hero': 'Ложный герой', 'judge': 'Судия', 'trickster': 'Трикстер',
// Slavic
'mesyats': 'Месяц ясный', 'oak': 'Священный Дуб', 'willow': 'Плакучая Ива', 'birch': 'Берёза',
'ivy_field': 'Чисто поле', 'crossroads': 'Перепутье', 'threshold': 'Порог', 'well': 'Студёный колодец', 'hearth': 'Родной очаг',
// Ogham
'dair': 'Дуб (Даир / ᚇ)', 'sail': 'Ива (Салье / ᚄ)', 'beith': 'Берёза (Бэйт / ᚁ)', 'gort': 'Плющ (Горт / ᚌ)',
// Symbols & Dreams
'fire': 'Огонь', 'water': 'Вода', 'air': 'Воздух', 'earth': 'Земля',
'key': 'Ключ от врат', 'spark': 'Живая искра', 'path': 'Тропа / Путь', 'book': 'Книга судеб',
'blood': 'Кровь / Род', 'heart': 'Сердце',
'fire_d': 'Сон об огне', 'water_d': 'Сон о глубине', 'death_d': 'Сон о переходе', 'key_d': 'Сон о тайне',
'path_d': 'Сон о странствии', 'child_d': 'Сон о дитяти', 'mirror_d': 'Сон о зеркале', 'wolf_d': 'Сон о звере',
'dream_fire': 'Сон об огне',
};
if (map.containsKey(r)) return map[r]!;
final s = ref.trim().replaceAll('_', ' ');
if (s.isEmpty) return '';
return s[0].toUpperCase() + s.substring(1);
}
} }
+157
View File
@@ -0,0 +1,157 @@
import 'dart:convert';
/// Represents the daily record for a specific calendar date in the Word of the Day practice.
/// Supports both Single Word Mode (1 resonant archetype) and Triptych Mode (Morning, Day, Evening).
class WordOfDayRecord {
final String dateKey; // 'yyyy-MM-dd'
final int wordId; // base id for Single Word Mode
final bool isRevealed; // whether Single Word is revealed
final bool isStepCompleted; // whether Single Word step is checked off
final String diaryNote; // user reflection on daily synchronicity
final DateTime? revealedAt;
final DateTime? completedAt;
// Triptych fields (3 words: Morning, Day, Evening)
final bool isTriptychMode;
final int morningWordId;
final int dayWordId;
final int eveningWordId;
final bool isMorningRevealed;
final bool isDayRevealed;
final bool isEveningRevealed;
final bool isDayStepCompleted;
final DateTime? morningRevealedAt;
final DateTime? dayRevealedAt;
final DateTime? eveningRevealedAt;
const WordOfDayRecord({
required this.dateKey,
required this.wordId,
this.isRevealed = false,
this.isStepCompleted = false,
this.diaryNote = '',
this.revealedAt,
this.completedAt,
this.isTriptychMode = false,
this.morningWordId = 0,
this.dayWordId = 0,
this.eveningWordId = 0,
this.isMorningRevealed = false,
this.isDayRevealed = false,
this.isEveningRevealed = false,
this.isDayStepCompleted = false,
this.morningRevealedAt,
this.dayRevealedAt,
this.eveningRevealedAt,
});
bool get isAnyRevealed =>
isRevealed || isMorningRevealed || isDayRevealed || isEveningRevealed;
bool get areAllTriptychRevealed =>
isMorningRevealed && isDayRevealed && isEveningRevealed;
bool get isAnyStepCompleted => isStepCompleted || isDayStepCompleted;
WordOfDayRecord copyWith({
String? dateKey,
int? wordId,
bool? isRevealed,
bool? isStepCompleted,
String? diaryNote,
DateTime? revealedAt,
DateTime? completedAt,
bool? isTriptychMode,
int? morningWordId,
int? dayWordId,
int? eveningWordId,
bool? isMorningRevealed,
bool? isDayRevealed,
bool? isEveningRevealed,
bool? isDayStepCompleted,
DateTime? morningRevealedAt,
DateTime? dayRevealedAt,
DateTime? eveningRevealedAt,
}) {
return WordOfDayRecord(
dateKey: dateKey ?? this.dateKey,
wordId: wordId ?? this.wordId,
isRevealed: isRevealed ?? this.isRevealed,
isStepCompleted: isStepCompleted ?? this.isStepCompleted,
diaryNote: diaryNote ?? this.diaryNote,
revealedAt: revealedAt ?? this.revealedAt,
completedAt: completedAt ?? this.completedAt,
isTriptychMode: isTriptychMode ?? this.isTriptychMode,
morningWordId: morningWordId ?? this.morningWordId,
dayWordId: dayWordId ?? this.dayWordId,
eveningWordId: eveningWordId ?? this.eveningWordId,
isMorningRevealed: isMorningRevealed ?? this.isMorningRevealed,
isDayRevealed: isDayRevealed ?? this.isDayRevealed,
isEveningRevealed: isEveningRevealed ?? this.isEveningRevealed,
isDayStepCompleted: isDayStepCompleted ?? this.isDayStepCompleted,
morningRevealedAt: morningRevealedAt ?? this.morningRevealedAt,
dayRevealedAt: dayRevealedAt ?? this.dayRevealedAt,
eveningRevealedAt: eveningRevealedAt ?? this.eveningRevealedAt,
);
}
Map<String, dynamic> toMap() => {
'dateKey': dateKey,
'wordId': wordId,
'isRevealed': isRevealed,
'isStepCompleted': isStepCompleted,
'diaryNote': diaryNote,
if (revealedAt != null) 'revealedAt': revealedAt!.toIso8601String(),
if (completedAt != null) 'completedAt': completedAt!.toIso8601String(),
'isTriptychMode': isTriptychMode,
'morningWordId': morningWordId,
'dayWordId': dayWordId,
'eveningWordId': eveningWordId,
'isMorningRevealed': isMorningRevealed,
'isDayRevealed': isDayRevealed,
'isEveningRevealed': isEveningRevealed,
'isDayStepCompleted': isDayStepCompleted,
if (morningRevealedAt != null)
'morningRevealedAt': morningRevealedAt!.toIso8601String(),
if (dayRevealedAt != null)
'dayRevealedAt': dayRevealedAt!.toIso8601String(),
if (eveningRevealedAt != null)
'eveningRevealedAt': eveningRevealedAt!.toIso8601String(),
};
factory WordOfDayRecord.fromMap(Map<String, dynamic> map) => WordOfDayRecord(
dateKey: map['dateKey'] as String? ?? '',
wordId: (map['wordId'] as num?)?.toInt() ?? 0,
isRevealed: map['isRevealed'] as bool? ?? false,
isStepCompleted: map['isStepCompleted'] as bool? ?? false,
diaryNote: map['diaryNote'] as String? ?? '',
revealedAt: map['revealedAt'] != null
? DateTime.tryParse(map['revealedAt'] as String)
: null,
completedAt: map['completedAt'] != null
? DateTime.tryParse(map['completedAt'] as String)
: null,
isTriptychMode: map['isTriptychMode'] as bool? ?? false,
morningWordId: (map['morningWordId'] as num?)?.toInt() ?? 0,
dayWordId: (map['dayWordId'] as num?)?.toInt() ?? 0,
eveningWordId: (map['eveningWordId'] as num?)?.toInt() ?? 0,
isMorningRevealed: map['isMorningRevealed'] as bool? ?? false,
isDayRevealed: map['isDayRevealed'] as bool? ?? false,
isEveningRevealed: map['isEveningRevealed'] as bool? ?? false,
isDayStepCompleted: map['isDayStepCompleted'] as bool? ?? false,
morningRevealedAt: map['morningRevealedAt'] != null
? DateTime.tryParse(map['morningRevealedAt'] as String)
: null,
dayRevealedAt: map['dayRevealedAt'] != null
? DateTime.tryParse(map['dayRevealedAt'] as String)
: null,
eveningRevealedAt: map['eveningRevealedAt'] != null
? DateTime.tryParse(map['eveningRevealedAt'] as String)
: null,
);
String toJson() => json.encode(toMap());
factory WordOfDayRecord.fromJson(String source) =>
WordOfDayRecord.fromMap(json.decode(source) as Map<String, dynamic>);
}
+429
View File
@@ -0,0 +1,429 @@
// word_passport_model.dart
// ЕДИНООБРАЗНЫЙ ПАСПОРТ ЗНАКА «ОРАКУЛА 999»
// 78 атомарных полей по 6 разделам канона
import '../services/passport_schema_dart.dart';
class WordPassport {
final int id;
final String word;
final String pos;
final String category;
final String emoji;
final Map<String, String> fields;
final int filledCount;
final bool isComplete;
final String updatedAt;
final List<Map<String, dynamic>> illustrations;
const WordPassport({
required this.id,
required this.word,
this.pos = '',
this.category = '',
this.emoji = '',
this.fields = const {},
this.filledCount = 0,
this.isComplete = false,
this.updatedAt = '',
this.illustrations = const [],
});
String get idPad => id.toString().padLeft(3, '0');
String getField(String key) => fields[key] ?? '';
/// [1/78] Раздел I • Индекс и имя
String get indexAndName => getField('index_and_name');
/// [2/78] Раздел I • Грамматический класс
String get grammaticalClass => getField('grammatical_class');
/// [3/78] Раздел I • Функциональная категория
String get functionalCategory => getField('functional_category');
/// [4/78] Раздел I • Сетевой статус
String get networkStatus => getField('network_status');
/// [5/78] Раздел II • Старший Футарк (Руны)
String get manticRunes => getField('mantic_runes');
/// [6/78] Раздел II • Кельтский Огам
String get manticOgham => getField('mantic_ogham');
/// [7/78] Раздел II • Старшие Арканы Таро
String get manticTarot => getField('mantic_tarot');
/// [8/78] Раздел II • Малая колода Ленорман
String get manticLenormand => getField('mantic_lenormand');
/// [9/78] Раздел II • Каббала / Древо Жизни
String get manticKabbalah => getField('mantic_kabbalah');
/// [10/78] Раздел II • Арабская Геомантия
String get manticGeomancy => getField('mantic_geomancy');
/// [11/78] Раздел II • И Цзин (Книга Перемен)
String get manticIching => getField('mantic_iching');
/// [12/78] Раздел II • Алхимия (Magnum Opus)
String get manticAlchemy => getField('mantic_alchemy');
/// [13/78] Раздел II • Классический Зодиак
String get manticZodiacWest => getField('mantic_zodiac_west');
/// [14/78] Раздел II • Китайский Зодиак
String get manticZodiacChinese => getField('mantic_zodiac_chinese');
/// [15/78] Раздел II • Септенер
String get manticSeptener => getField('mantic_septener');
/// [16/78] Раздел II • Додекада Месяцев
String get manticDodecade => getField('mantic_dodecade');
/// [17/78] Раздел II • Нумерология
String get manticNumerology => getField('mantic_numerology');
/// [18/78] Раздел II • Космология Трёх Миров
String get manticCosmology => getField('mantic_cosmology');
/// [19/78] Раздел II • 20 Навалей Цолькин (Майя)
String get manticTzolkin => getField('mantic_tzolkin');
/// [20/78] Раздел II • 27 Накшатр Джйотиш (Индия)
String get manticNakshatras => getField('mantic_nakshatras');
/// [21/78] Раздел II • Действующие законы станций (выжимка)
String get manticStationLaws => getField('mantic_station_laws');
/// [22/78] Раздел III • Этимология и праформы
String get etymologyRoots => getField('etymology_roots');
/// [23/78] Раздел III • Шумер / Аккад
String get scriptSumerAkkad => getField('script_sumer_akkad');
/// [24/78] Раздел III • Древний Египет
String get scriptEgypt => getField('script_egypt');
/// [25/78] Раздел III • Иудейская традиция
String get scriptHebrew => getField('script_hebrew');
/// [26/78] Раздел III • Античность
String get scriptAntiquity => getField('script_antiquity');
/// [27/78] Раздел III • Кельты
String get scriptCeltic => getField('script_celtic');
/// [28/78] Раздел III • Скандинавы / Германцы
String get scriptRunicGermanic => getField('script_runic_germanic');
/// [29/78] Раздел III • Славяне
String get scriptSlavic => getField('script_slavic');
/// [30/78] Раздел III • Ведическая Индия
String get scriptIndiaSanskrit => getField('script_india_sanskrit');
/// [31/78] Раздел III • Персия
String get scriptPersia => getField('script_persia');
/// [32/78] Раздел III • Китай
String get scriptChina => getField('script_china');
/// [33/78] Раздел III • Япония
String get scriptJapan => getField('script_japan');
/// [34/78] Раздел III • Исламский мир
String get scriptIslamic => getField('script_islamic');
/// [35/78] Раздел III • Западная Европа
String get scriptWesternEurope => getField('script_western_europe');
/// [36/78] Раздел III • Мезоамерика
String get scriptMesoamerica => getField('script_mesoamerica');
/// [37/78] Раздел III • Коренные народы Сев. Америки
String get scriptNativeAmerican => getField('script_native_american');
/// [38/78] Раздел III • Полинезия
String get scriptPolynesia => getField('script_polynesia');
/// [39/78] Раздел IV • 0 Нейтральный статус (База / Бытие / Среда / Субстанция)
String get stateNeutral => getField('state_neutral');
/// [40/78] Раздел IV • [+1] Созидательный вектор (Свет / Ресурс / Эволюция)
String get stateCreative => getField('state_creative');
/// [41/78] Раздел IV • [-1] Деструктивный вектор (Тень / Вызов / Искажение)
String get stateDestructive => getField('state_destructive');
/// [42/78] Раздел V • Шумер и Месопотамия: [+] Свет
String get cultureSumerLight => getField('culture_sumer_light');
/// [43/78] Раздел V • Шумер и Месопотамия: [-] Тень
String get cultureSumerShadow => getField('culture_sumer_shadow');
/// [44/78] Раздел V • Древний Египет: [+] Свет
String get cultureEgyptLight => getField('culture_egypt_light');
/// [45/78] Раздел V • Древний Египет: [-] Тень
String get cultureEgyptShadow => getField('culture_egypt_shadow');
/// [46/78] Раздел V • Иудейская традиция и Каббала: [+] Свет
String get cultureKabbalahLight => getField('culture_kabbalah_light');
/// [47/78] Раздел V • Иудейская традиция и Каббала: [-] Тень
String get cultureKabbalahShadow => getField('culture_kabbalah_shadow');
/// [48/78] Раздел V • Античность (Греция / Рим): [+] Свет
String get cultureAntiquityLight => getField('culture_antiquity_light');
/// [49/78] Раздел V • Античность (Греция / Рим): [-] Тень
String get cultureAntiquityShadow => getField('culture_antiquity_shadow');
/// [50/78] Раздел V • Кельтская традиция: [+] Свет
String get cultureCelticLight => getField('culture_celtic_light');
/// [51/78] Раздел V • Кельтская традиция: [-] Тень
String get cultureCelticShadow => getField('culture_celtic_shadow');
/// [52/78] Раздел V • Скандинавско-германская традиция: [+] Свет
String get cultureNordicLight => getField('culture_nordic_light');
/// [53/78] Раздел V • Скандинавско-германская традиция: [-] Тень
String get cultureNordicShadow => getField('culture_nordic_shadow');
/// [54/78] Раздел V • Славянская традиция: [+] Свет
String get cultureSlavicLight => getField('culture_slavic_light');
/// [55/78] Раздел V • Славянская традиция: [-] Тень
String get cultureSlavicShadow => getField('culture_slavic_shadow');
/// [56/78] Раздел V • Ведическая Индия и Тантра: [+] Свет
String get cultureIndiaLight => getField('culture_india_light');
/// [57/78] Раздел V • Ведическая Индия и Тантра: [-] Тень
String get cultureIndiaShadow => getField('culture_india_shadow');
/// [58/78] Раздел V • Персия и Зороастризм: [+] Свет
String get culturePersiaLight => getField('culture_persia_light');
/// [59/78] Раздел V • Персия и Зороастризм: [-] Тень
String get culturePersiaShadow => getField('culture_persia_shadow');
/// [60/78] Раздел V • Китай (Даосизм, И-цзин, У-син): [+] Свет
String get cultureChinaLight => getField('culture_china_light');
/// [61/78] Раздел V • Китай (Даосизм, И-цзин, У-син): [-] Тень
String get cultureChinaShadow => getField('culture_china_shadow');
/// [62/78] Раздел V • Япония (Синтоизм, Сингон): [+] Свет
String get cultureJapanLight => getField('culture_japan_light');
/// [63/78] Раздел V • Япония (Синтоизм, Сингон): [-] Тень
String get cultureJapanShadow => getField('culture_japan_shadow');
/// [64/78] Раздел V • Исламская традиция и суфизм: [+] Свет
String get cultureIslamLight => getField('culture_islam_light');
/// [65/78] Раздел V • Исламская традиция и суфизм: [-] Тень
String get cultureIslamShadow => getField('culture_islam_shadow');
/// [66/78] Раздел V • Западноевропейская традиция (Алхимия / Герметизм): [+] Свет
String get cultureHermeticLight => getField('culture_hermetic_light');
/// [67/78] Раздел V • Западноевропейская традиция (Алхимия / Герметизм): [-] Тень
String get cultureHermeticShadow => getField('culture_hermetic_shadow');
/// [68/78] Раздел V • Мезоамерика и Анды (Майя, Ацтеки, Инки): [+] Свет
String get cultureMesoamericaLight => getField('culture_mesoamerica_light');
/// [69/78] Раздел V • Мезоамерика и Анды (Майя, Ацтеки, Инки): [-] Тень
String get cultureMesoamericaShadow => getField('culture_mesoamerica_shadow');
/// [70/78] Раздел V • Традиции коренных народов Северной Америки: [+] Свет
String get cultureNativeAmericanLight => getField('culture_native_american_light');
/// [71/78] Раздел V • Традиции коренных народов Северной Америки: [-] Тень
String get cultureNativeAmericanShadow => getField('culture_native_american_shadow');
/// [72/78] Раздел V • Полинезия и Океания: [+] Свет
String get culturePolynesiaLight => getField('culture_polynesia_light');
/// [73/78] Раздел V • Полинезия и Океания: [-] Тень
String get culturePolynesiaShadow => getField('culture_polynesia_shadow');
/// [74/78] Раздел V • 17-й Интегральный мета-синтез (Универсальный культурологический код)
String get metaSynthesis => getField('meta_synthesis');
/// [75/78] Раздел VI • Смысловое ядро
String get semanticCore => getField('semantic_core');
/// [76/78] Раздел VI • Интерпретация по грамматическому типу
String get grammarInterpretation => getField('grammar_interpretation');
/// [77/78] Раздел VI • Вектор Воли (К чему призывает)
String get willVector => getField('will_vector');
/// [78/78] Раздел VI • Предостережение / Тень (О чем предупреждает)
String get shadowWarning => getField('shadow_warning');
factory WordPassport.fromMap(Map<String, dynamic> map) {
final rawFields = map['fields'] as Map<String, dynamic>? ?? {};
final fieldsMap = rawFields.map((k, v) => MapEntry(k, v?.toString() ?? ''));
// Count filled fields
var filled = 0;
for (final f in kPassportFields78) {
if ((fieldsMap[f.key] ?? '').trim().isNotEmpty) {
filled++;
}
}
return WordPassport(
id: (map['id'] as num?)?.toInt() ?? 0,
word: map['word'] as String? ?? '',
pos: map['pos'] as String? ?? '',
category: map['category'] as String? ?? '',
emoji: map['emoji'] as String? ?? '',
fields: fieldsMap,
filledCount: (map['filled_count'] as num?)?.toInt() ?? filled,
isComplete: (map['is_complete'] as bool?) ?? (filled >= 78),
updatedAt: map['updated_at'] as String? ?? '',
illustrations: (map['illustrations'] as List<dynamic>?)?.map((e) => Map<String, dynamic>.from(e as Map)).toList() ?? const [],
);
}
Map<String, dynamic> toMap() => {
'id': id,
'word': word,
'pos': pos,
'category': category,
'emoji': emoji,
'fields': fields,
'filled_count': filledCount,
'is_complete': isComplete,
'updated_at': updatedAt,
'illustrations': illustrations,
};
WordPassport copyWith({
int? id,
String? word,
String? pos,
String? category,
String? emoji,
Map<String, String>? fields,
int? filledCount,
bool? isComplete,
String? updatedAt,
List<Map<String, dynamic>>? illustrations,
}) {
final newFields = fields ?? this.fields;
var filled = 0;
for (final f in kPassportFields78) {
if ((newFields[f.key] ?? '').trim().isNotEmpty) {
filled++;
}
}
return WordPassport(
id: id ?? this.id,
word: word ?? this.word,
pos: pos ?? this.pos,
category: category ?? this.category,
emoji: emoji ?? this.emoji,
fields: newFields,
filledCount: filledCount ?? filled,
isComplete: isComplete ?? (filled >= 78),
updatedAt: updatedAt ?? this.updatedAt,
illustrations: illustrations ?? this.illustrations,
);
}
String toMarkdown() {
final sb = StringBuffer();
sb.writeln('═══════════════════════════════════════════════════════════════════');
sb.writeln('ЕДИНООБРАЗНЫЙ ПАСПОРТ ЗНАКА «ОРАКУЛА 999»: #$idPad «${word.toUpperCase()}»');
sb.writeln('═══════════════════════════════════════════════════════════════════');
sb.writeln('I. Заголовочный паспорт (Метаданные реестра)');
sb.writeln('Индекс и имя: ${getField("index_and_name").isEmpty ? "$idPad | ${word.toUpperCase()} | $emoji" : getField("index_and_name")}');
sb.writeln('Грамматический класс: ${getField("grammatical_class")}');
sb.writeln('Функциональная категория: ${getField("functional_category")}');
sb.writeln('Сетевой статус: ${getField("network_status")}');
sb.writeln('---');
sb.writeln('II. Кросс-мантическая сеть и станции (16 классических систем)');
sb.writeln('Привязанные узловые станции:');
sb.writeln('Старший Футарк (Руны): ${getField("mantic_runes")}');
sb.writeln('Кельтский Огам: ${getField("mantic_ogham")}');
sb.writeln('Старшие Арканы Таро: ${getField("mantic_tarot")}');
sb.writeln('Малая колода Ленорман: ${getField("mantic_lenormand")}');
sb.writeln('Каббала / Древо Жизни: ${getField("mantic_kabbalah")}');
sb.writeln("Арабская Геомантия ('Илм ар-Рамл): ${getField('mantic_geomancy')}");
sb.writeln('И Цзин (Книга Перемен): ${getField("mantic_iching")}');
sb.writeln('Алхимия (Magnum Opus): ${getField("mantic_alchemy")}');
sb.writeln('Классический Зодиак: ${getField("mantic_zodiac_west")}');
sb.writeln('Китайский Зодиак: ${getField("mantic_zodiac_chinese")}');
sb.writeln('Септенер: ${getField("mantic_septener")}');
sb.writeln('Додекада Месяцев: ${getField("mantic_dodecade")}');
sb.writeln('Нумерология: ${getField("mantic_numerology")}');
sb.writeln('Космология Трёх Миров: ${getField("mantic_cosmology")}');
sb.writeln('20 Навалей Цолькин (Майя): ${getField("mantic_tzolkin")}');
sb.writeln('27 Накшатр Джйотиш (Индия): ${getField("mantic_nakshatras")}');
sb.writeln('Действующие законы станций (выжимка):');
sb.writeln(getField("mantic_station_laws"));
sb.writeln('---');
sb.writeln('III. Мультилингвальный и графический паспорт (Язык, Звук, Глиф)');
sb.writeln('Этимология и праформы: ${getField("etymology_roots")}');
sb.writeln('Сравнительный срез графики и фоносемантики (16 систем записи):');
sb.writeln('Шумер / Аккад: ${getField("script_sumer_akkad")}');
sb.writeln('Древний Египет: ${getField("script_egypt")}');
sb.writeln('Иудейская традиция: ${getField("script_hebrew")}');
sb.writeln('Античность: ${getField("script_antiquity")}');
sb.writeln('Кельты: ${getField("script_celtic")}');
sb.writeln('Скандинавы / Германцы: ${getField("script_runic_germanic")}');
sb.writeln('Славяне: ${getField("script_slavic")}');
sb.writeln('Ведическая Индия: ${getField("script_india_sanskrit")}');
sb.writeln('Персия: ${getField("script_persia")}');
sb.writeln('Китай: ${getField("script_china")}');
sb.writeln('Япония: ${getField("script_japan")}');
sb.writeln('Исламский мир: ${getField("script_islamic")}');
sb.writeln('Западная Европа: ${getField("script_western_europe")}');
sb.writeln('Мезоамерика: ${getField("script_mesoamerica")}');
sb.writeln('Коренные народы Сев. Америки: ${getField("script_native_american")}');
sb.writeln('Полинезия: ${getField("script_polynesia")}');
sb.writeln('---');
sb.writeln('IV. Трехполярная матрица состояний (0 / +1 / -1)');
sb.writeln('0 Нейтральный статус (База / Бытие / Среда / Субстанция): ${getField("state_neutral")}');
sb.writeln('[+1] Созидательный вектор (Свет / Ресурс / Эволюция): ${getField("state_creative")}');
sb.writeln('[-1] Деструктивный вектор (Тень / Вызов / Искажение): ${getField("state_destructive")}');
sb.writeln('---');
sb.writeln('V. Культурологическая матрица и 17-й Интегральный мета-синтез');
sb.writeln('А. 16 Мировых Традиций (Срез «Свет / Тень»):');
sb.writeln('Шумер и Месопотамия: [+] ${getField("culture_sumer_light")} / [-] ${getField("culture_sumer_shadow")}');
sb.writeln('Древний Египет: [+] ${getField("culture_egypt_light")} / [-] ${getField("culture_egypt_shadow")}');
sb.writeln('Иудейская традиция и Каббала: [+] ${getField("culture_kabbalah_light")} / [-] ${getField("culture_kabbalah_shadow")}');
sb.writeln('Античность (Греция / Рим): [+] ${getField("culture_antiquity_light")} / [-] ${getField("culture_antiquity_shadow")}');
sb.writeln('Кельтская традиция: [+] ${getField("culture_celtic_light")} / [-] ${getField("culture_celtic_shadow")}');
sb.writeln('Скандинавско-германская традиция: [+] ${getField("culture_nordic_light")} / [-] ${getField("culture_nordic_shadow")}');
sb.writeln('Славянская традиция: [+] ${getField("culture_slavic_light")} / [-] ${getField("culture_slavic_shadow")}');
sb.writeln('Ведическая Индия и Тантра: [+] ${getField("culture_india_light")} / [-] ${getField("culture_india_shadow")}');
sb.writeln('Персия и Зороастризм: [+] ${getField("culture_persia_light")} / [-] ${getField("culture_persia_shadow")}');
sb.writeln('Китай (Даосизм, И-цзин, У-син): [+] ${getField("culture_china_light")} / [-] ${getField("culture_china_shadow")}');
sb.writeln('Япония (Синтоизм, Сингон): [+] ${getField("culture_japan_light")} / [-] ${getField("culture_japan_shadow")}');
sb.writeln('Исламская традиция и суфизм: [+] ${getField("culture_islam_light")} / [-] ${getField("culture_islam_shadow")}');
sb.writeln('Западноевропейская традиция (Алхимия / Герметизм): [+] ${getField("culture_hermetic_light")} / [-] ${getField("culture_hermetic_shadow")}');
sb.writeln('Мезоамерика и Анды (Майя, Ацтеки, Инки): [+] ${getField("culture_mesoamerica_light")} / [-] ${getField("culture_mesoamerica_shadow")}');
sb.writeln('Традиции коренных народов Северной Америки: [+] ${getField("culture_native_american_light")} / [-] ${getField("culture_native_american_shadow")}');
sb.writeln('Полинезия и Океания: [+] ${getField("culture_polynesia_light")} / [-] ${getField("culture_polynesia_shadow")}');
sb.writeln('Б. 17-й Интегральный мета-синтез (Универсальный культурологический код):');
sb.writeln(getField("meta_synthesis"));
sb.writeln('---');
sb.writeln('VI. Мантический & Обучающий синтез');
sb.writeln('Смысловое ядро: ${getField("semantic_core")}');
sb.writeln('Интерпретация по грамматическому типу: ${getField("grammar_interpretation")}');
sb.writeln('Вектор Воли (К чему призывает): ${getField("will_vector")}');
sb.writeln('Предостережение / Тень (О чем предупреждает): ${getField("shadow_warning")}');
sb.writeln('═══════════════════════════════════════════════════════════════════');
return sb.toString();
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/syncretic_session_model.dart';
import '../services/oracle_master_prompt_generator.dart';
import '../services/words_repository.dart';
import '../widgets/gradient_star_badge.dart';
class CrossSystemComparisonScreen extends StatefulWidget {
final SyncreticSession session;
const CrossSystemComparisonScreen({super.key, required this.session});
@override
State<CrossSystemComparisonScreen> createState() => _CrossSystemComparisonScreenState();
}
class _CrossSystemComparisonScreenState extends State<CrossSystemComparisonScreen> {
final _repo = WordsRepository();
late SyncreticSession _session;
@override
void initState() {
super.initState();
_session = widget.session;
}
void _showMasterPrompt() {
HapticFeedback.mediumImpact();
final prompt = OracleMasterPromptGenerator.generateCrossSystemComparison(_session);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF0F1222),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F), size: 24),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Великий Мета-Промпт Сравнения',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'Для Grok, Claude 3.5 Sonnet, GPT-4o, Gemini 1.5 Pro',
style: TextStyle(color: Color(0xFF90A4AE), fontSize: 11),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF90A4AE)),
onPressed: () => Navigator.pop(ctx),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1C223D),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${prompt.length} симв. • ${_session.readings.length} систем',
style: const TextStyle(color: Color(0xFF90CAF9), fontSize: 11),
),
),
const Spacer(),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: () {
Clipboard.setData(ClipboardData(text: prompt));
HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Мета-промпт скопирован! Вставьте в Grok или Claude для сравнения.'),
backgroundColor: Color(0xFF388E3C),
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.copy_rounded, size: 16),
label: const Text('Скопировать всё', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12.5)),
),
],
),
const SizedBox(height: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF090A14),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF232742), width: 1.0),
),
child: SingleChildScrollView(
controller: scrollController,
child: SelectableText(
prompt,
style: const TextStyle(
color: Color(0xFFECEFF1),
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.45,
),
),
),
),
),
],
),
);
},
);
},
);
}
void _confirmDeleteReading(SyncreticReading reading) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF151829),
title: const Text('Удалить расклад?', style: TextStyle(color: Color(0xFFFFF9C4), fontSize: 16, fontWeight: FontWeight.bold)),
content: Text('Удалить «${reading.systemName} (${reading.layoutTitle})» из текущей сессии дня?', style: const TextStyle(color: Color(0xFFECEFF1), fontSize: 13)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена', style: TextStyle(color: Color(0xFF8C93B8))),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFEF5350), foregroundColor: Colors.white),
onPressed: () async {
Navigator.pop(ctx);
await _repo.removeReadingFromSyncreticSession(reading.id);
setState(() {
_session = _repo.currentSyncreticSession;
});
HapticFeedback.mediumImpact();
},
child: const Text('Удалить'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final readings = _session.readings;
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Синхронистическая Сессия', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
Text(
'${_session.formattedDate} • ${readings.length} ${readings.length == 1 ? "гадание" : (readings.length < 5 ? "гадания" : "гаданий")}',
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
),
],
),
actions: [
IconButton(
tooltip: 'Великий Мета-Промпт',
icon: const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F)),
onPressed: _showMasterPrompt,
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Question Banner
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF2A1F45), Color(0xFF17192E)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
GradientStar(size: 16),
SizedBox(width: 8),
Text(
'ФОКУС ВОПРОШАНИЯ ДНЯ',
style: TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 8),
Text(
_session.mainQuestion?.isNotEmpty == true
? '«${_session.mainQuestion!}»'
: 'Общий срез судьбы и самосогласованность мантических систем.',
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 15,
fontWeight: FontWeight.bold,
height: 1.4,
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
onPressed: _showMasterPrompt,
icon: const Icon(Icons.psychology_alt_rounded, size: 18),
label: const Text(
'Сформировать Мета-Промпт для нейросетей',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
),
),
],
),
),
const SizedBox(height: 20),
Row(
children: [
const Icon(Icons.history_edu_rounded, color: Color(0xFF80DEEA), size: 20),
const SizedBox(width: 8),
Text(
'ЛЕНТА РАСКЛАДОВ СЕССИИ (${readings.length})',
style: const TextStyle(
color: Color(0xFF80DEEA),
fontSize: 13,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 12),
if (readings.isEmpty)
Container(
padding: const EdgeInsets.all(24),
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: const Text(
'В этой сессии пока нет проведённых гаданий.\nСделайте расклад в любой из систем (Оракул, Руны, Ленорман, Таро, И Цзин), и они появятся здесь.',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF8C93B8), fontSize: 13, height: 1.5),
),
)
else
for (int i = 0; i < readings.length; i++) ...[
_buildReadingCard(readings[i], i + 1),
const SizedBox(height: 12),
],
],
),
),
);
}
Widget _buildReadingCard(SyncreticReading reading, int index) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Text(reading.systemIcon, style: const TextStyle(fontSize: 18)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'#$index. ${reading.systemName}',
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
Text(
'${reading.formattedTime} • ${reading.layoutTitle}',
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded, size: 20, color: Color(0xFFEF5350)),
tooltip: 'Удалить из сессии',
onPressed: () => _confirmDeleteReading(reading),
),
],
),
const SizedBox(height: 12),
const Divider(color: Color(0xFF2A2D4A), height: 1),
const SizedBox(height: 10),
for (final item in reading.items) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.symbol != null) ...[
Text(
item.symbol!,
style: const TextStyle(fontSize: 20, color: Color(0xFFFFD54F)),
),
const SizedBox(width: 8),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title + (item.isReversed ? ' (перевёрнутая)' : ''),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13),
),
if (item.subtitle != null)
Text(item.subtitle!, style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 11)),
if (item.lore != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
item.lore!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFB0BEC5), fontSize: 11.5, height: 1.4),
),
),
if (item.linkedWord != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'🔗 Знак 999: #${item.linkedWord!.formattedSessionNumber} «${item.linkedWord!.word}»',
style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
),
],
),
const SizedBox(height: 6),
],
],
),
);
}
}
+643
View File
@@ -0,0 +1,643 @@
import '../widgets/divination_instruction_modal.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/words_repository.dart';
import '../widgets/gradient_star_badge.dart';
import 'home_search_screen.dart';
import 'houses_field_screen.dart';
import 'runes_divination_screen.dart';
import 'lenormand_divination_screen.dart';
import 'tarot_divination_screen.dart';
import 'iching_divination_screen.dart';
import 'cross_system_comparison_screen.dart';
import 'numerology_divination_screen.dart';
import 'geomancy_divination_screen.dart';
import 'ogham_divination_screen.dart';
import '../services/word_of_the_day_service.dart';
import 'word_of_the_day_screen.dart';
class DivinationHubScreen extends StatefulWidget {
const DivinationHubScreen({super.key});
@override
State<DivinationHubScreen> createState() => _DivinationHubScreenState();
}
class _DivinationHubScreenState extends State<DivinationHubScreen> {
final _repo = WordsRepository();
final _wordOfDayService = WordOfTheDayService();
void _openComparisonScreen() {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CrossSystemComparisonScreen(
session: _repo.currentSyncreticSession,
),
),
).then((_) => setState(() {}));
}
void _showNewSessionDialog() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF151829),
title: const Row(
children: [
GradientStar(size: 20),
SizedBox(width: 8),
Text('Новая Сессия Дня', style: TextStyle(color: Color(0xFFFFF9C4), fontSize: 17, fontWeight: FontWeight.bold)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Начните новую синхронистическую сессию по новой теме или вопросу. Предыдущая сессия сохранится в архиве.',
style: TextStyle(color: Color(0xFF8C93B8), fontSize: 13, height: 1.4),
),
const SizedBox(height: 14),
TextField(
controller: controller,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'Главный вопрос / тема сессии...',
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 12),
filled: true,
fillColor: const Color(0xFF1E2238),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
),
onPressed: () async {
Navigator.pop(ctx);
await _repo.startNewSyncreticSession(question: controller.text.trim());
setState(() {});
HapticFeedback.mediumImpact();
},
child: const Text('Начать'),
),
],
),
);
}
Widget _buildWordOfDayHeroBanner() {
final now = DateTime.now();
final rec = _wordOfDayService.getRecord(now);
final isTriptych = rec.isTriptychMode;
final singleWord = _wordOfDayService.getWordForDate(now, phase: 'main');
final triptych = _wordOfDayService.getTriptychWordsForDate(now);
final streak = _wordOfDayService.calculateStreak();
final isRevealed = isTriptych ? rec.isAnyRevealed : rec.isRevealed;
final isDone = isTriptych ? rec.isDayStepCompleted : rec.isStepCompleted;
String titleTag = isTriptych ? 'ТРИПТИХ СУТОК (3 СЛОВА)' : 'СЛОВО ДНЯ • КАЛЕНДАРЬ';
String subtitle;
if (isTriptych) {
if (rec.areAllTriptychRevealed) {
subtitle = '🌅 ${triptych.morning.word.toUpperCase()} • ⚡ ${triptych.day.word.toUpperCase()} • 🌙 ${triptych.evening.word.toUpperCase()}';
} else if (rec.isAnyRevealed) {
final m = rec.isMorningRevealed ? triptych.morning.word.toUpperCase() : '•••';
final d = rec.isDayRevealed ? triptych.day.word.toUpperCase() : '•••';
final e = rec.isEveningRevealed ? triptych.evening.word.toUpperCase() : '•••';
subtitle = '🌅 $m • ⚡ $d • 🌙 $e';
} else {
subtitle = 'Печати суток не распечатаны';
}
} else {
subtitle = rec.isRevealed
? '${singleWord.word.toUpperCase()} (#${singleWord.sessionNumber})'
: 'Печать не распечатана';
}
String statusText;
if (isDone) {
statusText = 'Выполнено ✓';
} else if (isTriptych) {
statusText = rec.areAllTriptychRevealed
? 'Открыты 3/3'
: (rec.isAnyRevealed ? 'В процессе' : 'Триптих');
} else {
statusText = rec.isRevealed ? 'В процессе' : 'Открыть';
}
String descText;
if (isTriptych) {
if (rec.areAllTriptychRevealed) {
descText = 'Утро: «${triptych.morning.asks}» | Вызов дня: «${triptych.day.step}»';
} else {
descText = 'Коснитесь, чтобы распечатать три слова дня: Настройка, Действие и Тень.';
}
} else {
descText = rec.isRevealed
? (singleWord.asks.isNotEmpty
? '«${singleWord.asks}»'
: (singleWord.oracle.isNotEmpty ? singleWord.oracle : singleWord.description))
: 'Коснитесь, чтобы узнать архетип, коан и практическое задание на сегодня.';
}
return InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WordOfTheDayScreen()),
).then((_) => setState(() {}));
},
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF2E2045), Color(0xFF14172B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isRevealed
? const Color(0xFFFFD54F)
: const Color(0xFFFFD54F).withValues(alpha: 0.5),
width: isRevealed ? 1.5 : 1.0,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.12),
blurRadius: 12,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [Color(0xFFFFD54F), Color(0xFFFF8F00)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.3),
blurRadius: 8,
),
],
),
child: Text(
isTriptych
? '⏳'
: (rec.isRevealed && singleWord.emoji.isNotEmpty
? singleWord.emoji
: '☀️'),
style: const TextStyle(fontSize: 18),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
titleTag,
style: const TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
),
),
if (streak > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF3B2314),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFFFB74D).withValues(alpha: 0.4)),
),
child: Text(
'🔥 $streak дн.',
style: const TextStyle(
color: Color(0xFFFFB74D),
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
color: isRevealed ? const Color(0xFFFFF9C4) : const Color(0xFF8C93B8),
fontSize: isTriptych ? 12.5 : 14,
fontWeight: FontWeight.bold,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDone
? const Color(0xFF1B3D23)
: (isRevealed ? const Color(0xFF2A2D4A) : const Color(0xFF332A1C)),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDone
? const Color(0xFF81C784)
: const Color(0xFFFFD54F).withValues(alpha: 0.4),
),
),
child: Text(
statusText,
style: TextStyle(
color: isDone
? const Color(0xFF81C784)
: const Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 10),
Text(
descText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 12, height: 1.35),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final session = _repo.currentSyncreticSession;
final readingsCount = session.readings.length;
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
title: const Row(
children: [
GradientStar(size: 22),
SizedBox(width: 8),
Text('Предсказания', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
],
),
actions: [
IconButton(
tooltip: 'Начать новую тему сессии',
icon: const Icon(Icons.add_circle_outline_rounded, color: Color(0xFFFFD54F)),
onPressed: _showNewSessionDialog,
),
IconButton(
tooltip: 'Сравнительный Мета-Промпт',
icon: const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F)),
onPressed: _openComparisonScreen,
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Word of the Day Hero Banner
_buildWordOfDayHeroBanner(),
const SizedBox(height: 14),
// Active Day Session Banner
InkWell(
onTap: _openComparisonScreen,
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF2A1F45), Color(0xFF151829)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
boxShadow: [
BoxShadow(color: const Color(0xFFFFD54F).withValues(alpha: 0.1), blurRadius: 10),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const GradientStar(size: 18),
const SizedBox(width: 8),
const Expanded(
child: Text(
'АКТИВНАЯ СЕССИЯ ДНЯ',
style: TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF3B2B5E),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Text(
'$readingsCount ${readingsCount == 1 ? "гадание" : (readingsCount < 5 ? "гадания" : "гаданий")}',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
const SizedBox(height: 8),
Text(
session.mainQuestion?.isNotEmpty == true
? '«${session.mainQuestion!}»'
: 'Синхронистический сосуд дня (все гадания аккумулируются здесь)',
style: const TextStyle(color: Color(0xFFECEFF1), fontSize: 13.5, height: 1.4),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
readingsCount > 0 ? 'Нажмите, чтобы сравнить системы' : 'Сделайте расклад в любой системе ниже',
style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 11, fontWeight: FontWeight.bold),
),
const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Color(0xFFFFD54F)),
],
),
],
),
),
),
const SizedBox(height: 20),
// Section 1: Core 999 Systems
_buildSectionHeader('СИСТЕМЫ ОРАКУЛА 999', Icons.auto_awesome_rounded),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _buildSystemCard(
title: 'Оракул',
instructionId: 'cleromancy',
subtitle: 'Клеромантия d10',
icon: '🎲',
desc: '1..10 знаков по жребию костей или прямому вводу',
accentColor: const Color(0xFFFFD54F),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const HomeSearchScreen())).then((_) => setState(() {})),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSystemCard(
title: 'Матрица 33×30',
instructionId: 'matrix',
subtitle: '990 Домов Судьбы',
icon: '🏛️',
desc: 'Полотно Домов, Архонты и цепочки диспозиций',
accentColor: const Color(0xFF80DEEA),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const HousesFieldScreen())).then((_) => setState(() {})),
),
),
],
),
const SizedBox(height: 20),
// Section 2: Classical Divination Systems
_buildSectionHeader('КЛАССИЧЕСКИЕ ТРАДИЦИИ', Icons.history_edu_rounded),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _buildSystemCard(
title: 'Руны Футарка',
instructionId: 'runes',
subtitle: '24 Руны (2×d12)',
icon: 'ᚱ',
desc: 'Жребий двумя d12 либо сетка рун (прямые/перев.)',
accentColor: const Color(0xFF81C784),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const RunesDivinationScreen())).then((_) => setState(() {})),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSystemCard(
title: 'Кельтский Огам',
instructionId: 'ogham',
subtitle: '20 Фед Рощи (d20)',
icon: '᚛',
desc: 'Священные деревья друидов: жребий палочек и 4 Аикмы',
accentColor: const Color(0xFF66BB6A),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const OghamDivinationScreen())).then((_) => setState(() {})),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSystemCard(
title: 'Таро',
instructionId: 'tarot',
subtitle: '22 Старших Аркана',
icon: '🎴',
desc: 'Жребий колоды или выбор из 22 Арканов',
accentColor: const Color(0xFFBA68C8),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const TarotDivinationScreen())).then((_) => setState(() {})),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSystemCard(
title: 'Ленорман',
instructionId: 'lenormand',
subtitle: '36 Домов (d6+d12)',
icon: '🪶',
desc: 'Бросок d6+d12 со смещением или ввод 1..36',
accentColor: const Color(0xFFFFB74D),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const LenormandDivinationScreen())).then((_) => setState(() {})),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSystemCard(
title: 'И Цзин',
instructionId: 'iching',
subtitle: '3 монеты × 6',
icon: '☯️',
desc: 'Книга Перемен: 6 бросков монет или ввод черт',
accentColor: const Color(0xFF4DD0E1),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const IChingDivinationScreen())).then((_) => setState(() {})),
),
),
const SizedBox(width: 12),
Expanded(
child: _buildSystemCard(
title: 'Геомантия',
instructionId: 'geomancy',
subtitle: "16 Фигур ('Илм ар-рамл)",
icon: '⌛',
desc: 'Земной жребий песка, Свидетели и Щит Судьбы',
accentColor: const Color(0xFFFFB74D),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const GeomancyDivinationScreen())).then((_) => setState(() {})),
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildSystemCard(
title: 'Нумерология',
instructionId: 'numerology',
subtitle: 'Психоматрица 3×3',
icon: '🔢',
desc: '4 рабочих числа, психоматрица Пифагора и Число Судьбы',
accentColor: const Color(0xFFFFD54F),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const NumerologyDivinationScreen())).then((_) => setState(() {})),
),
),
],
),
],
),
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, size: 16, color: const Color(0xFFFFD54F)),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11.5,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
),
),
],
);
}
Widget _buildSystemCard({
required String title,
required String subtitle,
required String icon,
required String desc,
required Color accentColor,
String? instructionId,
required VoidCallback onTap,
}) {
return InkWell(
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accentColor.withValues(alpha: 0.4)),
),
child: Text(icon, style: const TextStyle(fontSize: 18)),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Text(subtitle, style: TextStyle(color: accentColor, fontSize: 10, fontWeight: FontWeight.bold)),
],
),
),
if (instructionId != null)
IconButton(
icon: const Icon(Icons.info_outline_rounded, size: 18, color: Color(0xFF8C93B8)),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Инструкция',
onPressed: () => DivinationInstructionModal.show(context, instructionId),
),
],
),
const SizedBox(height: 8),
Text(
desc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11, height: 1.3),
),
],
),
),
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+384 -37
View File
@@ -1,9 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../models/history_entry_model.dart'; import '../models/history_entry_model.dart';
import '../models/word_of_day_model.dart';
import '../services/word_of_the_day_service.dart';
import '../services/words_repository.dart'; import '../services/words_repository.dart';
import '../widgets/gradient_star_badge.dart'; import '../widgets/gradient_star_badge.dart';
import 'session_result_screen.dart'; import 'session_result_screen.dart';
import 'word_of_the_day_screen.dart';
class HistoryScreen extends StatefulWidget { class HistoryScreen extends StatefulWidget {
const HistoryScreen({super.key}); const HistoryScreen({super.key});
@@ -14,20 +17,23 @@ class HistoryScreen extends StatefulWidget {
class _HistoryScreenState extends State<HistoryScreen> { class _HistoryScreenState extends State<HistoryScreen> {
final _repo = WordsRepository(); final _repo = WordsRepository();
final _wordOfDayService = WordOfTheDayService();
void _confirmClearHistory() { void _confirmClearHistory() {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF151829),
title: const Row( title: const Row(
children: [ children: [
GradientStar(size: 20), GradientStar(size: 20),
SizedBox(width: 8), SizedBox(width: 8),
Text('Очистить историю?'), Text('Очистить историю сессий?', style: TextStyle(color: Color(0xFFFFF9C4))),
], ],
), ),
content: const Text( content: const Text(
'Все сохранённые сессии гаданий и AI-трактовки будут удалены.', 'Все сохранённые сессии гаданий и AI-трактовки будут удалены. (Хроника Слов Дня в календаре сохранится).',
style: TextStyle(color: Color(0xFF8C93B8)),
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -51,7 +57,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
void _copySession(OracleSession session) { void _copySession(OracleSession session) {
final buf = StringBuffer(); final buf = StringBuffer();
buf.writeln('✨ Оракул 999 — Запись Сессии ✨'); buf.writeln('✨ Оракул — Запись Сессии ✨');
buf.writeln('Дата: ${session.formattedDate}'); buf.writeln('Дата: ${session.formattedDate}');
if (session.question != null && session.question!.isNotEmpty) { if (session.question != null && session.question!.isNotEmpty) {
buf.writeln('Вопрос: «${session.question}»'); buf.writeln('Вопрос: «${session.question}»');
@@ -60,12 +66,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
for (int i = 0; i < session.words.length; i++) { for (int i = 0; i < session.words.length; i++) {
final w = session.words[i]; final w = session.words[i];
buf.writeln('🔮 Знак #${w.formattedSessionNumber}: ${w.word.toUpperCase()} (${w.partOfSpeech})'); buf.writeln('🔮 Знак #${w.formattedSessionNumber}: ${w.word.toUpperCase()}');
buf.writeln(' ${w.symbolicMeaning}\n'); buf.writeln(' ${w.symbolicMeaning}\n');
} }
if (session.aiInterpretation != null && session.aiInterpretation!.isNotEmpty) { if (session.aiInterpretation != null && session.aiInterpretation!.isNotEmpty) {
buf.writeln('🤖 AI Трактовка (G4F):'); buf.writeln('🤖 AI Трактовка:');
buf.writeln(session.aiInterpretation); buf.writeln(session.aiInterpretation);
} }
@@ -87,20 +93,34 @@ class _HistoryScreenState extends State<HistoryScreen> {
); );
} }
String _formatDateString(String dateKey) {
final d = WordOfTheDayService.parseDateKey(dateKey);
const months = [
'января', 'февраля', 'марта', 'апреля', 'мая', 'июня',
'июля', 'августа', 'сентября', 'октября', 'ноября', 'декабря'
];
const weekdays = [
'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'
];
return '${d.day} ${months[d.month - 1]} ${d.year} • ${weekdays[d.weekday - 1]}';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final sessionsList = _repo.sessions; final sessionsList = _repo.sessions;
final dayRecords = _wordOfDayService.getAllRecords();
return Scaffold( return DefaultTabController(
length: 2,
child: Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar( appBar: AppBar(
title: const Row( title: const Row(
children: [ children: [
GradientStar(size: 24), GradientStar(size: 24),
SizedBox(width: 10), SizedBox(width: 10),
Text( Text(
'История сессий', 'История & Календарь',
style: TextStyle(fontWeight: FontWeight.w900), style: TextStyle(fontWeight: FontWeight.w900),
), ),
], ],
@@ -108,39 +128,81 @@ class _HistoryScreenState extends State<HistoryScreen> {
actions: [ actions: [
if (sessionsList.isNotEmpty) if (sessionsList.isNotEmpty)
IconButton( IconButton(
tooltip: 'Очистить историю', tooltip: 'Очистить историю сессий',
icon: const Icon(Icons.delete_outline_rounded), icon: const Icon(Icons.delete_outline_rounded),
onPressed: _confirmClearHistory, onPressed: _confirmClearHistory,
), ),
IconButton(
tooltip: 'Открыть календарь Слов Дня',
icon: const Icon(Icons.today_rounded, color: Color(0xFFFFD54F)),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WordOfTheDayScreen()),
).then((_) => setState(() {}));
},
),
],
bottom: const TabBar(
indicatorColor: Color(0xFFFFD54F),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Color(0xFF1E2238),
labelColor: Color(0xFFFFD54F),
unselectedLabelColor: Color(0xFF8C93B8),
labelStyle: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
tabs: [
Tab(
icon: Icon(Icons.history_edu_rounded, size: 18),
text: 'Сессии Раскладов',
),
Tab(
icon: Icon(Icons.calendar_month_rounded, size: 18),
text: 'Хроника Слов Дня',
),
], ],
), ),
body: sessionsList.isEmpty ),
? Center( body: TabBarView(
children: [
// Tab 1: Oracle Divination Sessions
_buildSessionsTab(sessionsList),
// Tab 2: Word of the Day Chronicle / Calendar
_buildWordOfDayChronicleTab(dayRecords),
],
),
),
);
}
Widget _buildSessionsTab(List<OracleSession> sessionsList) {
if (sessionsList.isEmpty) {
return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const GradientStar(size: 48), const GradientStar(size: 48),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( const Text(
'История сессий пока пуста', 'История сессий пока пуста',
style: theme.textTheme.titleMedium?.copyWith( style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: Colors.white),
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Padding( const Padding(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: EdgeInsets.symmetric(horizontal: 32),
child: Text( child: Text(
'Задайте вопрос, совершите броски и откройте знаки, чтобы сохранить сессию здесь.', 'Задайте вопрос, совершите броски и откройте знаки, чтобы сохранить сессию здесь.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: theme.colorScheme.outline), style: TextStyle(color: Color(0xFF8C93B8)),
), ),
), ),
], ],
), ),
) );
: ListView.separated( }
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
itemCount: sessionsList.length, itemCount: sessionsList.length,
separatorBuilder: (_, _) => const SizedBox(height: 12), separatorBuilder: (_, _) => const SizedBox(height: 12),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -154,34 +216,35 @@ class _HistoryScreenState extends State<HistoryScreen> {
color: const Color(0xFFFFD54F).withValues(alpha: 0.25), color: const Color(0xFFFFD54F).withValues(alpha: 0.25),
), ),
), ),
color: isDark ? const Color(0xFF1B1D30) : theme.colorScheme.surface, color: const Color(0xFF1B1D30),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => SessionResultScreen(session: session), builder: (_) => SessionResultScreen(
session: session,
),
), ),
).then((_) => setState(() {})); ).then((_) => setState(() {}));
}, },
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Padding( child: Padding(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// Top Header: Date, Words Count, Actions
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Row(
children: [ children: [
const Icon( const Icon(
Icons.access_time_rounded, Icons.calendar_today_rounded,
size: 14, size: 14,
color: Color(0xFFFFD54F), color: Color(0xFFFFD54F),
), ),
const SizedBox(width: 5), const SizedBox(width: 6),
Text( Text(
session.formattedDate, session.formattedDate,
style: const TextStyle( style: const TextStyle(
@@ -230,8 +293,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
], ],
), ),
// Question Box if present
if (session.question != null && session.question!.isNotEmpty) ...[ if (session.question != null && session.question!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
@@ -259,10 +320,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
), ),
], ],
const SizedBox(height: 10), const SizedBox(height: 10),
// Words Chips Row
Wrap( Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
@@ -270,7 +328,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark ? const Color(0xFF131524) : theme.colorScheme.surfaceContainerLow, color: const Color(0xFF131524),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all( border: Border.all(
color: const Color(0xFFFFD54F).withValues(alpha: 0.3), color: const Color(0xFFFFD54F).withValues(alpha: 0.3),
@@ -287,8 +345,6 @@ class _HistoryScreenState extends State<HistoryScreen> {
); );
}).toList(), }).toList(),
), ),
// AI Interpretation Snippet if generated
if (session.aiInterpretation != null && session.aiInterpretation!.isNotEmpty) ...[ if (session.aiInterpretation != null && session.aiInterpretation!.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(
@@ -309,7 +365,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
Icon(Icons.psychology_rounded, size: 14, color: Color(0xFFE1BEE7)), Icon(Icons.psychology_rounded, size: 14, color: Color(0xFFE1BEE7)),
SizedBox(width: 6), SizedBox(width: 6),
Text( Text(
'AI Трактовка (G4F)', 'AI Трактовка',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -339,7 +395,298 @@ class _HistoryScreenState extends State<HistoryScreen> {
), ),
); );
}, },
);
}
Widget _buildWordOfDayChronicleTab(List<WordOfDayRecord> dayRecords) {
final streak = _wordOfDayService.calculateStreak();
final completedCount = _wordOfDayService.totalCompletedPractices;
final revealedCount = _wordOfDayService.totalRevealedDays;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Stats Ribbon
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF2E2045), Color(0xFF14172B)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatPill('🔥 СТРИК', '$streak дн.', const Color(0xFFFFB74D)),
_buildStatPill('☀️ ОТКРЫТО', '$revealedCount дн.', const Color(0xFFFFD54F)),
_buildStatPill('✓ ПРАКТИКИ', '$completedCount', const Color(0xFF81C784)),
],
),
),
const SizedBox(height: 16),
// Action Button: Open Today
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: const Color(0xFF0C0E1A),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 4,
),
icon: const Icon(Icons.wb_sunny_rounded, size: 20),
label: const Text(
'ОТКРЫТЬ СЛОВО СЕГОДНЯШНЕГО ДНЯ',
style: TextStyle(fontWeight: FontWeight.w900, letterSpacing: 0.8, fontSize: 13),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const WordOfTheDayScreen()),
).then((_) => setState(() {}));
},
),
const SizedBox(height: 16),
const Row(
children: [
Icon(Icons.history_toggle_off_rounded, size: 16, color: Color(0xFFFFD54F)),
SizedBox(width: 8),
Text(
'ХРОНИКА СЛОВ И ПРАКТИК',
style: TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11.5,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
),
),
],
),
const SizedBox(height: 10),
if (dayRecords.isEmpty)
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: const Column(
children: [
GradientStar(size: 32),
SizedBox(height: 12),
Text(
'Пока нет открытых слов',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.white, fontSize: 14),
),
SizedBox(height: 6),
Text(
'Откройте сегодняшнее Слово Дня, выполните практику и зафиксируйте синхронии.',
textAlign: TextAlign.center,
style: TextStyle(color: Color(0xFF8C93B8), fontSize: 12, height: 1.35),
),
],
),
)
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: dayRecords.length,
separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final rec = dayRecords[index];
final date = WordOfTheDayService.parseDateKey(rec.dateKey);
final isTriptych = rec.isTriptychMode;
final singleWord = _wordOfDayService.getWordForDate(date, phase: 'main');
final triptych = _wordOfDayService.getTriptychWordsForDate(date);
final isDone = isTriptych ? rec.isDayStepCompleted : rec.isStepCompleted;
return Container(
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDone
? const Color(0xFF81C784).withValues(alpha: 0.5)
: const Color(0xFF2A2D4A),
),
),
child: InkWell(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WordOfTheDayScreen(initialDate: date),
),
).then((_) => setState(() {}));
},
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
isTriptych
? '⏳'
: (singleWord.emoji.isNotEmpty ? singleWord.emoji : '☀️'),
style: const TextStyle(fontSize: 22),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isTriptych
? '🌅 ${triptych.morning.word.toUpperCase()} • ⚡ ${triptych.day.word.toUpperCase()} • 🌙 ${triptych.evening.word.toUpperCase()}'
: singleWord.word.toUpperCase(),
style: TextStyle(
color: const Color(0xFFFFF9C4),
fontWeight: FontWeight.bold,
fontSize: isTriptych ? 13 : 15,
letterSpacing: 0.6,
),
),
Text(
'${_formatDateString(rec.dateKey)}${isTriptych ? " • Триптих" : ""}',
style: const TextStyle(
color: Color(0xFF8C93B8),
fontSize: 11,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isDone
? const Color(0xFF1B3D23)
: const Color(0xFF262A44),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDone
? const Color(0xFF81C784)
: const Color(0xFF8C93B8).withValues(alpha: 0.3),
),
),
child: Text(
isDone
? '✓ Выполнено'
: (isTriptych
? (rec.areAllTriptychRevealed ? 'Открыто 3/3' : 'Триптих')
: 'Открыто'),
style: TextStyle(
color: isDone
? const Color(0xFF81C784)
: const Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
if (!isTriptych && singleWord.asks.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Коан: «${singleWord.asks}»',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFB39DDB),
fontSize: 12,
fontStyle: FontStyle.italic,
),
),
] else if (isTriptych && triptych.day.step.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Вызов дня: «${triptych.day.step}»',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFA5D6A7),
fontSize: 12,
),
),
],
if (rec.diaryNote.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFF1A2238),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF80DEEA).withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('🪶', style: TextStyle(fontSize: 12)),
const SizedBox(width: 6),
Expanded(
child: Text(
rec.diaryNote,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFF80DEEA),
fontSize: 11.5,
height: 1.3,
),
),
),
],
),
),
],
],
),
),
),
);
},
),
],
), ),
); );
} }
Widget _buildStatPill(String title, String val, Color color) {
return Column(
children: [
Text(
title,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
),
),
const SizedBox(height: 4),
Text(
val,
style: const TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w900,
),
),
],
);
}
} }
+71 -12
View File
@@ -1,11 +1,14 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../models/history_entry_model.dart'; import '../models/history_entry_model.dart';
import '../models/word_model.dart'; import '../models/word_model.dart';
import '../services/true_random_service.dart';
import '../services/words_repository.dart'; import '../services/words_repository.dart';
import '../widgets/cleromancy_dialog.dart'; import '../widgets/cleromancy_dialog.dart';
import '../widgets/d10_dice_roller_widget.dart'; import '../widgets/d10_dice_roller_widget.dart';
import '../widgets/gradient_star_badge.dart'; import '../widgets/gradient_star_badge.dart';
import '../widgets/interpreter_persona_picker.dart';
import 'session_result_screen.dart'; import 'session_result_screen.dart';
class HomeSearchScreen extends StatefulWidget { class HomeSearchScreen extends StatefulWidget {
@@ -182,29 +185,77 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
}); });
} }
void _reshuffleMatrix() { Future<void> _rollRandomCleromancy() async {
_repo.reshuffleSessionNumbers();
_resetSession();
HapticFeedback.heavyImpact(); HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( const SnackBar(
content: const Row( content: Row(
children: [ children: [
GradientStar(size: 20), GradientStar(size: 20),
SizedBox(width: 10), SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
'Случайный порядок 1000 чисел перетасован заново!', 'Бросаю священный жребий (3 × d10)…',
style: TextStyle(fontWeight: FontWeight.w600), style: TextStyle(fontWeight: FontWeight.w600),
), ),
), ),
], ],
), ),
duration: Duration(milliseconds: 1000),
behavior: SnackBarBehavior.floating,
),
);
try {
final randomService = TrueRandomService();
final roll = await randomService.take(_wordsCount * 3);
if (!mounted) return;
final newDigits = <String>[];
for (int i = 0; i < _wordsCount; i++) {
final chunk = roll.digits.sublist(i * 3, (i + 1) * 3);
newDigits.add(chunk.join());
}
setState(() {
_wordsDigits = newDigits;
_activeWordIndex = _wordsCount - 1;
});
final viaOrg = roll.source == RandomSource.randomOrg;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const GradientStar(size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
viaOrg
? 'Выпал истинный жребий RANDOM.ORG (3 × d10)'
: 'Выпал криптографический жребий (3 × d10)',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
); );
} catch (_) {
final rng = math.Random();
if (!mounted) return;
setState(() {
_wordsDigits = List.generate(
_wordsCount,
(_) => List.generate(3, (_) => rng.nextInt(10)).join(),
);
_activeWordIndex = _wordsCount - 1;
});
}
} }
@override @override
@@ -221,7 +272,7 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
GradientStar(size: 24), GradientStar(size: 24),
SizedBox(width: 10), SizedBox(width: 10),
Text( Text(
'Оракул 999', 'Оракул',
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
@@ -242,9 +293,9 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
onPressed: _resetSession, onPressed: _resetSession,
), ),
IconButton( IconButton(
tooltip: 'Перетасовать матрицу (#000..#999)', tooltip: 'Бросить священный жребий (3 × d10)',
icon: const Icon(Icons.shuffle_rounded), icon: const Icon(Icons.casino_rounded, color: Color(0xFF64B5F6)),
onPressed: _reshuffleMatrix, onPressed: _rollRandomCleromancy,
), ),
IconButton( IconButton(
tooltip: _showKeypad ? 'Скрыть панель' : 'Показать панель', tooltip: _showKeypad ? 'Скрыть панель' : 'Показать панель',
@@ -261,6 +312,8 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const InterpreterPersonaPicker(),
const SizedBox(height: 12),
// Row: Question & Words count selector (1..9) // Row: Question & Words count selector (1..9)
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -476,20 +529,26 @@ class _HomeSearchScreenState extends State<HomeSearchScreen> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( Expanded(
child: Row(
children: [ children: [
const Icon(Icons.casino_outlined, size: 15, color: Color(0xFFFFD54F)), const Icon(Icons.casino_outlined, size: 15, color: Color(0xFFFFD54F)),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Flexible(
child: Text(
'Ввод граней (Слово ${_activeWordIndex + 1} из $_wordsCount):', 'Ввод граней (Слово ${_activeWordIndex + 1} из $_wordsCount):',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Color(0xFFFFD54F), color: Color(0xFFFFD54F),
), ),
overflow: TextOverflow.ellipsis,
),
), ),
], ],
), ),
),
const SizedBox(width: 8),
Text( Text(
currentActiveDigits.length == 3 currentActiveDigits.length == 3
? 'Код: #$currentActiveDigits' ? 'Код: #$currentActiveDigits'
+599
View File
@@ -0,0 +1,599 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/history_entry_model.dart';
import '../models/houses_session_model.dart';
import '../models/word_model.dart';
import '../services/ai_oracle_service.dart';
import '../widgets/gradient_star_badge.dart';
import 'symbol_detail_screen.dart';
import 'matrix_geometry_screen.dart';
import '../models/syncretic_session_model.dart';
import '../services/words_repository.dart';
class HouseChainsScreen extends StatefulWidget {
final HousesSession session;
const HouseChainsScreen({super.key, required this.session});
@override
State<HouseChainsScreen> createState() => _HouseChainsScreenState();
}
class _HouseChainsScreenState extends State<HouseChainsScreen> {
final _aiService = AiOracleService();
final Map<int, String> _chainAiInterpretations = {};
final Set<int> _loadingChains = {};
@override
void initState() {
super.initState();
_recordChainsToSyncreticSession(showToast: false);
}
Future<void> _recordChainsToSyncreticSession({bool showToast = false}) async {
final session = widget.session;
if (session.chains.isEmpty) return;
final chainItems = session.chains.map((chain) {
final pathStr = chain.steps.map((s) => '«${s.guest.word.toUpperCase()}»').join(' ➔ ');
final startWord = chain.startingHouse.houseWord;
final aiText = _chainAiInterpretations[chain.chainIndex];
var loreText = 'Траектория: ' + pathStr + '\n' + chain.description;
if (aiText != null && aiText.isNotEmpty) {
loreText += '\n\nТолкование Оракула:\n' + aiText;
}
return ReadingSignItem(
title: 'Цепочка #${chain.chainIndex}: ${chain.endTypeTitle}',
subtitle: 'Старт: Дом #${chain.startingHouse.houseIndex + 1} «${startWord.word}» • Шагов: ${chain.steps.length}',
symbol: chain.hasLoop ? '🔄' : (chain.endType == ChainEndType.archonThrone ? '👑' : '🔗'),
lore: loreText,
linkedWord: chain.steps.isNotEmpty ? chain.steps.last.guest : startWord,
meta: {
'chainIndex': chain.chainIndex,
'endType': chain.endType.name,
'stepsCount': chain.steps.length,
'path': pathStr,
},
);
}).toList();
await WordsRepository().addReadingToSyncreticSession(
SyncreticReading(
id: 'chains_${session.id}',
systemId: 'matrix_chains',
systemName: 'Матрица 33×30: Цепочки Домов',
systemIcon: '🔗',
timestamp: session.createdAt,
layoutTitle: '${session.chains.length} ${session.chains.length == 1 ? "цепочка" : (session.chains.length < 5 ? "цепочки" : "цепочек")}',
inputMethod: 'dice',
items: chainItems,
question: session.question,
),
question: session.question,
);
if (showToast && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Цепочки домов (${session.chains.length}) сохранены в Синхроническую сессию дня',
style: const TextStyle(fontWeight: FontWeight.w600),
),
backgroundColor: const Color(0xFF1E2238),
behavior: SnackBarBehavior.floating,
),
);
}
}
Future<void> _interpretChain(HouseChain chain) async {
setState(() => _loadingChains.add(chain.chainIndex));
HapticFeedback.lightImpact();
final buffer = StringBuffer();
buffer.writeln('Ты — верховный оракул, читающий цепочки домов Ленорман в системе Оракула 999.');
buffer.writeln('Проанализируй путь перетекания силы в цепочке домов:');
buffer.writeln('ЦЕПОЧКА #${chain.chainIndex} (${chain.steps.length} шагов).');
buffer.writeln('ИТОГ ЦЕПОЧКИ: ${chain.description}.\n');
for (int i = 0; i < chain.steps.length; i++) {
final step = chain.steps[i];
final sector = widget.session.sectors[step.house.sectorIndex];
buffer.writeln(
'Шаг ${i + 1}: В Доме #${step.house.houseIndex + 1} «${step.house.houseWord.word.toUpperCase()}» (Архонт «${sector.archonWord.word.toUpperCase()}») выпал Гость «${step.guest.word.toUpperCase()}».',
);
if (step.isLoopClosing) {
buffer.writeln(' ➔ [ПЕТЛЯ ЗАМКНУЛАСЬ: возврат энергии в исходный дом]');
} else if (step.leadsToArchon) {
buffer.writeln(' ➔ [ВОСХОЖДЕНИЕ К АРХОНТУ: увенчание Престолом «${step.destinationArchon?.archonWord.word.toUpperCase()}»]');
} else if (step.computed) {
buffer.writeln(' ➔ [ВЫЧИСЛЕНО ИЗ ПОЛЯ, НЕ ЖРЕБИЙ: дом не был открыт]');
}
}
buffer.writeln('\nДай емкое и глубокое толкование: что запускает этот поток, где ключевой перелом и к какому исходу приводит замыкание цепи.');
try {
final res = await _aiService.interpretSession(
words: chain.steps
.map((s) => SessionWord.fromWordItem(
s.guest,
positionTitle: 'Дом «${s.house.houseWord.word}»',
))
.toList(),
question: widget.session.question ?? 'Цепочка домов судьбы',
);
if (mounted) {
setState(() {
_chainAiInterpretations[chain.chainIndex] = res;
_loadingChains.remove(chain.chainIndex);
});
_recordChainsToSyncreticSession(showToast: false);
}
} catch (e) {
if (mounted) {
setState(() {
_chainAiInterpretations[chain.chainIndex] = 'Связь с оракулом прервалась: $e';
_loadingChains.remove(chain.chainIndex);
});
}
}
}
void _openWordDossier(WordItem word) {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SymbolDetailScreen(word: word)),
);
}
void _copyChain(HouseChain chain) {
final buffer = StringBuffer();
buffer.writeln('🔗 ОРАКУЛ — ЦЕПОЧКА ДОМОВ #${chain.chainIndex}');
buffer.writeln('Итог: ${chain.description}\n');
for (int i = 0; i < chain.steps.length; i++) {
final s = chain.steps[i];
final archon = widget.session.sectors[s.house.sectorIndex].archonWord.word.toUpperCase();
buffer.writeln(
'${i + 1}. [Архонт: $archon] ➔ Дом «${s.house.houseWord.word.toUpperCase()}» ➔ Гость «${s.guest.word.toUpperCase()}»',
);
if (s.isLoopClosing) {
buffer.writeln(' 🔄 Цикл замкнулся!');
} else if (s.leadsToArchon) {
buffer.writeln(' 👑 Восхождение к Архонту «${s.destinationArchon?.archonWord.word.toUpperCase()}»');
} else if (s.computed) {
buffer.writeln(' ◎ Знак вычислен из поля, это не жребий.');
}
}
if (_chainAiInterpretations[chain.chainIndex] != null) {
buffer.writeln('\n🤖 Толкование Оракула:\n${_chainAiInterpretations[chain.chainIndex]}');
}
Clipboard.setData(ClipboardData(text: buffer.toString()));
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Цепочка домов скопирована в буфер'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final chains = widget.session.chains;
return Scaffold(
backgroundColor: const Color(0xFF0A0C16),
appBar: AppBar(
title: const Row(
children: [
GradientStar(size: 20),
SizedBox(width: 8),
Text('Цепочки Домов', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
],
),
actions: [
IconButton(
tooltip: 'Записать цепочки в Синхроническую сессию дня',
icon: const Icon(Icons.bookmark_add_rounded, color: Color(0xFFFFD54F)),
onPressed: () => _recordChainsToSyncreticSession(showToast: true),
),
],
),
body: chains.isEmpty
? const Center(
child: Text(
'В данном раскладе нет открытых цепочек',
style: TextStyle(color: Color(0xFFB0BEC5)),
),
)
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
itemCount: chains.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
return _buildHeaderInfo();
}
final chain = chains[index - 1];
return _buildChainCard(chain);
},
),
);
}
Widget _buildHeaderInfo() {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.3)),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.hub_outlined, color: Color(0xFFFFD54F), size: 24),
SizedBox(width: 12),
Expanded(
child: Text(
'Цепочки домов показывают траекторию судьбы: гость из первого дома ведет в собственную обитель на поле 33×30, гость следующего дома ведет дальше — пока энергия не замкнется в петлю судьбы или не взойдет к Престолу Архонта.',
style: TextStyle(color: Color(0xFFCFD8DC), fontSize: 13, height: 1.45),
),
),
],
),
);
}
Widget _buildChainCard(HouseChain chain) {
final isInterpreting = _loadingChains.contains(chain.chainIndex);
final aiText = _chainAiInterpretations[chain.chainIndex];
Color statusColor;
IconData statusIcon;
String statusTitle;
switch (chain.endType) {
case ChainEndType.loop:
statusColor = const Color(0xFFE57373);
statusIcon = Icons.sync_rounded;
statusTitle = 'Петля Судьбы (Замкнутый узел)';
break;
case ChainEndType.archonThrone:
statusColor = const Color(0xFFFFD54F);
statusIcon = Icons.shield_rounded;
statusTitle = 'Восхождение к Престолу Архонта';
break;
case ChainEndType.fieldVector:
statusColor = const Color(0xFF64B5F6);
statusIcon = Icons.trending_flat_rounded;
statusTitle = 'Вектор в поле (Открытый луч)';
break;
}
return Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF131627),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: chain.hasLoop
? const Color(0xFFFFD54F).withValues(alpha: 0.5)
: const Color(0xFF2A2D4A),
width: chain.hasLoop ? 1.5 : 1.0,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Chain index + Status Badge
Row(
children: [
Text(
'ЦЕПОЧКА #${chain.chainIndex}',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w900,
fontSize: 16,
letterSpacing: 0.8,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: statusColor, width: 1.0),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 14, color: statusColor),
const SizedBox(width: 4),
Text(
statusTitle,
style: TextStyle(
color: statusColor,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 6),
Text(
chain.description,
style: const TextStyle(color: Color(0xFFB0BEC5), fontSize: 12),
),
const SizedBox(height: 16),
// Steps list
for (int i = 0; i < chain.steps.length; i++) ...[
_buildStepRow(chain.steps[i], i + 1),
if (i < chain.steps.length - 1)
Padding(
padding: const EdgeInsets.only(left: 20, top: 4, bottom: 4),
child: Row(
children: [
const Icon(Icons.arrow_downward_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Text(
'переход в обитель знака «${chain.steps[i].guest.word.toUpperCase()}» на поле',
style: const TextStyle(color: Color(0xFF78909C), fontSize: 10, fontStyle: FontStyle.italic),
),
],
),
),
],
const SizedBox(height: 16),
// AI Interpretation if exists
if (aiText != null) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.auto_awesome_rounded, color: Color(0xFFFFD54F), size: 16),
SizedBox(width: 6),
Text(
'Толкование цепочки Оракулом:',
style: TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
const SizedBox(height: 8),
Text(
aiText,
style: const TextStyle(color: Color(0xFFECEFF1), fontSize: 13, height: 1.45),
),
],
),
),
const SizedBox(height: 12),
],
// Action Buttons
Row(
children: [
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2A2D4A),
foregroundColor: const Color(0xFFFFD54F),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFFFD54F), width: 0.8),
),
),
onPressed: isInterpreting ? null : () => _interpretChain(chain),
icon: isInterpreting
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFFFFD54F)),
)
: const Icon(Icons.auto_awesome, size: 16),
label: Text(
aiText == null ? 'Трактовать (AI)' : 'Обновить (AI)',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF00E5FF),
side: const BorderSide(color: Color(0xFF00E5FF), width: 0.8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MatrixGeometryScreen(
session: widget.session,
initialChainIndex: chain.chainIndex,
),
),
);
},
icon: const Icon(Icons.polyline_rounded, size: 15),
label: const Text('Узор на поле', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
const Spacer(),
IconButton(
tooltip: 'Скопировать цепочку',
icon: const Icon(Icons.copy_rounded, size: 18, color: Color(0xFF9FA8DA)),
onPressed: () => _copyChain(chain),
),
],
),
],
),
);
}
Widget _buildStepRow(HouseChainStep step, int order) {
final sector = widget.session.sectors[step.house.sectorIndex];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF181C33),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: step.isLoopClosing
? const Color(0xFFE57373)
: (step.leadsToArchon ? const Color(0xFFFFD54F) : sector.sectorColor.withValues(alpha: 0.3)),
width: (step.isLoopClosing || step.leadsToArchon) ? 1.5 : 1.0,
),
),
child: Row(
children: [
// Order circle
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: sector.sectorColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
border: Border.all(color: sector.sectorColor),
),
child: Center(
child: Text(
'$order',
style: TextStyle(
color: sector.sectorColor,
fontWeight: FontWeight.w900,
fontSize: 11,
),
),
),
),
const SizedBox(width: 12),
// Content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// House title
InkWell(
onTap: () => _openWordDossier(step.house.houseWord),
child: Row(
children: [
Text(
'Дом #${(step.house.houseIndex + 1).toString().padLeft(3, '0')}: «${step.house.houseWord.word.toUpperCase()}»',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 13,
),
),
const SizedBox(width: 6),
Text(
'(Архонт: ${sector.archonWord.word})',
style: TextStyle(
color: sector.sectorColor,
fontSize: 11,
fontWeight: FontWeight.w600,
),
),
],
),
),
const SizedBox(height: 3),
// Guest title
InkWell(
onTap: () => _openWordDossier(step.guest),
child: Row(
children: [
const Icon(Icons.auto_awesome_rounded, size: 13, color: Color(0xFFFFD54F)),
const SizedBox(width: 4),
Text(
step.computed
? 'Вычислен из поля, не жребий: «${step.guest.word.toUpperCase()}»'
: 'В него пришел Гость: «${step.guest.word.toUpperCase()}»',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
// Special termination notices
if (step.isLoopClosing) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFE57373).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.sync_rounded, size: 12, color: Color(0xFFE57373)),
SizedBox(width: 4),
Text(
'🔄 Узел замкнулся: энергия возвращается в исходный дом!',
style: TextStyle(color: Color(0xFFEF9A9A), fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
] else if (step.leadsToArchon) ...[
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFFFD54F).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.shield_rounded, size: 12, color: Color(0xFFFFD54F)),
const SizedBox(width: 4),
Text(
'👑 Восхождение к Престолу Архонта «${step.destinationArchon?.archonWord.word.toUpperCase()}»!',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 11, fontWeight: FontWeight.bold),
),
],
),
),
],
],
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+964
View File
@@ -0,0 +1,964 @@
import '../models/syncretic_session_model.dart';
import '../services/words_repository.dart';
import '../models/history_entry_model.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/houses_session_model.dart';
import '../models/word_model.dart';
import '../services/ai_oracle_service.dart';
import '../widgets/gradient_star_badge.dart';
import 'house_chains_screen.dart';
import 'matrix_geometry_screen.dart';
import '../services/oracle_master_prompt_generator.dart';
import 'symbol_detail_screen.dart';
class HousesResultScreen extends StatefulWidget {
final HousesSession session;
const HousesResultScreen({super.key, required this.session});
@override
State<HousesResultScreen> createState() => _HousesResultScreenState();
}
class _HousesResultScreenState extends State<HousesResultScreen> {
final _aiService = AiOracleService();
String? _aiInterpretation;
bool _isLoadingAi = false;
@override
void initState() {
super.initState();
// Auto-record Houses Session into active Syncretic Day Session
final session = widget.session;
final items = session.selectedHouses.map((h) {
final g = h.guestWord ?? h.houseWord;
return ReadingSignItem(
title: 'Дом #${(h.houseIndex + 1).toString().padLeft(3, "0")} «${g.word.toUpperCase()}»',
subtitle: 'Архонт: ${session.sectors[h.sectorIndex].archonWord.word}',
symbol: '🏛️',
lore: g.core.isNotEmpty ? g.core : g.description,
linkedWord: g,
);
}).toList();
WordsRepository().addReadingToSyncreticSession(
SyncreticReading(
id: 'reading_${session.id}',
systemId: 'matrix',
systemName: 'Матрица 33×30 (Дома)',
systemIcon: '🏛️',
timestamp: session.createdAt,
layoutTitle: '${items.length} ${items.length == 1 ? "дом" : "домов"}',
inputMethod: 'dice',
items: items,
question: session.question,
),
question: session.question,
);
// Auto-record Calculated House Chains into active Syncretic Day Session
if (session.chains.isNotEmpty) {
final chainItems = session.chains.map((chain) {
final pathStr = chain.steps.map((s) => '«${s.guest.word.toUpperCase()}»').join(' ➔ ');
final startWord = chain.startingHouse.houseWord;
return ReadingSignItem(
title: 'Цепочка #${chain.chainIndex}: ${chain.endTypeTitle}',
subtitle: 'Старт: Дом #${chain.startingHouse.houseIndex + 1} «${startWord.word}» • Шагов: ${chain.steps.length}',
symbol: chain.hasLoop ? '🔄' : (chain.endType == ChainEndType.archonThrone ? '👑' : '🔗'),
lore: 'Траектория: ' + pathStr + '\n' + chain.description,
linkedWord: chain.steps.isNotEmpty ? chain.steps.last.guest : startWord,
meta: {
'chainIndex': chain.chainIndex,
'endType': chain.endType.name,
'stepsCount': chain.steps.length,
'path': pathStr,
},
);
}).toList();
WordsRepository().addReadingToSyncreticSession(
SyncreticReading(
id: 'chains_${session.id}',
systemId: 'matrix_chains',
systemName: 'Матрица 33×30: Цепочки Домов',
systemIcon: '🔗',
timestamp: session.createdAt,
layoutTitle: '${session.chains.length} ${session.chains.length == 1 ? "цепочка" : (session.chains.length < 5 ? "цепочки" : "цепочек")}',
inputMethod: 'dice',
items: chainItems,
question: session.question,
),
question: session.question,
);
}
_aiInterpretation = widget.session.aiInterpretation;
}
Future<void> _requestAiInterpretation() async {
setState(() => _isLoadingAi = true);
HapticFeedback.lightImpact();
final buffer = StringBuffer();
buffer.writeln('Ты — верховный оракул, читающий расклад Домов 33×30 в традиции Оракула 999.');
buffer.writeln('В раскладе участвуют 3 уровня реальности:');
buffer.writeln('1. Небесный Архонт Сектора — высший закон и климат триады столбцов.');
buffer.writeln('2. Земной Дом — конкретная сфера и почва действия.');
buffer.writeln('3. Знак-Гость — сила, прибывшая в дом по жребию.\n');
buffer.writeln('ПОСЛЕДОВАТЕЛЬНОСТЬ ОТКРЫТЫХ ДОМОВ:');
for (int i = 0; i < widget.session.selectedHouses.length; i++) {
final h = widget.session.selectedHouses[i];
final sector = widget.session.sectors[h.sectorIndex];
final guest = h.guestWord!;
buffer.writeln(
'Позиция ${i + 1}: Архонт «${sector.archonWord.word.toUpperCase()}» ➔ Дом «${h.houseWord.word.toUpperCase()}» ➔ Гость «${guest.word.toUpperCase()}»',
);
if (guest.core.isNotEmpty) {
buffer.writeln(' Суть гостя: ${guest.core}');
}
}
if (widget.session.chains.isNotEmpty) {
buffer.writeln('\nЦЕПОЧКИ ДОМОВ:');
for (final c in widget.session.chains) {
buffer.writeln('• Цепочка #${c.chainIndex} (${c.hasLoop ? "петля" : "вектор"}): ${c.description}');
}
}
buffer.writeln('\nДай развернутое, цельное толкование расклада: как разворачивается путь от первого шага к исходу, как гости чувствуют себя в домах под властью своих Архонтов, и какой главный совет даёт оракул.');
try {
final res = await _aiService.interpretSession(
words: widget.session.selectedHouses
.map((h) => SessionWord.fromWordItem(
h.guestWord!,
positionTitle: 'Дом #${h.houseIndex + 1} «${h.houseWord.word}»',
))
.toList(),
question: widget.session.question ?? 'Расклад Домов 33×30',
);
if (mounted) {
setState(() {
_aiInterpretation = res;
_isLoadingAi = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_aiInterpretation = 'Ошибка связи с оракулом: $e';
_isLoadingAi = false;
});
}
}
}
void _openWordDossier(WordItem word) {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SymbolDetailScreen(word: word),
),
);
}
void _copyAll() {
final buffer = StringBuffer();
buffer.writeln('🔮 ОРАКУЛ — РАСКЛАД ДОМОВ 33×30');
if (widget.session.question != null && widget.session.question!.isNotEmpty) {
buffer.writeln('Вопрос: «${widget.session.question}»');
}
buffer.writeln('Открыто домов: ${widget.session.selectedHouses.length}\n');
for (int i = 0; i < widget.session.selectedHouses.length; i++) {
final h = widget.session.selectedHouses[i];
final sector = widget.session.sectors[h.sectorIndex];
final guest = h.guestWord!;
buffer.writeln(
'#${i + 1} [Сектор: Архонт ${sector.archonWord.word.toUpperCase()}]',
);
buffer.writeln(' 🏛️ Дом #${(h.houseIndex + 1).toString().padLeft(3, '0')} «${h.houseWord.word.toUpperCase()}»');
buffer.writeln(' ✨ Гость: «${guest.word.toUpperCase()}»');
if (guest.core.isNotEmpty) {
buffer.writeln(' Закон: ${guest.core}');
}
buffer.writeln();
}
if (_aiInterpretation != null) {
buffer.writeln('🤖 Толкование Оракула:\n$_aiInterpretation\n');
}
Clipboard.setData(ClipboardData(text: buffer.toString()));
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Полный расклад домов скопирован в буфер'),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final session = widget.session;
final activeSectorIndices = session.selectedHouses.map((h) => h.sectorIndex).toSet();
return Scaffold(
backgroundColor: const Color(0xFF0A0C16),
appBar: AppBar(
title: const Row(
children: [
GradientStar(size: 20),
SizedBox(width: 8),
Text('Расклад Домов 33×30', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)),
],
),
actions: [
IconButton(
tooltip: 'Цепочки Домов',
icon: Badge(
label: Text('${session.chains.length}'),
backgroundColor: const Color(0xFFFFD54F),
textColor: Colors.black,
child: const Icon(Icons.hub_outlined, color: Color(0xFFFFD54F)),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HouseChainsScreen(session: session),
),
);
},
),
IconButton(
tooltip: 'Узоры Матрицы 33×30',
icon: const Icon(Icons.auto_awesome_mosaic_rounded, color: Color(0xFFFFD54F)),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MatrixGeometryScreen(session: session),
),
);
},
),
IconButton(
tooltip: 'Скопировать расклад',
icon: const Icon(Icons.copy_rounded),
onPressed: _copyAll,
),
],
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 40),
children: [
// Querent (Significator) Card
_buildQuerentSection(session),
const SizedBox(height: 16),
// Revealed Archons Banner
_buildArchonsRevealSection(activeSectorIndices),
const SizedBox(height: 16),
// Houses sequence header
Row(
children: [
const Icon(Icons.format_list_numbered_rounded, color: Color(0xFFFFD54F), size: 18),
const SizedBox(width: 8),
Text(
'ПОСЛЕДОВАТЕЛЬНОСТЬ ДОМОВ (${session.selectedHouses.length}):',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w900,
fontSize: 13,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 12),
// Houses Cards
for (int i = 0; i < session.selectedHouses.length; i++) ...[
_buildHouseStepCard(session.selectedHouses[i], i + 1),
const SizedBox(height: 12),
],
const SizedBox(height: 12),
// 1. Master Prompt for Advanced LLMs (Grok / Claude / Gemini / GPT-4)
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF261D48),
foregroundColor: const Color(0xFFFFD54F),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.4),
),
elevation: 6,
shadowColor: const Color(0x66FFD54F),
),
onPressed: () => _showMasterPromptDialog(context, session),
icon: const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F), size: 22),
label: const Text(
'🧠 Мастер-Промпт для Нейросети (Grok / Claude / Gemini)',
style: TextStyle(fontWeight: FontWeight.w900, fontSize: 13.5, letterSpacing: 0.3),
),
),
const SizedBox(height: 12),
// 2. Matrix Geometry & Constellation Pattern
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF19203C),
foregroundColor: const Color(0xFF00E5FF),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFF00E5FF), width: 1.2),
),
elevation: 4,
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MatrixGeometryScreen(session: session),
),
);
},
icon: const Icon(Icons.auto_awesome_mosaic_rounded, color: Color(0xFF00E5FF)),
label: Text(
'Узор на Матрице 33×30 (${session.selectedHouses.length} узлов)',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14.5),
),
),
const SizedBox(height: 12),
// Action Button: House Chains Explorer
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1E2238),
foregroundColor: const Color(0xFFFFD54F),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2),
),
),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => HouseChainsScreen(session: session),
),
);
},
icon: const Icon(Icons.hub_outlined, color: Color(0xFFFFD54F)),
label: Text(
'Исследовать Цепочки Домов (${session.chains.length})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15),
),
),
const SizedBox(height: 16),
// AI Interpretation Section
_buildAiSection(),
],
),
);
}
Widget _buildArchonsRevealSection(Set<int> activeSectors) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF131627),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF3949AB).withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.shield_moon_rounded, color: Color(0xFFFFD54F), size: 20),
SizedBox(width: 8),
Text(
'АРХОНТЫ АКТИВНЫХ СЕКТОРОВ',
style: TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 13,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: activeSectors.map((sectorIdx) {
final sector = widget.session.sectors[sectorIdx];
return InkWell(
onTap: () => _openWordDossier(sector.archonWord),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: sector.sectorColor.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: sector.sectorColor, width: 1.2),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: sector.sectorColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'Триада ${sectorIdx + 1}: «${sector.archonWord.word.toUpperCase()}»',
style: TextStyle(
color: sector.sectorColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
],
),
),
);
}).toList(),
),
],
),
);
}
Widget _buildHouseStepCard(HouseCell cell, int order) {
final sector = widget.session.sectors[cell.sectorIndex];
final guest = cell.guestWord!;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF141729),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: sector.sectorColor.withValues(alpha: 0.4), width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.25),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Order, Archon triad
Row(
children: [
Container(
width: 26,
height: 26,
decoration: BoxDecoration(
color: const Color(0xFFFFD54F),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
'$order',
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.w900,
fontSize: 14,
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: sector.sectorColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'Под властью Архонта «${sector.archonWord.word.toUpperCase()}» (Триада ${cell.sectorIndex + 1})',
style: TextStyle(
color: sector.sectorColor,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Host & Guest Banner
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF1C2038),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
children: [
// Host row
InkWell(
onTap: () => _openWordDossier(cell.houseWord),
child: Row(
children: [
const Icon(Icons.home_work_rounded, color: Color(0xFF90A4AE), size: 18),
const SizedBox(width: 8),
Text(
'Дом #${(cell.houseIndex + 1).toString().padLeft(3, '0')}:',
style: const TextStyle(color: Color(0xFF90A4AE), fontSize: 13),
),
const SizedBox(width: 6),
Text(
cell.houseWord.word.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w900,
fontSize: 15,
),
),
const Spacer(),
const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Color(0xFF5C6280)),
],
),
),
const Divider(color: Color(0xFF2A2D4A), height: 16),
// Guest row
InkWell(
onTap: () => _openWordDossier(guest),
child: Row(
children: [
const Icon(Icons.auto_awesome_rounded, color: Color(0xFFFFD54F), size: 18),
const SizedBox(width: 8),
const Text(
'Пришедший Гость:',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 13, fontWeight: FontWeight.w600),
),
const SizedBox(width: 6),
Text(
guest.word.toUpperCase(),
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 16,
letterSpacing: 0.5,
),
),
const Spacer(),
const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Color(0xFFFFD54F)),
],
),
),
],
),
),
const SizedBox(height: 12),
// Guest Core / Meaning
if (guest.core.isNotEmpty) ...[
Text(
'✨ Закон знака: ${guest.core}',
style: const TextStyle(
color: Color(0xFFE0E0E0),
fontSize: 13,
height: 1.4,
),
),
const SizedBox(height: 8),
],
if (guest.oracle.isNotEmpty) ...[
Text(
guest.oracle,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFB0BEC5),
fontSize: 12,
height: 1.35,
),
),
],
],
),
);
}
Widget _buildAiSection() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const GradientStar(size: 20),
const SizedBox(width: 8),
const Text(
'Толкование Оракула (AI)',
style: TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
const Spacer(),
if (!_isLoadingAi)
IconButton(
tooltip: 'Запросить толкование',
icon: const Icon(Icons.auto_awesome, color: Color(0xFFFFD54F)),
onPressed: _requestAiInterpretation,
),
],
),
const SizedBox(height: 10),
if (_isLoadingAi) ...[
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
CircularProgressIndicator(color: Color(0xFFFFD54F)),
SizedBox(height: 12),
Text('Оракул синтезирует Архонтов, Дома и Цепочки...', style: TextStyle(color: Color(0xFF90A4AE), fontSize: 13)),
],
),
),
),
] else if (_aiInterpretation != null) ...[
Text(
_aiInterpretation!,
style: const TextStyle(color: Color(0xFFECEFF1), fontSize: 14, height: 1.5),
),
] else ...[
const Text(
'Нажмите кнопку ниже, чтобы нейросеть Оракула соединила энергии 10 Архонтов, открытых Домов и Цепочек судьбы в единое предсказание.',
style: TextStyle(color: Color(0xFF90A4AE), fontSize: 13, height: 1.4),
),
const SizedBox(height: 14),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2A2D4A),
foregroundColor: const Color(0xFFFFD54F),
minimumSize: const Size.fromHeight(44),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: Color(0xFFFFD54F), width: 0.8),
),
),
onPressed: _requestAiInterpretation,
icon: const Icon(Icons.auto_awesome_rounded),
label: const Text('Получить толкование Оракула'),
),
],
],
),
);
}
Widget _buildQuerentSection(HousesSession session) {
final q = session.querentWord;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF2A164D), Color(0xFF161933)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFFFD54F), width: 1.5),
boxShadow: const [
BoxShadow(
color: Color(0x55FFD54F),
blurRadius: 16,
offset: Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFFD54F).withValues(alpha: 0.18),
border: Border.all(color: const Color(0xFFFFD54F), width: 1.2),
),
child: const Icon(Icons.workspace_premium_rounded, color: Color(0xFFFFD54F), size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'КВЕРЕНТ / СИГНИФИКАТОР РАСКЛАДА',
style: TextStyle(
color: Color(0xFFFFD54F),
fontSize: 10.5,
fontWeight: FontWeight.w900,
letterSpacing: 1.0,
),
),
const SizedBox(height: 2),
Text(
q != null ? q.word.toUpperCase() : 'ОТКРЫТЫЙ ФОКУС',
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
),
],
),
),
if (session.querentHouseIndex != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF3B246B),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFFD54F), width: 0.8),
),
child: Text(
'Дом #${(session.querentHouseIndex! + 1).toString().padLeft(3, "0")}',
style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
if (q != null) ...[
const SizedBox(height: 10),
if (q.core.isNotEmpty) ...[
Text(
'Суть объекта: ${q.core}',
style: const TextStyle(color: Color(0xFFCFD8DC), fontSize: 13, height: 1.4),
),
const SizedBox(height: 4),
],
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
q.category,
style: const TextStyle(color: Color(0xFF9FA8DA), fontSize: 10.5),
),
),
const SizedBox(width: 8),
Text(
q.partOfSpeech,
style: const TextStyle(color: Color(0xFF78909C), fontSize: 11, fontStyle: FontStyle.italic),
),
],
),
],
if (session.question != null && session.question!.trim().isNotEmpty) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF101323),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF33385C), width: 0.8),
),
child: Row(
children: [
const Icon(Icons.help_outline_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Вопрос: «${session.question!.trim()}»',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 12.5, fontWeight: FontWeight.w500),
),
),
],
),
),
],
],
),
);
}
void _showMasterPromptDialog(BuildContext context, HousesSession session) {
HapticFeedback.mediumImpact();
final prompt = OracleMasterPromptGenerator.generate(session);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF0F1222),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F), size: 24),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Мастер-Промпт для Нейросети',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'Для Grok, Claude 3.5 Sonnet, Gemini 1.5 Pro, GPT-4',
style: TextStyle(color: Color(0xFF90A4AE), fontSize: 11),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF90A4AE)),
onPressed: () => Navigator.pop(ctx),
),
],
),
const SizedBox(height: 12),
// Quick Actions Bar
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1C223D),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${prompt.length} символов • ${prompt.split(RegExp(r"\s+")).length} слов',
style: const TextStyle(color: Color(0xFF90CAF9), fontSize: 11),
),
),
const Spacer(),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: () {
Clipboard.setData(ClipboardData(text: prompt));
HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Мастер-промпт скопирован в буфер обмена! Вставьте в Grok или Claude.'),
backgroundColor: Color(0xFF388E3C),
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.copy_rounded, size: 16),
label: const Text('Скопировать всё', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12.5)),
),
],
),
const SizedBox(height: 12),
// Scrollable Prompt Text Area
Expanded(
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF090A14),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF232742), width: 1.0),
),
child: SingleChildScrollView(
controller: scrollController,
child: SelectableText(
prompt,
style: const TextStyle(
color: Color(0xFFECEFF1),
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.45,
),
),
),
),
),
],
),
);
},
);
},
);
}
}
+525
View File
@@ -0,0 +1,525 @@
import '../widgets/divination_instruction_modal.dart';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/syncretic_session_model.dart';
import '../models/tradition_model.dart';
import '../models/word_model.dart';
import '../services/traditions_repository.dart';
import '../services/words_repository.dart';
class IChingDivinationScreen extends StatefulWidget {
final String? initialQuestion;
const IChingDivinationScreen({super.key, this.initialQuestion});
@override
State<IChingDivinationScreen> createState() => _IChingDivinationScreenState();
}
class _IChingDivinationScreenState extends State<IChingDivinationScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final _questionController = TextEditingController();
final _repo = WordsRepository();
List<TraditionStation> _hexagrams = [];
// Six lines (from bottom line 1 to top line 6). Values: 6 (old yin), 7 (young yang), 8 (young yin), 9 (old yang)
List<int> _lines = [];
bool _isTossingCoins = false;
List<int> _currentCoins = [3, 3, 3]; // 3 coins (2=tail, 3=head)
TraditionStation? _resultStation;
bool _savedToSession = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
if (widget.initialQuestion != null) {
_questionController.text = widget.initialQuestion!;
}
_loadHexagrams();
}
void _loadHexagrams() {
final canon = TraditionsRepository().byId('iching');
if (canon != null) {
_hexagrams = canon.stations;
}
}
@override
void dispose() {
_tabController.dispose();
_questionController.dispose();
super.dispose();
}
Future<void> _tossCoinsStep() async {
if (_lines.length >= 6) return;
setState(() => _isTossingCoins = true);
HapticFeedback.mediumImpact();
final rnd = Random();
for (int i = 0; i < 6; i++) {
await Future.delayed(const Duration(milliseconds: 50));
if (mounted) {
setState(() {
_currentCoins = [
rnd.nextBool() ? 3 : 2,
rnd.nextBool() ? 3 : 2,
rnd.nextBool() ? 3 : 2,
];
});
}
}
final finalCoins = [
rnd.nextBool() ? 3 : 2,
rnd.nextBool() ? 3 : 2,
rnd.nextBool() ? 3 : 2,
];
final sum = finalCoins.reduce((a, b) => a + b); // 6, 7, 8, or 9
if (mounted) {
setState(() {
_currentCoins = finalCoins;
_lines.add(sum);
_isTossingCoins = false;
if (_lines.length == 6) {
_resolveHexagram('dice');
}
});
HapticFeedback.heavyImpact();
}
}
void _addLineManually(int val) {
if (_lines.length >= 6) return;
setState(() {
_lines.add(val);
if (_lines.length == 6) {
_resolveHexagram('manual');
}
});
HapticFeedback.selectionClick();
}
void _resetLines() {
setState(() {
_lines.clear();
_resultStation = null;
_savedToSession = false;
});
}
void _resolveHexagram(String inputMethod) {
if (_hexagrams.isEmpty || _lines.length < 6) return;
// Map 6 lines to binary / trigrams
// Lower trigram: lines 0, 1, 2
// Upper trigram: lines 3, 4, 5
// For now find closest matching hexagram in canon
final hexIndex = (_lines.fold(0, (acc, val) => acc + val) % _hexagrams.length);
final station = _hexagrams[hexIndex];
setState(() {
_resultStation = station;
});
_saveToSession(inputMethod);
}
Future<void> _saveToSession(String inputMethod) async {
if (_resultStation == null) return;
final st = _resultStation!;
WordItem? linkedWord;
if (st.primary != null) {
linkedWord = _repo.getWordById(st.primary!.id) ?? _repo.getWordByName(st.primary!.word);
}
final linesStr = _lines.join('-');
final item = ReadingSignItem(
title: st.name,
subtitle: 'Линии: $linesStr',
symbol: st.symbol,
lore: st.lore,
linkedWord: linkedWord,
);
final reading = SyncreticReading(
id: 'reading_${DateTime.now().millisecondsSinceEpoch}',
systemId: 'iching',
systemName: 'И Цзин (Книга Перемен)',
systemIcon: '☯️',
timestamp: DateTime.now(),
layoutTitle: '6 линий: [$linesStr]',
inputMethod: inputMethod,
items: [item],
question: _questionController.text.trim(),
);
await _repo.addReadingToSyncreticSession(reading, question: _questionController.text.trim());
if (mounted) {
setState(() => _savedToSession = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Гексаграмма «${st.name}» добавлена в сессию дня!'),
backgroundColor: const Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
title: const Row(
children: [
Text('☯️', style: TextStyle(fontSize: 22)),
SizedBox(width: 8),
Text('И Цзин (3 монеты × 6)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)),
],
),
actions: [
IconButton(
tooltip: 'Инструкция по И Цзин',
icon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F)),
onPressed: () => DivinationInstructionModal.show(context, 'iching'),
),
if (_lines.isNotEmpty)
IconButton(
tooltip: 'Сбросить черты',
icon: const Icon(Icons.refresh_rounded),
onPressed: _resetLines,
),
],
bottom: TabBar(
controller: _tabController,
indicatorColor: const Color(0xFFFFD54F),
tabs: const [
Tab(icon: Icon(Icons.toll_rounded, size: 18), text: 'Бросок 3 монет'),
Tab(icon: Icon(Icons.touch_app_rounded, size: 18), text: 'Ввод черт (6..9)'),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _questionController,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'Вопрос к Книге Перемен...',
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 13),
prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 18),
filled: true,
fillColor: const Color(0xFF151829),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
),
),
),
const SizedBox(height: 16),
// Hexagram Visualizer Box (bottom to top: line 1 at bottom, line 6 at top)
_buildHexagramVisualizer(),
const SizedBox(height: 16),
// Controls Tabs
SizedBox(
height: 220,
child: TabBarView(
controller: _tabController,
children: [
_buildCoinTossTab(),
_buildManualLinesTab(),
],
),
),
const SizedBox(height: 16),
if (_resultStation != null) _buildResultCard(),
],
),
),
);
}
Widget _buildHexagramVisualizer() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'ПОСТРОЕНИЕ ГЕКСАГРАММЫ (${_lines.length}/6)',
style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold),
),
if (_lines.length == 6)
const Text('Завершена ✓', style: TextStyle(color: Color(0xFF81C784), fontSize: 11, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 12),
// Lines from top (line 6) to bottom (line 1)
for (int i = 5; i >= 0; i--)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 28,
child: Text(
'${i + 1}.',
style: TextStyle(
color: (i < _lines.length) ? const Color(0xFFFFD54F) : const Color(0xFF5C6285),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
Expanded(
child: (i < _lines.length)
? _renderLineGraphic(_lines[i])
: Container(
height: 8,
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 24,
child: Text(
(i < _lines.length) ? '${_lines[i]}' : '',
style: const TextStyle(color: Color(0xFF90A4AE), fontSize: 11, fontWeight: FontWeight.bold),
),
),
],
),
),
],
),
);
}
Widget _renderLineGraphic(int val) {
// 7 = young yang (solid)
// 9 = old yang (solid with circle/mutating)
// 8 = young yin (broken)
// 6 = old yin (broken with X/mutating)
final isYang = (val == 7 || val == 9);
final isMutating = (val == 6 || val == 9);
final color = isMutating ? const Color(0xFFFF8A65) : const Color(0xFFFFD54F);
if (isYang) {
return Container(
height: 10,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(5),
),
);
} else {
// Broken line
return Row(
children: [
Expanded(
child: Container(
height: 10,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(5)),
),
),
const SizedBox(width: 14),
Expanded(
child: Container(
height: 10,
decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(5)),
),
),
],
);
}
}
Widget _buildCoinTossTab() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final c in _currentCoins)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFF4A3E1E),
border: Border.all(color: const Color(0xFFFFD54F), width: 1.5),
),
child: Text(
c == 3 ? 'О (3)' : 'Р (2)',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 12, fontWeight: FontWeight.bold),
),
),
),
],
),
const SizedBox(height: 16),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: (_lines.length >= 6 || _isTossingCoins) ? null : _tossCoinsStep,
icon: const Icon(Icons.toll_rounded, size: 18),
label: Text(
_lines.length >= 6
? 'Все 6 линий брошены'
: (_isTossingCoins ? 'Монеты звенят...' : 'Бросить 3 монеты (линия ${_lines.length + 1})'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
);
}
Widget _buildManualLinesTab() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Text(
'Нажмите выпавшую сумму броска со стола (${_lines.length + 1}-я черта):',
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
),
const SizedBox(height: 12),
Row(
children: [
_buildManualLineButton('6 (Старая Инь ✕)', 6, Colors.redAccent),
const SizedBox(width: 8),
_buildManualLineButton('7 (Молодая Ян ⚊)', 7, Colors.amber),
],
),
const SizedBox(height: 8),
Row(
children: [
_buildManualLineButton('8 (Молодая Инь ⚋)', 8, Colors.blueAccent),
const SizedBox(width: 8),
_buildManualLineButton('9 (Старая Ян ◯)', 9, Colors.deepOrangeAccent),
],
),
],
),
);
}
Widget _buildManualLineButton(String label, int val, Color color) {
return Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: color,
side: BorderSide(color: color.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: _lines.length >= 6 ? null : () => _addLineManually(val),
child: Text(label, style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.bold)),
),
);
}
Widget _buildResultCard() {
final st = _resultStation!;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F)),
),
child: Text(st.symbol, style: const TextStyle(fontSize: 28, color: Color(0xFFFFD54F))),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(st.name, style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(st.group, style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 12)),
],
),
),
],
),
const SizedBox(height: 14),
const Divider(color: Color(0xFF2A2D4A)),
const SizedBox(height: 10),
Text(st.lore, style: const TextStyle(color: Color(0xFFE0E2EC), fontSize: 14, height: 1.5)),
if (st.primary != null) ...[
const SizedBox(height: 14),
Row(
children: [
const Icon(Icons.link_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 6),
Text(
'Связан со знаком 999: #${st.primary!.id} «${st.primary!.word.toUpperCase()}»',
style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
],
],
),
);
}
}
@@ -0,0 +1,663 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/divination_layout.dart';
import '../models/syncretic_session_model.dart';
import '../models/tradition_model.dart';
import '../models/word_model.dart';
import '../services/traditions_repository.dart';
import '../services/words_repository.dart';
import '../widgets/divination_instruction_modal.dart';
import 'lenormand_station_screen.dart';
class LenormandDivinationScreen extends StatefulWidget {
final String? initialQuestion;
const LenormandDivinationScreen({super.key, this.initialQuestion});
@override
State<LenormandDivinationScreen> createState() => _LenormandDivinationScreenState();
}
class _LenormandDivinationScreenState extends State<LenormandDivinationScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final _questionController = TextEditingController();
final _directCardNumberController = TextEditingController();
final _repo = WordsRepository();
List<TraditionStation> _cards = [];
// Layouts: 2 cards (pair), 3 cards (line), 5 cards (cross)
DivinationLayout _selectedLayout = DivinationLayouts.lenormandLayouts[0];
int _currentStepIndex = 0;
// Dice simulation (d6 + d12)
int? _d6Value;
int? _d12Value;
bool _isRollingDice = false;
// Drawn results
List<({TraditionStation card, int cardNumber, String positionTitle})> _drawnResults = [];
bool _savedToSession = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
if (widget.initialQuestion != null) {
_questionController.text = widget.initialQuestion!;
}
_loadCards();
}
void _loadCards() {
final canon = TraditionsRepository().byId('lenormand');
if (canon != null) {
_cards = canon.stations;
}
}
@override
void dispose() {
_tabController.dispose();
_questionController.dispose();
_directCardNumberController.dispose();
super.dispose();
}
int _calculateCardNumber(int d6, int d12) {
int base = 0;
if (d6 == 3 || d6 == 4) base = 12;
if (d6 == 5 || d6 == 6) base = 24;
return (base + d12).clamp(1, 36);
}
void _onLayoutChanged(DivinationLayout layout) {
setState(() {
_selectedLayout = layout;
_currentStepIndex = 0;
_drawnResults.clear();
_d6Value = null;
_d12Value = null;
_savedToSession = false;
});
}
Future<void> _rollDiceForStep() async {
if (_cards.isEmpty || _currentStepIndex >= _selectedLayout.count) return;
setState(() => _isRollingDice = true);
HapticFeedback.mediumImpact();
final rnd = Random();
for (int i = 0; i < 8; i++) {
await Future.delayed(const Duration(milliseconds: 50));
if (mounted) {
setState(() {
_d6Value = rnd.nextInt(6) + 1;
_d12Value = rnd.nextInt(12) + 1;
});
}
}
int finalD6 = rnd.nextInt(6) + 1;
int finalD12 = rnd.nextInt(12) + 1;
int cardNum = _calculateCardNumber(finalD6, finalD12);
// If card already drawn in this spread, pick next available card (no duplicates in a single layout)
final alreadyDrawnNums = _drawnResults.map((r) => r.cardNumber).toSet();
if (alreadyDrawnNums.contains(cardNum)) {
for (int i = 1; i <= 36; i++) {
final candidate = (cardNum + i - 1) % 36 + 1;
if (!alreadyDrawnNums.contains(candidate)) {
cardNum = candidate;
break;
}
}
}
final card = (cardNum - 1 < _cards.length) ? _cards[cardNum - 1] : null;
if (card != null && mounted) {
final posTitle = _selectedLayout.positionTitles[_currentStepIndex];
setState(() {
_d6Value = finalD6;
_d12Value = finalD12;
_isRollingDice = false;
_drawnResults.add((card: card, cardNumber: cardNum, positionTitle: posTitle));
_currentStepIndex++;
if (_currentStepIndex >= _selectedLayout.count) {
_saveToSession('dice');
}
});
HapticFeedback.heavyImpact();
}
}
void _applyManualCardNumber(int cardNum) {
if (cardNum < 1 || cardNum > 36 || _cards.isEmpty || _currentStepIndex >= _selectedLayout.count) return;
final card = (cardNum - 1 < _cards.length) ? _cards[cardNum - 1] : null;
if (card == null) return;
final posTitle = _selectedLayout.positionTitles[_currentStepIndex];
setState(() {
_drawnResults.add((card: card, cardNumber: cardNum, positionTitle: posTitle));
_currentStepIndex++;
_directCardNumberController.clear();
if (_currentStepIndex >= _selectedLayout.count) {
_saveToSession('manual');
}
});
HapticFeedback.selectionClick();
}
void _resetSpread() {
setState(() {
_currentStepIndex = 0;
_drawnResults.clear();
_d6Value = null;
_d12Value = null;
_savedToSession = false;
});
}
Future<void> _saveToSession(String inputMethod) async {
if (_drawnResults.isEmpty) return;
final items = _drawnResults.map((r) {
WordItem? linkedWord;
if (r.card.primary != null) {
linkedWord = _repo.getWordById(r.card.primary!.id) ?? _repo.getWordByName(r.card.primary!.word);
}
return ReadingSignItem(
title: '${r.positionTitle}: #${r.cardNumber} «${r.card.name}»',
subtitle: r.card.group,
symbol: '#${r.cardNumber}',
lore: r.card.lore,
linkedWord: linkedWord,
);
}).toList();
final reading = SyncreticReading(
id: 'reading_${DateTime.now().millisecondsSinceEpoch}',
systemId: 'lenormand',
systemName: 'Малый Оракул Ленорман',
systemIcon: '🪶',
timestamp: DateTime.now(),
layoutTitle: '${_selectedLayout.title} (${inputMethod == "dice" ? "d6+d12" : "со стола"})',
inputMethod: inputMethod,
items: items,
question: _questionController.text.trim(),
);
await _repo.addReadingToSyncreticSession(reading, question: _questionController.text.trim());
if (mounted) {
setState(() => _savedToSession = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Расклад Ленорман «${_selectedLayout.title}» сохранён в сессию дня!'),
backgroundColor: const Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
}
Future<void> _autoDealFullTableau() async {
if (_cards.length < 36) return;
setState(() => _isRollingDice = true);
HapticFeedback.mediumImpact();
final rnd = Random();
final shuffledIndices = List<int>.generate(36, (i) => i)..shuffle(rnd);
_drawnResults.clear();
for (int i = 0; i < 36; i++) {
final cardIdx = shuffledIndices[i];
final card = _cards[cardIdx];
final cardNum = cardIdx + 1;
final posTitle = _selectedLayout.positionTitles[i];
_drawnResults.add((card: card, cardNumber: cardNum, positionTitle: posTitle));
}
if (mounted) {
setState(() {
_isRollingDice = false;
_currentStepIndex = 36;
});
HapticFeedback.heavyImpact();
_saveToSession('dice');
}
}
@override
Widget build(BuildContext context) {
final isSpreadComplete = _currentStepIndex >= _selectedLayout.count;
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
actions: [
IconButton(
tooltip: 'Инструкция по Ленорман',
icon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F)),
onPressed: () => DivinationInstructionModal.show(context, 'lenormand'),
),
if (_drawnResults.isNotEmpty)
IconButton(
tooltip: 'Сбросить расклад',
icon: const Icon(Icons.refresh_rounded),
onPressed: _resetSpread,
),
],
title: const Row(
children: [
Text('🪶', style: TextStyle(fontSize: 22)),
SizedBox(width: 8),
Text('Ленорман (d6 + d12)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)),
],
),
bottom: TabBar(
controller: _tabController,
indicatorColor: const Color(0xFFFFD54F),
tabs: const [
Tab(icon: Icon(Icons.casino_rounded, size: 18), text: 'Жребий d6+d12'),
Tab(icon: Icon(Icons.touch_app_rounded, size: 18), text: 'Ввод 1..36 со стола'),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _questionController,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'Вопрос к оракулу Ленорман...',
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 13),
prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 18),
filled: true,
fillColor: const Color(0xFF151829),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
),
),
),
const SizedBox(height: 14),
// Layout Picker Row
Row(
children: [
const Icon(Icons.dashboard_customize_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 6),
const Text('РАСКЛАД:', style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: DivinationLayouts.lenormandLayouts.map((l) {
final isSel = _selectedLayout.id == l.id;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(l.title, style: TextStyle(fontSize: 11.5, fontWeight: isSel ? FontWeight.bold : FontWeight.normal)),
selected: isSel,
selectedColor: const Color(0xFF4A3E1E),
side: BorderSide(color: isSel ? const Color(0xFFFFD54F) : const Color(0xFF2A2D4A)),
onSelected: (_) => _onLayoutChanged(l),
),
);
}).toList(),
),
),
),
],
),
const SizedBox(height: 14),
// Progress Banner
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Row(
children: [
Text(
'Позиция: ${_currentStepIndex + 1} из ${_selectedLayout.count}',
style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (!isSpreadComplete)
Expanded(
child: Text(
'«${_selectedLayout.positionTitles[_currentStepIndex]}»',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 12, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
)
else
const Text('✓ Расклад собран', style: TextStyle(color: Color(0xFF81C784), fontSize: 12, fontWeight: FontWeight.bold)),
],
),
),
const SizedBox(height: 14),
if (!isSpreadComplete)
SizedBox(
height: 290,
child: TabBarView(
controller: _tabController,
children: [
_buildDiceTab(),
_buildManualTab(),
],
),
),
if (isSpreadComplete) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF81C784)),
),
child: Row(
children: [
const Icon(Icons.check_circle_rounded, color: Color(0xFF81C784), size: 20),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Все карты расклада извлечены и записаны в сессию дня!',
style: TextStyle(color: Color(0xFFECEFF1), fontSize: 12.5, fontWeight: FontWeight.bold),
),
),
TextButton(
onPressed: _resetSpread,
child: const Text('Заново', style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.bold)),
),
],
),
),
],
const SizedBox(height: 16),
if (_drawnResults.isNotEmpty) ...[
const Text(
'ВЫПАВШИЕ КАРТЫ В РАСКЛАДЕ:',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11.5, fontWeight: FontWeight.w900, letterSpacing: 0.8),
),
const SizedBox(height: 10),
for (int i = 0; i < _drawnResults.length; i++) ...[
_buildResultCardItem(_drawnResults[i], i + 1),
const SizedBox(height: 10),
],
],
],
),
),
);
}
Widget _buildDiceTab() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'БРОСОК d6 + d12 (1..36 РАВНОМЕРНО)',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.8),
),
const SizedBox(height: 6),
Text(
'Извлечение позиции: «' + _selectedLayout.positionTitles[_currentStepIndex] + '»',
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11.5),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildDieBox('d6 (блок)', _d6Value, isD6: true),
const SizedBox(width: 20),
_buildDieBox('d12 (номер)', _d12Value),
],
),
const SizedBox(height: 18),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: _isRollingDice ? null : _rollDiceForStep,
icon: const Icon(Icons.casino_rounded, size: 20),
label: Text(
_isRollingDice ? 'Кости летят...' : 'Бросить d6+d12 (шаг ${_currentStepIndex + 1})',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
),
if (_selectedLayout.count >= 9) ...[
const SizedBox(height: 10),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF80DEEA),
side: const BorderSide(color: Color(0xFF80DEEA)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _isRollingDice ? null : _autoDealFullTableau,
icon: const Icon(Icons.auto_awesome_rounded, size: 16),
label: Text(
_selectedLayout.count == 36 ? 'Случайный расклад всех 36 Домов' : 'Разложить весь квадрат 3×3',
style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.bold),
),
),
],
],
),
);
}
Widget _buildDieBox(String label, int? val, {bool isD6 = false}) {
String sub = '';
if (val != null && isD6) {
if (val <= 2) sub = 'База +0 (1..12)';
else if (val <= 4) sub = 'База +12 (13..24)';
else sub = 'База +24 (25..36)';
}
return Column(
children: [
Container(
width: 68,
height: 68,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.6), width: 1.5),
),
child: Text(
val != null ? '$val' : '?',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 26, fontWeight: FontWeight.w900),
),
),
const SizedBox(height: 6),
Text(label, style: const TextStyle(color: Color(0xFF90A4AE), fontSize: 10, fontWeight: FontWeight.bold)),
if (sub.isNotEmpty)
Text(sub, style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 9, fontWeight: FontWeight.bold)),
],
);
}
Widget _buildManualTab() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: TextField(
controller: _directCardNumberController,
keyboardType: TextInputType.number,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'Номер карты для шага ${_currentStepIndex + 1} (1..36)...',
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
isDense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
filled: true,
fillColor: const Color(0xFF1E2238),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
onSubmitted: (val) {
final n = int.tryParse(val.trim());
if (n != null) _applyManualCardNumber(n);
},
),
),
const SizedBox(width: 8),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
final n = int.tryParse(_directCardNumberController.text.trim());
if (n != null) _applyManualCardNumber(n);
},
child: const Text('Ввод', style: TextStyle(fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: 8),
Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 1.0,
),
itemCount: _cards.length,
itemBuilder: (ctx, i) {
return InkWell(
onTap: () => _applyManualCardNumber(i + 1),
borderRadius: BorderRadius.circular(8),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Text(
'${i + 1}',
style: const TextStyle(color: Color(0xFFE0E2EC), fontSize: 14, fontWeight: FontWeight.bold),
),
),
);
},
),
),
],
),
);
}
Widget _buildResultCardItem(({TraditionStation card, int cardNumber, String positionTitle}) res, int index) {
final c = res.card;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFFD54F)),
),
child: Text(
'#${res.cardNumber}',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFFFFD54F)),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'#$index. ${res.positionTitle}: ${c.name}',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 14, fontWeight: FontWeight.bold),
),
),
IconButton(
icon: const Icon(Icons.auto_stories_rounded, size: 18, color: Color(0xFFFFD54F)),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
tooltip: 'Сочетания карты',
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => LenormandStationScreen(number: res.cardNumber),
),
);
},
),
],
),
const SizedBox(height: 4),
Text(c.group, style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 11)),
const SizedBox(height: 6),
Text(
c.lore,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFD1D5DB), fontSize: 12, height: 1.4),
),
],
),
),
],
),
);
}
}
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/lenormand_pairs_repository.dart';
import 'lenormand_station_screen.dart';
class LenormandExtrasScreen extends StatelessWidget {
const LenormandExtrasScreen({super.key});
@override
Widget build(BuildContext context) {
final repo = LenormandPairsRepository();
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(title: const Text('Ленорман · триады и слои')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
const Text(
'Триады',
style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.w900, fontSize: 16),
),
const SizedBox(height: 6),
const Text(
'Тройные сочетания с Карусели (порядок карт в раскладе подряд).',
style: TextStyle(color: Color(0x8AFFFFFF), fontSize: 12, height: 1.4),
),
const SizedBox(height: 10),
for (final t in repo.triads)
Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x1AFFFFFF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 6,
children: [
for (var i = 0; i < t.cards.length; i++)
ActionChip(
label: Text('${t.cards[i]}. ${t.names[i]}'),
onPressed: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => LenormandStationScreen(number: t.cards[i]),
),
);
},
),
],
),
const SizedBox(height: 8),
Text(
t.text,
style: const TextStyle(color: Color(0xFFE8EAF6), fontSize: 13, height: 1.45),
),
],
),
),
const SizedBox(height: 16),
const Text(
'Дополнительные разделы',
style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.w900, fontSize: 16),
),
const SizedBox(height: 10),
for (final e in repo.extras)
Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
collapsedBackgroundColor: const Color(0xFF151829),
backgroundColor: const Color(0xFF151829),
collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0x1AFFFFFF)),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0x4DFFD54F)),
),
title: Text(
e.title.isEmpty ? e.source : e.title,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
child: Text(
e.text,
style: const TextStyle(color: Color(0xFFD1D5DB), fontSize: 13, height: 1.5),
),
),
],
),
),
],
),
);
}
}
+158
View File
@@ -0,0 +1,158 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/lenormand_pairs.dart';
import '../services/lenormand_pairs_repository.dart';
import 'lenormand_station_screen.dart';
class LenormandPairScreen extends StatelessWidget {
final int left;
final int right;
final LenormandCardEntry? Function(int n)? lookup;
final String title;
const LenormandPairScreen({
super.key,
required this.left,
required this.right,
this.lookup,
this.title = 'Сочетание',
});
@override
Widget build(BuildContext context) {
final lookup = this.lookup ?? (n) => LenormandPairsRepository().byNumber(n);
final a = lookup(left);
final b = lookup(right);
LenormandPairLink? link;
if (a != null) {
for (final p in a.pairs) {
if (p.n == right) {
link = p;
break;
}
}
}
if (a == null || b == null || link == null) {
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(title: Text(title)),
body: const Center(
child: Text('Пара не найдена', style: TextStyle(color: Color(0xFF8C93B8))),
),
);
}
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
title: Text('${a.name} + ${b.name}'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
Row(
children: [
Expanded(child: _CardChip(n: a.n, name: a.name, symbol: a.symbol)),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: Text('+', style: TextStyle(color: Color(0xFFFFD54F), fontSize: 22, fontWeight: FontWeight.w900)),
),
Expanded(child: _CardChip(n: b.n, name: b.name, symbol: b.symbol)),
],
),
const SizedBox(height: 8),
Row(
children: [
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LenormandStationScreen(
number: a.n,
lookup: lookup,
title: title,
),
),
);
},
child: Text('← ${a.name}'),
),
const Spacer(),
TextButton(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => LenormandStationScreen(
number: b.n,
lookup: lookup,
title: title,
),
),
);
},
child: Text('${b.name} →'),
),
],
),
const SizedBox(height: 14),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Text(
link.displayText,
style: const TextStyle(
color: Color(0xFFE8EAF6),
fontSize: 14,
height: 1.5,
),
),
),
],
),
);
}
}
class _CardChip extends StatelessWidget {
final int n;
final String name;
final String symbol;
const _CardChip({required this.n, required this.name, required this.symbol});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Column(
children: [
Text(symbol, style: const TextStyle(fontSize: 22)),
const SizedBox(height: 4),
Text(
'$n. $name',
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
],
),
);
}
}
+263
View File
@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/lenormand_pairs.dart';
import '../services/lenormand_pairs_repository.dart';
import 'lenormand_pair_screen.dart';
class LenormandStationScreen extends StatefulWidget {
final int number;
final LenormandCardEntry? Function(int n)? lookup;
final String title;
const LenormandStationScreen({
super.key,
required this.number,
this.lookup,
this.title = 'Ленорман',
});
@override
State<LenormandStationScreen> createState() => _LenormandStationScreenState();
}
class _LenormandStationScreenState extends State<LenormandStationScreen> {
late int _n;
String _query = '';
@override
void initState() {
super.initState();
_n = widget.number;
}
@override
Widget build(BuildContext context) {
final lookup = widget.lookup ?? (n) => LenormandPairsRepository().byNumber(n);
final card = lookup(_n);
if (card == null) {
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(title: Text(widget.title)),
body: const Center(
child: Text('Карта не найдена', style: TextStyle(color: Color(0xFF8C93B8))),
),
);
}
final pairs = card.pairs.where((p) {
if (_query.isEmpty) return true;
final q = _query.toLowerCase();
return p.name.toLowerCase().contains(q) ||
p.preview.toLowerCase().contains(q) ||
'${p.n}'.contains(q);
}).toList();
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
title: Text('${card.n}. ${card.name}'),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
children: [
Row(
children: [
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x1AFFD54F),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Text(card.symbol, style: const TextStyle(fontSize: 24)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
card.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 20,
),
),
if (card.lore.isNotEmpty)
Text(
card.lore,
style: const TextStyle(
color: Color(0xFF90CDF4),
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 18),
const _SectionTitle('Значение'),
const SizedBox(height: 8),
Text(
card.displayMeaning,
style: const TextStyle(color: Color(0xFFD1D5DB), fontSize: 14, height: 1.5),
),
if (card.shadow.isNotEmpty) ...[
const SizedBox(height: 16),
const _SectionTitle('Тень'),
const SizedBox(height: 8),
Text(
card.shadow,
style: const TextStyle(color: Color(0xFFC5CAE9), fontSize: 13, height: 1.5),
),
],
if (card.pairs.isNotEmpty) ...[
const SizedBox(height: 22),
_SectionTitle('Сочетания (${card.pairs.length})'),
const SizedBox(height: 6),
Text(
'Пара с каждой из остальных ${card.pairs.length} карт. Нажмите, чтобы открыть толкование.',
style: TextStyle(color: Color(0x8AFFFFFF), fontSize: 12, height: 1.4),
),
const SizedBox(height: 10),
TextField(
onChanged: (v) => setState(() => _query = v.trim()),
style: const TextStyle(color: Color(0xFFE8EAF6), fontSize: 14),
decoration: InputDecoration(
hintText: 'Поиск по второй карте…',
hintStyle: const TextStyle(color: Color(0xFF8C93B8)),
filled: true,
fillColor: const Color(0xFF151829),
prefixIcon: const Icon(Icons.search, color: Color(0xFFFFD54F)),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0x1AFFFFFF)),
),
),
),
const SizedBox(height: 12),
for (final p in pairs)
_PairTile(
card: card,
link: p,
onOpen: () {
HapticFeedback.selectionClick();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => LenormandPairScreen(
left: card.n,
right: p.n,
lookup: lookup,
title: widget.title,
),
),
);
},
),
],
],
),
);
}
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) {
return Text(
text,
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w900,
fontSize: 16,
letterSpacing: 0.3,
),
);
}
}
class _PairTile extends StatelessWidget {
final LenormandCardEntry card;
final LenormandPairLink link;
final VoidCallback onOpen;
const _PairTile({
required this.card,
required this.link,
required this.onOpen,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x1AFFFFFF)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 36,
child: Text(
'${link.n}',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w900,
fontSize: 14,
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${card.name} + ${link.name}',
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w800,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
link.displayText,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFC5CAE9),
fontSize: 12,
height: 1.4,
),
),
],
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFF8C93B8)),
],
),
),
),
),
);
}
}
+963
View File
@@ -0,0 +1,963 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/word_model.dart';
import '../models/word_passport_model.dart';
import '../services/passport_schema_dart.dart';
import '../services/words_repository.dart';
import '../widgets/gradient_star_badge.dart';
/// Ручной обогатитель единого паспорта знака «Оракула 999»
/// Полноценная система окошек и предпромптов по всем 6 разделам канона (78 полей).
class ManualDiveScreen extends StatefulWidget {
final WordItem? initialWord;
const ManualDiveScreen({super.key, this.initialWord});
@override
State<ManualDiveScreen> createState() => _ManualDiveScreenState();
}
class _ManualDiveScreenState extends State<ManualDiveScreen> with SingleTickerProviderStateMixin {
final _repo = WordsRepository();
final _searchController = TextEditingController();
late final TabController _tabController;
// 78 контроллеров по ключам полей
final Map<String, TextEditingController> _controllers = {};
// Контроллер для умной вставки сырого текста
final _pasteRawController = TextEditingController();
WordItem? _selectedWord;
bool _isSaving = false;
final List<String> _tabTitles = const [
'I. Метаданные (4)',
'II. Мантика (17)',
'III. Языки (17)',
'IV. Матрица (3)',
'V. Культуры (33)',
'VI. Синтез (4)',
'📄 Паспорт',
];
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabTitles.length, vsync: this);
// Инициализируем контроллеры для всех 78 полей
for (final field in kPassportFields78) {
_controllers[field.key] = TextEditingController();
_controllers[field.key]!.addListener(_onFieldChanged);
}
// Если передан начальный знак, выбираем его
if (widget.initialWord != null) {
_selectWord(widget.initialWord!);
} else if (_repo.allWords.isNotEmpty) {
_selectWord(_repo.allWords.first);
}
}
void _onFieldChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_pasteRawController.dispose();
for (final c in _controllers.values) {
c.removeListener(_onFieldChanged);
c.dispose();
}
super.dispose();
}
void _selectWord(WordItem word) {
final fresh = _repo.getWordByName(word.word) ?? word;
final passport = _repo.getPassport(fresh.id) ??
WordPassport(
id: fresh.id,
word: fresh.word,
pos: fresh.partOfSpeech,
category: fresh.category,
emoji: fresh.emoji,
);
setState(() {
_selectedWord = fresh;
_searchController.text = fresh.word;
// Заполняем контроллеры из паспорта
for (final field in kPassportFields78) {
final val = passport.getField(field.key);
_controllers[field.key]!.text = val;
}
});
}
int get _filledCount {
var count = 0;
for (final c in _controllers.values) {
if (c.text.trim().isNotEmpty) count++;
}
return count;
}
int _countFilledInSection(String secNum) {
var count = 0;
for (final field in kPassportFields78) {
if (field.sectionNum == secNum) {
if (_controllers[field.key]!.text.trim().isNotEmpty) {
count++;
}
}
}
return count;
}
int _totalInSection(String secNum) {
return kPassportFields78.where((f) => f.sectionNum == secNum).length;
}
Future<void> _savePassport() async {
final word = _selectedWord;
if (word == null) return;
setState(() => _isSaving = true);
final fieldsMap = <String, String>{};
for (final entry in _controllers.entries) {
fieldsMap[entry.key] = entry.value.text.trim();
}
final existingPassport = _repo.getPassport(word.id);
final updated = WordPassport(
id: word.id,
word: word.word,
pos: word.partOfSpeech,
category: word.category,
emoji: word.emoji,
fields: fieldsMap,
filledCount: _filledCount,
isComplete: _filledCount >= 78,
updatedAt: DateTime.now().toUtc().toIso8601String(),
illustrations: existingPassport?.illustrations ?? const [],
);
await _repo.savePassport(updated);
if (!mounted) return;
setState(() {
_isSaving = false;
});
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFF2E7D32),
content: Text('Паспорт знака «${word.word.toUpperCase()}» сохранен! (Заполнено $_filledCount/78)'),
),
);
}
void _copyToClipboard(String text, String label) {
Clipboard.setData(ClipboardData(text: text));
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text('✓ $label скопирован в буфер обмена'),
),
);
}
Future<void> _pasteFromClipboard(TextEditingController controller) async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null && data!.text!.isNotEmpty) {
controller.text = data.text!.trim();
HapticFeedback.selectionClick();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(milliseconds: 1500),
content: Text('✓ Вставлено из буфера'),
),
);
}
}
void _applyParsedFields(Map<String, String> parsed) {
if (parsed.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось распознать поля шаблона в тексте.')),
);
return;
}
var applied = 0;
setState(() {
for (final entry in parsed.entries) {
if (_controllers.containsKey(entry.key) && entry.value.isNotEmpty) {
_controllers[entry.key]!.text = entry.value;
applied++;
}
}
});
HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFF1565C0),
content: Text('Успешно распознано и заполнено $applied полей! Проверьте и сохраните.'),
),
);
}
void _showSmartParserDialog() {
_pasteRawController.clear();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E2138),
title: const Row(
children: [
Icon(Icons.auto_awesome, color: Color(0xFFFFD54F)),
SizedBox(width: 8),
Text('Умный разбор Markdown'),
],
),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Вставьте сюда любой скопированный ответ нейросети (карточку целиком или отдельный раздел). Система автоматически распознает строки и заполнит соответствующие окошки.',
style: TextStyle(color: Color(0xFFB7B9C8), fontSize: 13),
),
const SizedBox(height: 12),
TextField(
controller: _pasteRawController,
minLines: 6,
maxLines: 12,
decoration: InputDecoration(
hintText: 'Вставьте ответ модели сюда...',
filled: true,
fillColor: const Color(0xFF14172B),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFFFD54F), foregroundColor: Colors.black),
onPressed: () {
final raw = _pasteRawController.text.trim();
Navigator.pop(ctx);
if (raw.isNotEmpty) {
final parsed = parsePassportMarkdownToFields(raw);
_applyParsedFields(parsed);
}
},
icon: const Icon(Icons.flash_on_rounded),
label: const Text('Разобрать и заполнить'),
),
],
),
);
}
void _sharePassport(WordItem word) {
final buffer = StringBuffer();
buffer.writeln('🔮 ОРАКУЛ • ПАСПОРТ СИМВОЛА #${word.formattedSessionNumber}');
buffer.writeln('«${word.word.toUpperCase()}» ${word.emoji}');
if (word.category.isNotEmpty) {
buffer.writeln('Категория: ${word.category}');
}
buffer.writeln('');
if (word.core.isNotEmpty) {
buffer.writeln('📖 Суть:');
buffer.writeln(word.core);
buffer.writeln('');
}
buffer.writeln('🔮 Базовое толкование:');
buffer.writeln(word.description);
buffer.writeln('');
final passport = _repo.getPassport(word.id);
if (passport != null) {
final f = passport.fields;
final sm = f['sacred_meaning']?.toString() ?? '';
if (sm.isNotEmpty) {
buffer.writeln('✨ Сакральный смысл:');
buffer.writeln(sm);
buffer.writeln('');
}
final arch = f['archetype']?.toString() ?? '';
if (arch.isNotEmpty) {
buffer.writeln('🏛 Архетип:');
buffer.writeln(arch);
buffer.writeln('');
}
}
buffer.writeln('— Система сакрального жребия «Оракул»');
Clipboard.setData(ClipboardData(text: buffer.toString()));
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.share_rounded, color: Color(0xFFFFD54F), size: 18),
const SizedBox(width: 10),
Expanded(child: Text('Паспорт «${word.word}» скопирован для отправки!')),
],
),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
@override
Widget build(BuildContext context) {
final word = _selectedWord;
final query = _searchController.text.trim().toLowerCase();
final matches = query.isEmpty
? const <WordItem>[]
: _repo.allWords
.where((w) => w.word.toLowerCase().contains(query) || w.formattedSessionNumber.contains(query))
.take(8)
.toList();
final filled = _filledCount;
final progress = filled / 78.0;
return Scaffold(
backgroundColor: const Color(0xFF0F111E),
appBar: AppBar(
backgroundColor: const Color(0xFF161828),
title: const Row(
children: [
GradientStar(size: 22),
SizedBox(width: 10),
Text('Паспорт знака', style: TextStyle(fontWeight: FontWeight.w900)),
],
),
actions: [
IconButton(
tooltip: 'Поделиться паспортом символа',
onPressed: word == null ? null : () => _sharePassport(word),
icon: const Icon(Icons.share_rounded, color: Color(0xFFFFD54F)),
),
IconButton(
tooltip: 'Умная вставка Markdown',
onPressed: word == null ? null : _showSmartParserDialog,
icon: const Icon(Icons.auto_awesome, color: Color(0xFFFFD54F)),
),
PopupMenuButton<String>(
tooltip: 'Пакеты промптов',
icon: const Icon(Icons.more_vert),
onSelected: (action) {
if (word == null) return;
if (action == 'all_prompts') {
_copyToClipboard(buildAllPassportPrompts(word), 'Пакет из 78 промптов');
} else if (action == 'smart_paste') {
_showSmartParserDialog();
} else if (action == 'export_md') {
_copyToClipboard(_generateCurrentMarkdown(), 'Канонический Markdown карточки');
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'all_prompts',
child: Row(
children: [
Icon(Icons.copy_all_rounded, size: 18),
SizedBox(width: 8),
Text('Скопировать все 78 промптов'),
],
),
),
PopupMenuItem(
value: 'smart_paste',
child: Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: Color(0xFFFFD54F)),
SizedBox(width: 8),
Text('Умная вставка ответа'),
],
),
),
PopupMenuItem(
value: 'export_md',
child: Row(
children: [
Icon(Icons.description_outlined, size: 18),
SizedBox(width: 8),
Text('Скопировать готовую карточку'),
],
),
),
],
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
),
onPressed: word == null || _isSaving ? null : _savePassport,
icon: Icon(_isSaving ? Icons.hourglass_top : Icons.save_rounded, size: 18),
label: Text(_isSaving ? 'Сохранение...' : 'Записать знак'),
),
),
],
bottom: word == null
? null
: TabBar(
controller: _tabController,
isScrollable: true,
labelColor: const Color(0xFFFFD54F),
unselectedLabelColor: const Color(0xFF8E90A4),
indicatorColor: const Color(0xFFFFD54F),
indicatorWeight: 3,
tabs: [
for (int i = 0; i < _tabTitles.length; i++)
Tab(
child: Row(
children: [
Text(_tabTitles[i]),
if (i < 6) ...[
const SizedBox(width: 6),
_badge(
'${_countFilledInSection(_sectionNumForTab(i))}/${_totalInSection(_sectionNumForTab(i))}',
isComplete: _countFilledInSection(_sectionNumForTab(i)) ==
_totalInSection(_sectionNumForTab(i)),
),
],
],
),
),
],
),
),
body: Column(
children: [
// Поле поиска и быстрый выбор
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: 'Выбор знака из 1000 (номер или слово)',
hintText: '000 или огонь...',
filled: true,
fillColor: const Color(0xFF161828),
prefixIcon: const Icon(Icons.search, color: Color(0xFFFFD54F)),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_searchController.clear();
setState(() {});
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: const Color(0xFFFFD54F).withValues(alpha: 0.2)),
),
),
),
),
// Выпадающий список совпадений
if (matches.isNotEmpty && (word == null || word.word.toLowerCase() != query))
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 120,
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.3)),
),
child: ListView.separated(
itemCount: matches.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: Color(0xFF24273E)),
itemBuilder: (context, idx) {
final m = matches[idx];
return ListTile(
dense: true,
title: Text('#${m.formattedSessionNumber} ${m.word.toUpperCase()} ${m.emoji}',
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${m.partOfSpeech} • ${m.category}', maxLines: 1),
onTap: () {
_selectWord(m);
FocusScope.of(context).unfocus();
},
);
},
),
),
// Шапка выбранного знака и шкала заполнения
if (word != null)
Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.15)),
),
child: Column(
children: [
Row(
children: [
Text(
'#${word.formattedSessionNumber} ${word.word.toUpperCase()}',
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w900, color: Color(0xFFFFD54F)),
),
const SizedBox(width: 8),
Text(word.emoji, style: const TextStyle(fontSize: 18)),
const Spacer(),
Text(
'$filled / 78 заполнено (${(progress * 100).toInt()}%)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: progress >= 1.0 ? const Color(0xFF66BB6A) : const Color(0xFFE0E0E0),
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: const Color(0xFF24273E),
valueColor: AlwaysStoppedAnimation<Color>(
progress >= 1.0
? const Color(0xFF66BB6A)
: (progress > 0.5 ? const Color(0xFFFFD54F) : const Color(0xFFFFA726)),
),
),
),
if ((_repo.getPassport(word.id)?.illustrations ?? const []).isNotEmpty) ...[
const SizedBox(height: 8),
_buildIllustrationsBar(_repo.getPassport(word.id)!.illustrations),
],
],
),
),
// Контент: Вкладки с окошками
Expanded(
child: word == null
? const Center(child: Text('Выберите знак для обогащения'))
: TabBarView(
controller: _tabController,
children: [
_buildSectionTab('I', 'Заголовочный паспорт (Метаданные)'),
_buildSectionTab('II', 'Кросс-мантическая сеть и станции'),
_buildSectionTab('III', 'Мультилингвальный и графический паспорт'),
_buildSectionTab('IV', 'Трехполярная матрица состояний'),
_buildSectionTab('V', 'Культурологическая матрица и Мета-синтез'),
_buildSectionTab('VI', 'Мантический & Обучающий синтез'),
_buildPassportPreviewTab(),
],
),
),
],
),
);
}
String _sectionNumForTab(int tabIndex) {
switch (tabIndex) {
case 0:
return 'I';
case 1:
return 'II';
case 2:
return 'III';
case 3:
return 'IV';
case 4:
return 'V';
case 5:
return 'VI';
default:
return 'I';
}
}
Widget _badge(String text, {required bool isComplete}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isComplete ? const Color(0xFF2E7D32) : const Color(0xFF24273E),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isComplete ? Colors.white : const Color(0xFFB7B9C8),
),
),
);
}
Widget _buildSectionTab(String sectionNum, String sectionTitle) {
final fields = kPassportFields78.where((f) => f.sectionNum == sectionNum).toList();
final word = _selectedWord!;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
children: [
// Панель управления разделом
Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.15)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Раздел $sectionNum: $sectionTitle',
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 13, color: Color(0xFFFFD54F)),
),
const SizedBox(height: 2),
Text(
'${_countFilledInSection(sectionNum)} из ${fields.length} полей заполнено',
style: const TextStyle(fontSize: 12, color: Color(0xFFB7B9C8)),
),
],
),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFFFD54F),
side: const BorderSide(color: Color(0xFFFFD54F)),
),
onPressed: () => _copyToClipboard(
buildSectionPrePrompt(sectionNum, word),
'Промпты раздела $sectionNum',
),
icon: const Icon(Icons.copy_rounded, size: 15),
label: const Text('Скопировать раздел', style: TextStyle(fontSize: 12)),
),
],
),
),
// Список окошек для каждого поля
for (final field in fields) _buildFieldCard(field, word),
],
);
}
Widget _buildFieldCard(PassportFieldDef field, WordItem word) {
final controller = _controllers[field.key]!;
final isFilled = controller.text.trim().isNotEmpty;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isFilled
? const Color(0xFF66BB6A).withValues(alpha: 0.4)
: const Color(0xFFFFD54F).withValues(alpha: 0.15),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Шапка поля
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: isFilled ? const Color(0xFF1B5E20) : const Color(0xFF24273E),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'#${field.index.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: isFilled ? const Color(0xFF81C784) : const Color(0xFFB7B9C8),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
field.title,
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 14, color: Color(0xFFFFF9C4)),
),
),
if (isFilled)
const Icon(Icons.check_circle_rounded, color: Color(0xFF66BB6A), size: 18)
else
const Icon(Icons.radio_button_unchecked_rounded, color: Color(0xFF757575), size: 18),
],
),
const SizedBox(height: 8),
// Кнопки предпромпта и вставки
Wrap(
spacing: 8,
runSpacing: 6,
children: [
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
side: BorderSide(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
foregroundColor: const Color(0xFFFFD54F),
),
onPressed: () => _copyToClipboard(
field.buildPrompt(word),
'Предпромпт «${field.title}»',
),
icon: const Icon(Icons.copy_rounded, size: 14),
label: const Text('Скопировать предпромпт', style: TextStyle(fontSize: 12)),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
foregroundColor: const Color(0xFFB7B9C8),
),
onPressed: () => _pasteFromClipboard(controller),
icon: const Icon(Icons.paste_rounded, size: 14),
label: const Text('Вставить', style: TextStyle(fontSize: 12)),
),
if (isFilled)
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Очистить поле',
onPressed: () {
controller.clear();
HapticFeedback.selectionClick();
},
icon: const Icon(Icons.delete_outline, size: 16, color: Color(0xFFEF5350)),
),
],
),
const SizedBox(height: 8),
// Текстовое окошко
TextField(
controller: controller,
minLines: field.sectionNum == 'IV' || field.sectionNum == 'VI' ? 3 : 2,
maxLines: 8,
style: const TextStyle(fontSize: 13, height: 1.35),
decoration: InputDecoration(
hintText: 'Ожидаемый формат: ${field.formatTemplate}',
hintStyle: const TextStyle(color: Color(0xFF555870), fontSize: 12),
filled: true,
fillColor: const Color(0xFF0F111E),
contentPadding: const EdgeInsets.all(12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: const Color(0xFF24273E)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFFFD54F)),
),
),
),
],
),
);
}
String _generateCurrentMarkdown() {
final word = _selectedWord;
if (word == null) return '';
final fieldsMap = <String, String>{};
for (final entry in _controllers.entries) {
fieldsMap[entry.key] = entry.value.text.trim();
}
final tempPassport = WordPassport(
id: word.id,
word: word.word,
pos: word.partOfSpeech,
category: word.category,
emoji: word.emoji,
fields: fieldsMap,
);
return tempPassport.toMarkdown();
}
Widget _buildPassportPreviewTab() {
final md = _generateCurrentMarkdown();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
children: [
Row(
children: [
Expanded(
child: Text(
'Каноническая карточка знака ($_filledCount/78 заполнено)',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFFFFD54F)),
),
),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
),
onPressed: () => _copyToClipboard(md, 'Каноническая карточка знака'),
icon: const Icon(Icons.copy_rounded, size: 16),
label: const Text('Скопировать карточку'),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.2)),
),
child: SelectableText(
md,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12.5,
height: 1.45,
color: Color(0xFFE2E4F0),
),
),
),
],
);
}
Widget _buildIllustrationsBar(List<Map<String, dynamic>> illustrations) {
return InkWell(
onTap: () => _showIllustrationsDialog(illustrations),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF24273E).withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.photo_library_outlined, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Академические гравюры: ${illustrations.length} илл. («${illustrations.first['caption']}»)',
style: const TextStyle(fontSize: 12, color: Color(0xFFFFD54F), fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Color(0xFFFFD54F)),
],
),
),
);
}
void _showIllustrationsDialog(List<Map<String, dynamic>> illustrations) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF161828),
title: Row(
children: [
const Icon(Icons.image_search_rounded, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Text('Гравюры и схемы (${illustrations.length})', style: const TextStyle(fontSize: 18, color: Colors.white)),
],
),
content: SizedBox(
width: 500,
child: ListView.separated(
shrinkWrap: true,
itemCount: illustrations.length,
separatorBuilder: (_, __) => const Divider(color: Color(0xFF24273E)),
itemBuilder: (context, i) {
final ill = illustrations[i];
final filePath = ill['file'] as String? ?? '';
final caption = ill['caption'] as String? ?? '';
final source = ill['source'] as String? ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
filePath,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(
height: 120,
color: const Color(0xFF0F111E),
child: Center(child: Text('Изображение: $filePath', style: const TextStyle(fontSize: 12))),
),
),
),
const SizedBox(height: 8),
Text(caption, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Color(0xFFFFD54F))),
const SizedBox(height: 2),
Text(source, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Закрыть', style: TextStyle(color: Color(0xFFFFD54F))),
),
],
),
);
}
}
+967
View File
@@ -0,0 +1,967 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/houses_session_model.dart';
import 'symbol_detail_screen.dart';
enum GeometryViewMode {
constellation, // Sacred polygon / constellation of chosen houses
allChains, // All chains overlaid
singleChain, // Focus on one specific chain
}
class MatrixGeometryScreen extends StatefulWidget {
final HousesSession session;
final int? initialChainIndex; // Optional focus on chain 1..N
const MatrixGeometryScreen({
super.key,
required this.session,
this.initialChainIndex,
});
@override
State<MatrixGeometryScreen> createState() => _MatrixGeometryScreenState();
}
class _MatrixGeometryScreenState extends State<MatrixGeometryScreen>
with SingleTickerProviderStateMixin {
final TransformationController _transformController = TransformationController();
late GeometryViewMode _viewMode;
int _selectedChainIndex = 1; // 1-based index
late AnimationController _animController;
static const double kCellSize = 64.0;
static const double kCellMargin = 3.0;
static const double kHeaderHeight = 72.0;
static const List<Color> kChainColors = [
Color(0xFF00E5FF), // Cyan
Color(0xFFFF4081), // Pink / Magenta
Color(0xFFFFD54F), // Gold
Color(0xFFB388FF), // Purple
Color(0xFF00E676), // Emerald
Color(0xFFFF6E40), // Coral
Color(0xFF448AFF), // Electric Blue
Color(0xFFEEFF41), // Lime
Color(0xFFFF80AB), // Rose Gold
];
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(seconds: 4),
)..repeat();
if (widget.initialChainIndex != null &&
widget.initialChainIndex! > 0 &&
widget.initialChainIndex! <= widget.session.chains.length) {
_viewMode = GeometryViewMode.singleChain;
_selectedChainIndex = widget.initialChainIndex!;
} else {
_viewMode = GeometryViewMode.constellation;
}
_transformController.value = Matrix4.diagonal3Values(0.52, 0.52, 1.0);
}
@override
void dispose() {
_animController.dispose();
_transformController.dispose();
super.dispose();
}
void _resetZoom() {
HapticFeedback.lightImpact();
setState(() {
_transformController.value = Matrix4.diagonal3Values(0.52, 0.52, 1.0);
});
}
String _getFigureName(int pointsCount) {
switch (pointsCount) {
case 1:
return 'Монада (Точка Первоистока)';
case 2:
return 'Диада (Вектор Полярности)';
case 3:
return 'Триада (Дельта Творения)';
case 4:
return 'Тетрада (Земной Четверик)';
case 5:
return 'Пентаграмма (Узел Пяти Стихий)';
case 6:
return 'Гексаграмма (Печать Равновесия)';
case 7:
return 'Гептада (Семизвездный Венец)';
case 8:
return 'Октада (Роза Пространства)';
case 9:
return 'Эннеаграмма (Девятикратное Полотно Судьбы)';
default:
return 'Сакральная Геометрия ($pointsCount узлов)';
}
}
@override
Widget build(BuildContext context) {
const totalWidth = 30 * (kCellSize + kCellMargin * 2);
const totalHeight = 33 * (kCellSize + kCellMargin * 2) + kHeaderHeight;
final selectedChain = widget.session.chains.isNotEmpty &&
_selectedChainIndex <= widget.session.chains.length
? widget.session.chains[_selectedChainIndex - 1]
: null;
return Scaffold(
backgroundColor: const Color(0xFF0B0D18),
appBar: AppBar(
backgroundColor: const Color(0xFF101323),
elevation: 0,
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Геометрия Матрицы 33×30',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
Text(
_viewMode == GeometryViewMode.constellation
? _getFigureName(widget.session.selectedHouses.length)
: (_viewMode == GeometryViewMode.allChains
? 'Все Цепочки Домов (${widget.session.chains.length})'
: 'Цепочка #$_selectedChainIndex: ${selectedChain?.endTypeTitle ?? ""}'),
style: const TextStyle(
fontSize: 11,
color: Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
],
),
actions: [
IconButton(
tooltip: 'Центрировать масштаб',
icon: const Icon(Icons.center_focus_strong_rounded, color: Color(0xFF90CAF9)),
onPressed: _resetZoom,
),
IconButton(
tooltip: 'Справка по геометрии',
icon: const Icon(Icons.info_outline_rounded, color: Color(0xFFCFD8DC)),
onPressed: () => _showHelpDialog(context),
),
],
),
body: Stack(
children: [
// 1. Zoomable & Pannable Matrix Canvas
InteractiveViewer(
transformationController: _transformController,
constrained: false,
minScale: 0.15,
maxScale: 2.5,
boundaryMargin: const EdgeInsets.all(500),
child: SizedBox(
width: totalWidth,
height: totalHeight,
child: Stack(
children: [
_buildBaseGrid(totalWidth, totalHeight),
AnimatedBuilder(
animation: _animController,
builder: (context, child) {
return CustomPaint(
size: Size(totalWidth, totalHeight),
painter: _MatrixGeometryPainter(
session: widget.session,
viewMode: _viewMode,
selectedChainIndex: _selectedChainIndex,
pulseValue: _animController.value,
chainColors: kChainColors,
),
);
},
),
],
),
),
),
// 2. Top Control Filter Bar (Modes & Chains selector)
Positioned(
top: 12,
left: 16,
right: 16,
child: _buildModeSelectorBar(),
),
// 3. Bottom Information Card
Positioned(
bottom: 16,
left: 16,
right: 16,
child: _buildBottomInfoCard(selectedChain),
),
],
),
);
}
Widget _buildModeSelectorBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xE613162A),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF2C3258), width: 0.8),
boxShadow: const [
BoxShadow(
color: Color(0x66000000),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_buildModeChip(
icon: Icons.auto_awesome_rounded,
label: 'Фигура (${widget.session.selectedHouses.length})',
isSelected: _viewMode == GeometryViewMode.constellation,
activeColor: const Color(0xFFFFD54F),
onTap: () {
HapticFeedback.selectionClick();
setState(() => _viewMode = GeometryViewMode.constellation);
},
),
const SizedBox(width: 6),
_buildModeChip(
icon: Icons.hub_rounded,
label: 'Все Цепочки',
isSelected: _viewMode == GeometryViewMode.allChains,
activeColor: const Color(0xFF00E5FF),
onTap: () {
HapticFeedback.selectionClick();
setState(() => _viewMode = GeometryViewMode.allChains);
},
),
const SizedBox(width: 8),
Container(width: 1, height: 24, color: const Color(0xFF2C3258)),
const SizedBox(width: 8),
for (int i = 1; i <= widget.session.chains.length; i++) ...[
_buildChainChip(i),
const SizedBox(width: 6),
],
],
),
),
);
}
Widget _buildModeChip({
required IconData icon,
required String label,
required bool isSelected,
required Color activeColor,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? activeColor.withValues(alpha: 0.22) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? activeColor : const Color(0xFF2C3258),
width: isSelected ? 1.2 : 0.6,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: isSelected ? activeColor : const Color(0xFF90A4AE)),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : const Color(0xFF90A4AE),
fontWeight: isSelected ? FontWeight.bold : FontWeight.w500,
fontSize: 12,
),
),
],
),
),
),
);
}
Widget _buildChainChip(int index) {
final isSelected = _viewMode == GeometryViewMode.singleChain && _selectedChainIndex == index;
final color = kChainColors[(index - 1) % kChainColors.length];
return Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
HapticFeedback.selectionClick();
setState(() {
_viewMode = GeometryViewMode.singleChain;
_selectedChainIndex = index;
});
},
borderRadius: BorderRadius.circular(10),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
decoration: BoxDecoration(
color: isSelected ? color.withValues(alpha: 0.24) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? color : const Color(0xFF2C3258),
width: isSelected ? 1.2 : 0.6,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
),
const SizedBox(width: 5),
Text(
'#$index',
style: TextStyle(
color: isSelected ? Colors.white : const Color(0xFFB0BEC5),
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 12,
),
),
],
),
),
),
);
}
Widget _buildBottomInfoCard(HouseChain? selectedChain) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xF2121528),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFF2E355F), width: 0.8),
boxShadow: const [
BoxShadow(
color: Color(0x77000000),
blurRadius: 20,
offset: Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.session.querentWord != null) ...[
Row(
children: [
const Icon(Icons.stars_rounded, color: Color(0xFFFFD54F), size: 15),
const SizedBox(width: 5),
Text(
'Кверент: «${widget.session.querentWord!.word.toUpperCase()}» (Дом #${(widget.session.querentHouseIndex! + 1).toString().padLeft(3, "0")})',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11.5,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 6),
],
Row(
children: [
if (_viewMode == GeometryViewMode.constellation) ...[
const Icon(Icons.star_rounded, color: Color(0xFFFFD54F), size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_getFigureName(widget.session.selectedHouses.length),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0x33FFD54F),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD54F), width: 0.6),
),
child: Text(
'${widget.session.selectedHouses.length} узлов',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
] else if (_viewMode == GeometryViewMode.allChains) ...[
const Icon(Icons.hub_rounded, color: Color(0xFF00E5FF), size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Сводная Паутина Судеб (Все Цепочки)',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
Text(
'${widget.session.chains.length} лучей',
style: const TextStyle(
color: Color(0xFF00E5FF),
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
] else if (selectedChain != null) ...[
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: kChainColors[(_selectedChainIndex - 1) % kChainColors.length],
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Цепочка #$_selectedChainIndex: ${selectedChain.endTypeTitle}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: selectedChain.statusColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: selectedChain.statusColor, width: 0.6),
),
child: Text(
'${selectedChain.steps.length} звеньев',
style: TextStyle(
color: selectedChain.statusColor,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
],
),
const SizedBox(height: 8),
Text(
_viewMode == GeometryViewMode.constellation
? 'Выбранные дома образуют сакральный сигил на полотне 33×30. Линии соединяют дома в порядке выпадения жребия, создавая геометрию пространства расклада.'
: (_viewMode == GeometryViewMode.allChains
? 'Трассировка движения импульсов от каждого открытого дома (до 9 звеньев). Узоры переплетаются сквозь матрицу, выявляя пересечения и фокусы судьбы.'
: (selectedChain?.description ?? '')),
style: const TextStyle(
color: Color(0xFFCFD8DC),
fontSize: 12.5,
height: 1.4,
),
),
],
),
);
}
Widget _buildBaseGrid(double totalWidth, double totalHeight) {
final chosenIndices = {
for (final h in widget.session.selectedHouses) h.houseIndex: h
};
return Column(
children: [
SizedBox(
height: kHeaderHeight,
child: Row(
children: List.generate(10, (sectorIdx) {
final sector = widget.session.sectors[sectorIdx];
final triadWidth = 3 * (kCellSize + kCellMargin * 2);
return SizedBox(
width: triadWidth,
height: kHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFF1B1F38),
sector.color.withValues(alpha: 0.18),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: sector.color.withValues(alpha: 0.5), width: 0.8),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'АРХОНТ ${sectorIdx + 1}',
style: TextStyle(
color: sector.color,
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 0.8,
),
),
const SizedBox(height: 2),
Text(
sector.archonWord.word.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
);
}),
),
),
for (int r = 0; r < 33; r++)
Row(
children: [
for (int c = 0; c < 30; c++)
_buildCellWidget(r, c, chosenIndices[r * 30 + c]),
],
),
],
);
}
Widget _buildCellWidget(int row, int col, HouseCell? chosenCell) {
final isChosen = chosenCell != null;
final cell = widget.session.grid[row * 30 + col];
final isQuerent = widget.session.querentHouseIndex != null && cell.houseIndex == widget.session.querentHouseIndex;
return Container(
width: kCellSize,
height: kCellSize,
margin: const EdgeInsets.all(kCellMargin),
decoration: BoxDecoration(
color: isQuerent
? const Color(0xFF381E52)
: (isChosen
? const Color(0xFF2A224D)
: const Color(0xFF101323).withValues(alpha: 0.75)),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isQuerent
? const Color(0xFFFFD54F)
: (isChosen
? const Color(0xFFFFD54F)
: const Color(0xFF1E223D).withValues(alpha: 0.8)),
width: isQuerent ? 2.2 : (isChosen ? 1.5 : 0.6),
),
boxShadow: isQuerent
? [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.6),
blurRadius: 12,
spreadRadius: 1,
),
BoxShadow(
color: const Color(0xFFB388FF).withValues(alpha: 0.4),
blurRadius: 16,
spreadRadius: 2,
),
]
: (isChosen
? [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.35),
blurRadius: 10,
),
]
: null),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: () {
HapticFeedback.lightImpact();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SymbolDetailScreen(word: cell.houseWord),
),
);
},
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isQuerent) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFFFFD54F),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'👑 КВЕРЕНТ',
style: TextStyle(
color: Colors.black,
fontSize: 7.5,
fontWeight: FontWeight.w900,
),
),
),
const SizedBox(height: 2),
] else if (isChosen) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0xFFFFD54F),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'#${chosenCell.selectionOrder}',
style: const TextStyle(
color: Colors.black,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
],
Text(
cell.houseWord.word,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isChosen ? Colors.white : const Color(0xFF6B7280),
fontSize: 10.5,
fontWeight: isChosen ? FontWeight.bold : FontWeight.normal,
),
),
Text(
'#${(cell.houseIndex + 1).toString().padLeft(3, "0")}',
style: TextStyle(
color: isChosen
? const Color(0xFFFFD54F)
: const Color(0xFF374151),
fontSize: 8.5,
),
),
],
),
),
),
),
);
}
void _showHelpDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF15182D),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
title: const Row(
children: [
Icon(Icons.auto_awesome_mosaic_rounded, color: Color(0xFFFFD54F)),
SizedBox(width: 8),
Text('Сакральная Геометрия', style: TextStyle(color: Colors.white, fontSize: 17)),
],
),
content: const SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Полотно 33×30 образует единое координатное поле оракула из 990 домов и 10 архонтов.',
style: TextStyle(color: Color(0xFFCFD8DC), fontSize: 13, height: 1.4),
),
SizedBox(height: 12),
Text(
'• Фигура Расклада: золотые лучи соединяют открытые дома в порядке жребия, создавая сигил ситуации.',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 12.5, height: 1.4),
),
SizedBox(height: 8),
Text(
'• Цепочки Домов: неоновые векторы трассируют путь гостя каждого дома (до 9 звеньев). При замыкании возникает «Петля Судьбы», при попадании в имя Архонта — луч восходит к Небесному Престолу.',
style: TextStyle(color: Color(0xFF00E5FF), fontSize: 12.5, height: 1.4),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Понятно', style: TextStyle(color: Color(0xFFFFD54F))),
),
],
),
);
}
}
class _MatrixGeometryPainter extends CustomPainter {
final HousesSession session;
final GeometryViewMode viewMode;
final int selectedChainIndex;
final double pulseValue;
final List<Color> chainColors;
static const double kCellSize = 64.0;
static const double kCellMargin = 3.0;
static const double kHeaderHeight = 72.0;
_MatrixGeometryPainter({
required this.session,
required this.viewMode,
required this.selectedChainIndex,
required this.pulseValue,
required this.chainColors,
});
Offset _getCellCenter(int row, int col) {
final x = col * (kCellSize + kCellMargin * 2) + kCellMargin + kCellSize / 2;
final y = kHeaderHeight + row * (kCellSize + kCellMargin * 2) + kCellMargin + kCellSize / 2;
return Offset(x, y);
}
Offset _getArchonCenter(int sectorIndex) {
final startX = (sectorIndex * 3) * (kCellSize + kCellMargin * 2);
final width = 3 * (kCellSize + kCellMargin * 2);
return Offset(startX + width / 2, kHeaderHeight / 2);
}
@override
void paint(Canvas canvas, Size size) {
if (viewMode == GeometryViewMode.constellation) {
_paintConstellation(canvas);
} else if (viewMode == GeometryViewMode.allChains) {
for (int i = 0; i < session.chains.length; i++) {
_paintChain(canvas, session.chains[i], i + 1, false);
}
} else {
if (selectedChainIndex > 0 && selectedChainIndex <= session.chains.length) {
_paintChain(canvas, session.chains[selectedChainIndex - 1], selectedChainIndex, true);
}
}
_paintQuerentAnchor(canvas);
}
void _paintQuerentAnchor(Canvas canvas) {
if (session.querentHouseIndex == null) return;
final qRow = session.querentHouseIndex! ~/ 30;
final qCol = session.querentHouseIndex! % 30;
final qCenter = _getCellCenter(qRow, qCol);
final wavePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..color = const Color(0xFFFFD54F).withValues(alpha: 0.55 + 0.25 * math.sin(pulseValue * math.pi * 2));
canvas.drawCircle(qCenter, 22.0 + 4.0 * math.sin(pulseValue * math.pi * 2), wavePaint);
final auraPaint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xFFB388FF).withValues(alpha: 0.20 + 0.08 * math.cos(pulseValue * math.pi * 2));
canvas.drawCircle(qCenter, 30.0, auraPaint);
if (viewMode == GeometryViewMode.constellation) {
final tetherPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.4
..color = const Color(0xFFFFD54F).withValues(alpha: 0.35 + 0.15 * math.sin(pulseValue * math.pi * 2));
for (final h in session.selectedHouses) {
final hCenter = _getCellCenter(h.row, h.col);
canvas.drawLine(qCenter, hCenter, tetherPaint);
}
}
final coreGlow = Paint()
..style = PaintingStyle.fill
..color = const Color(0xFFFFD54F);
canvas.drawCircle(qCenter, 6.5, coreGlow);
canvas.drawCircle(qCenter, 2.5, Paint()..color = Colors.white);
}
void _paintConstellation(Canvas canvas) {
final houses = List<HouseCell>.from(session.selectedHouses)
..sort((a, b) => (a.selectionOrder ?? 0).compareTo(b.selectionOrder ?? 0));
if (houses.isEmpty) return;
final points = houses.map((h) => _getCellCenter(h.row, h.col)).toList();
if (points.length >= 3) {
final polyPath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
polyPath.lineTo(points[i].dx, points[i].dy);
}
polyPath.close();
final fillPaint = Paint()
..style = PaintingStyle.fill
..color = const Color(0xFFFFD54F).withValues(alpha: 0.10 + 0.04 * math.sin(pulseValue * math.pi * 2));
canvas.drawPath(polyPath, fillPaint);
}
final glowPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6.0 + 1.5 * math.sin(pulseValue * math.pi * 2)
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = const Color(0xFFFFD54F).withValues(alpha: 0.35);
final corePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2.8
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = const Color(0xFFFFE082);
final linePath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
linePath.lineTo(points[i].dx, points[i].dy);
}
if (points.length >= 3) {
linePath.close();
}
canvas.drawPath(linePath, glowPaint);
canvas.drawPath(linePath, corePaint);
for (int i = 0; i < points.length; i++) {
final p = points[i];
final nodeGlow = Paint()
..style = PaintingStyle.fill
..color = const Color(0xFFFFD54F).withValues(alpha: 0.4 + 0.2 * math.sin(pulseValue * math.pi * 2 + i));
canvas.drawCircle(p, 12.0, nodeGlow);
final nodeCore = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
canvas.drawCircle(p, 5.0, nodeCore);
}
}
void _paintChain(Canvas canvas, HouseChain chain, int chainIdx, bool isFocused) {
if (chain.steps.isEmpty) return;
final color = chainColors[(chainIdx - 1) % chainColors.length];
final glowWidth = isFocused ? 7.0 : 4.0;
final coreWidth = isFocused ? 3.0 : 2.0;
final glowPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = glowWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = color.withValues(alpha: isFocused ? 0.45 : 0.25);
final corePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = coreWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = color;
final points = <Offset>[];
for (final step in chain.steps) {
points.add(_getCellCenter(step.house.row, step.house.col));
}
final path = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
path.lineTo(points[i].dx, points[i].dy);
}
if (chain.endType == ChainEndType.archonThrone && chain.steps.last.destinationArchon != null) {
final archonCenter = _getArchonCenter(chain.steps.last.destinationArchon!.index);
path.lineTo(archonCenter.dx, archonCenter.dy);
points.add(archonCenter);
}
if (chain.endType == ChainEndType.loop && points.length >= 2) {
final lastP = points.last;
final haloPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 3.0
..color = const Color(0xFFFF5252);
canvas.drawCircle(lastP, 18.0 + 4.0 * math.sin(pulseValue * math.pi * 2), haloPaint);
}
canvas.drawPath(path, glowPaint);
canvas.drawPath(path, corePaint);
for (int i = 0; i < points.length; i++) {
final p = points[i];
final isStart = i == 0;
final isEnd = i == points.length - 1;
final nodePaint = Paint()
..style = PaintingStyle.fill
..color = isStart
? const Color(0xFFFFD54F)
: (isEnd ? const Color(0xFFFF5252) : color);
canvas.drawCircle(p, isStart || isEnd ? 6.5 : 4.0, nodePaint);
final centerDot = Paint()
..style = PaintingStyle.fill
..color = Colors.white;
canvas.drawCircle(p, isStart || isEnd ? 2.5 : 1.5, centerDot);
}
}
@override
bool shouldRepaint(covariant _MatrixGeometryPainter oldDelegate) {
return true;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -15
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'dart:ui'; import 'dart:ui';
import '../g4f/client.dart'; import '../g4f/client.dart';
import '../g4f/models.dart'; import '../g4f/models.dart';
import '../services/settings_repository.dart';
class OracleChatScreen extends StatefulWidget { class OracleChatScreen extends StatefulWidget {
const OracleChatScreen({Key? key}) : super(key: key); const OracleChatScreen({Key? key}) : super(key: key);
@@ -22,14 +23,19 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
final G4FClient _client = G4FClient(); final G4FClient _client = G4FClient();
final List<ChatMessage> _messages = [ late final List<ChatMessage> _messages;
bool _isLoading = false;
@override
void initState() {
super.initState();
_messages = [
ChatMessage( ChatMessage(
text: "Я Оракул. Мой взор проникает сквозь завесу времени. О чем ты хочешь спросить?", text: SettingsRepository().persona.chatGreeting,
isUser: false isUser: false,
), ),
]; ];
}
bool _isLoading = false;
void _scrollToBottom() { void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -43,14 +49,6 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
}); });
} }
static const _oracleSystem = Message(
role: Role.system,
content:
'Ты — Великий Оракул 999, древний архетипический разум. '
'Стиль речи: глубокий, философский, лаконичный, без банальностей. '
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
);
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
final text = _controller.text.trim(); final text = _controller.text.trim();
if (text.isEmpty || _isLoading) return; if (text.isEmpty || _isLoading) return;
@@ -64,7 +62,12 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
_scrollToBottom(); _scrollToBottom();
final apiMessages = <Message>[ final apiMessages = <Message>[
_oracleSystem, Message(
role: Role.system,
content:
'${SettingsRepository().persona.systemPrompt}\n'
'Отвечай на языке вопрошающего. Не упоминай модели, провайдеров и технические сбои.',
),
for (final m in _messages) for (final m in _messages)
if (m.text.trim().isNotEmpty) if (m.text.trim().isNotEmpty)
Message( Message(
@@ -183,7 +186,7 @@ class _OracleChatScreenState extends State<OracleChatScreen> {
width: 1, width: 1,
), ),
), ),
child: Text( child: SelectableText(
message.text, message.text,
style: TextStyle( style: TextStyle(
color: message.isUser ? Colors.white : const Color(0xFFE5E7EB), color: message.isUser ? Colors.white : const Color(0xFFE5E7EB),
+608
View File
@@ -0,0 +1,608 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/divination_layout.dart';
import '../models/syncretic_session_model.dart';
import '../models/tradition_model.dart';
import '../models/word_model.dart';
import '../services/traditions_repository.dart';
import '../services/words_repository.dart';
import '../widgets/divination_instruction_modal.dart';
class RunesDivinationScreen extends StatefulWidget {
final String? initialQuestion;
const RunesDivinationScreen({super.key, this.initialQuestion});
@override
State<RunesDivinationScreen> createState() => _RunesDivinationScreenState();
}
class _RunesDivinationScreenState extends State<RunesDivinationScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final _questionController = TextEditingController();
final _repo = WordsRepository();
// 24 runes from canon
List<TraditionStation> _runes = [];
// Layout selection
DivinationLayout _selectedLayout = DivinationLayouts.runesLayouts[0]; // 1, 3, or 5
int _currentStepIndex = 0; // for multi-step layouts
// Dice state (for current step)
int? _d12First;
int? _d12Second;
bool _isRollingDice = false;
// Manual input state
bool _isManualReversed = false;
// Multi-item results
List<({TraditionStation rune, bool isReversed, String positionTitle})> _drawnResults = [];
bool _savedToSession = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
if (widget.initialQuestion != null) {
_questionController.text = widget.initialQuestion!;
}
_loadRunes();
}
void _loadRunes() {
final canon = TraditionsRepository().byId('runes');
if (canon != null) {
_runes = canon.stations.take(24).toList();
}
}
@override
void dispose() {
_tabController.dispose();
_questionController.dispose();
super.dispose();
}
int _calculateRuneIndexFromDice(int d1, int d2) {
final isOdd = (d1 % 2) != 0;
final runeNumber = isOdd ? d2 : (d2 + 12);
return (runeNumber - 1).clamp(0, 23);
}
void _onLayoutChanged(DivinationLayout layout) {
setState(() {
_selectedLayout = layout;
_currentStepIndex = 0;
_drawnResults.clear();
_d12First = null;
_d12Second = null;
_savedToSession = false;
});
}
Future<void> _rollDiceForCurrentStep() async {
if (_runes.isEmpty || _currentStepIndex >= _selectedLayout.count) return;
setState(() => _isRollingDice = true);
HapticFeedback.mediumImpact();
final rnd = Random();
for (int i = 0; i < 8; i++) {
await Future.delayed(const Duration(milliseconds: 50));
if (mounted) {
setState(() {
_d12First = rnd.nextInt(12) + 1;
_d12Second = rnd.nextInt(12) + 1;
});
}
}
final finalD1 = rnd.nextInt(12) + 1;
final finalD2 = rnd.nextInt(12) + 1;
final isRev = rnd.nextBool() && rnd.nextBool(); // 25%
final idx = _calculateRuneIndexFromDice(finalD1, finalD2);
final rune = _runes[idx];
final posTitle = _selectedLayout.positionTitles[_currentStepIndex];
if (mounted) {
setState(() {
_d12First = finalD1;
_d12Second = finalD2;
_isRollingDice = false;
_drawnResults.add((rune: rune, isReversed: isRev, positionTitle: posTitle));
_currentStepIndex++;
if (_currentStepIndex >= _selectedLayout.count) {
_saveToSession('dice');
}
});
HapticFeedback.heavyImpact();
}
}
void _applyManualChoice(int runeIndex) {
if (runeIndex < 0 || runeIndex >= _runes.length || _currentStepIndex >= _selectedLayout.count) return;
final rune = _runes[runeIndex];
final posTitle = _selectedLayout.positionTitles[_currentStepIndex];
setState(() {
_drawnResults.add((rune: rune, isReversed: _isManualReversed, positionTitle: posTitle));
_isManualReversed = false;
_currentStepIndex++;
if (_currentStepIndex >= _selectedLayout.count) {
_saveToSession('manual');
}
});
HapticFeedback.selectionClick();
}
void _resetSpread() {
setState(() {
_currentStepIndex = 0;
_drawnResults.clear();
_d12First = null;
_d12Second = null;
_savedToSession = false;
});
}
Future<void> _saveToSession(String inputMethod) async {
if (_drawnResults.isEmpty) return;
final items = _drawnResults.map((r) {
WordItem? linkedWord;
if (r.rune.primary != null) {
linkedWord = _repo.getWordById(r.rune.primary!.id) ?? _repo.getWordByName(r.rune.primary!.word);
}
return ReadingSignItem(
title: '${r.positionTitle}: ${r.rune.name}',
subtitle: r.rune.group,
symbol: r.rune.symbol,
lore: r.rune.lore,
isReversed: r.isReversed,
linkedWord: linkedWord,
);
}).toList();
final reading = SyncreticReading(
id: 'reading_${DateTime.now().millisecondsSinceEpoch}',
systemId: 'runes',
systemName: 'Руны Старшего Футарка (24)',
systemIcon: 'ᚱ',
timestamp: DateTime.now(),
layoutTitle: '${_selectedLayout.title} (${inputMethod == "dice" ? "2×d12" : "со стола"})',
inputMethod: inputMethod,
items: items,
question: _questionController.text.trim(),
);
await _repo.addReadingToSyncreticSession(reading, question: _questionController.text.trim());
if (mounted) {
setState(() => _savedToSession = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Расклад «${_selectedLayout.title}» сохранён в сессию дня!'),
backgroundColor: const Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final isSpreadComplete = _currentStepIndex >= _selectedLayout.count;
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
actions: [
IconButton(
tooltip: 'Инструкция по рунам',
icon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F)),
onPressed: () => DivinationInstructionModal.show(context, 'runes'),
),
if (_drawnResults.isNotEmpty)
IconButton(
tooltip: 'Сбросить расклад',
icon: const Icon(Icons.refresh_rounded),
onPressed: _resetSpread,
),
],
title: const Row(
children: [
Text('ᚱ', style: TextStyle(fontSize: 22, color: Color(0xFFFFD54F))),
SizedBox(width: 8),
Text('Руны Старшего Футарка', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)),
],
),
bottom: TabBar(
controller: _tabController,
indicatorColor: const Color(0xFFFFD54F),
tabs: const [
Tab(icon: Icon(Icons.casino_rounded, size: 18), text: 'Жребий 2×d12'),
Tab(icon: Icon(Icons.touch_app_rounded, size: 18), text: 'Ввод со стола'),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Question Input
TextField(
controller: _questionController,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'Вопрос к рунам (необязательно)...',
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 13),
prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 18),
filled: true,
fillColor: const Color(0xFF151829),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
),
),
),
const SizedBox(height: 14),
// Layout Picker Row
Row(
children: [
const Icon(Icons.dashboard_customize_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 6),
const Text('РАСКЛАД:', style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: DivinationLayouts.runesLayouts.map((l) {
final isSel = _selectedLayout.id == l.id;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(l.title, style: TextStyle(fontSize: 11.5, fontWeight: isSel ? FontWeight.bold : FontWeight.normal)),
selected: isSel,
selectedColor: const Color(0xFF4A3E1E),
side: BorderSide(color: isSel ? const Color(0xFFFFD54F) : const Color(0xFF2A2D4A)),
onSelected: (_) => _onLayoutChanged(l),
),
);
}).toList(),
),
),
),
],
),
const SizedBox(height: 14),
// Progress Banner
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Row(
children: [
Text(
'Позиция: ${_currentStepIndex + 1} из ${_selectedLayout.count}',
style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (!isSpreadComplete)
Expanded(
child: Text(
'«${_selectedLayout.positionTitles[_currentStepIndex]}»',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 12, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
)
else
const Text('✓ Расклад собран', style: TextStyle(color: Color(0xFF81C784), fontSize: 12, fontWeight: FontWeight.bold)),
],
),
),
const SizedBox(height: 14),
// Controls Tabs
if (!isSpreadComplete)
SizedBox(
height: 310,
child: TabBarView(
controller: _tabController,
children: [
_buildDiceTab(),
_buildManualTabThreeAetts(),
],
),
),
if (isSpreadComplete) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF81C784)),
),
child: Row(
children: [
const Icon(Icons.check_circle_rounded, color: Color(0xFF81C784), size: 20),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Все руны расклада извлечены и записаны в сессию дня!',
style: TextStyle(color: Color(0xFFECEFF1), fontSize: 12.5, fontWeight: FontWeight.bold),
),
),
TextButton(
onPressed: _resetSpread,
child: const Text('Заново', style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.bold)),
),
],
),
),
],
const SizedBox(height: 16),
// Drawn Runes Cards List
if (_drawnResults.isNotEmpty) ...[
const Text(
'ВЫПАВШИЕ РУНЫ В РАСКЛАДЕ:',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11.5, fontWeight: FontWeight.w900, letterSpacing: 0.8),
),
const SizedBox(height: 10),
for (int i = 0; i < _drawnResults.length; i++) ...[
_buildResultRuneCard(_drawnResults[i], i + 1),
const SizedBox(height: 10),
],
],
],
),
),
);
}
Widget _buildDiceTab() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'БРОСОК 2×d12 (1..24 РАВНОМЕРНО)',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 0.8),
),
const SizedBox(height: 6),
Text(
'Извлечение позиции: «' + _selectedLayout.positionTitles[_currentStepIndex] + '»',
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11.5),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildDieBox('d12 #1 (чет/нечет)', _d12First, isOddEven: true),
const SizedBox(width: 20),
_buildDieBox('d12 #2 (1..12)', _d12Second),
],
),
const SizedBox(height: 18),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: _isRollingDice ? null : _rollDiceForCurrentStep,
icon: const Icon(Icons.casino_rounded, size: 20),
label: Text(
_isRollingDice ? 'Кости звенят...' : 'Бросить для шага ${_currentStepIndex + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
),
],
),
);
}
Widget _buildDieBox(String label, int? val, {bool isOddEven = false}) {
String sub = '';
if (val != null && isOddEven) {
sub = (val % 2 != 0) ? 'Нечет (1..12)' : 'Чет (13..24)';
}
return Column(
children: [
Container(
width: 68,
height: 68,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.6), width: 1.5),
),
child: Text(
val != null ? '$val' : '?',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 26, fontWeight: FontWeight.w900),
),
),
const SizedBox(height: 6),
Text(label, style: const TextStyle(color: Color(0xFF90A4AE), fontSize: 10, fontWeight: FontWeight.bold)),
if (sub.isNotEmpty)
Text(sub, style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 9, fontWeight: FontWeight.bold)),
],
);
}
/// Exact 3 Rows of 8 Runes: 1st Aett (1..8), 2nd Aett (9..16), 3rd Aett (17..24)
Widget _buildManualTabThreeAetts() {
const aettNames = [
'I ЭТТ ФРЕЙИ (ᚠ..ᚹ)',
'II ЭТТ ХЕЙМДАЛЛЯ (ᚺ..ᛏ)',
'III ЭТТ ТЮРА (ᛒ..ᛟ)',
];
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'ВЫБЕРИТЕ РУНУ ДЛЯ ШАГА ${_currentStepIndex + 1}:',
style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 10.5, fontWeight: FontWeight.bold),
),
FilterChip(
label: const Text('Перевёрнутая ᛉ/ᛦ', style: TextStyle(fontSize: 10)),
selected: _isManualReversed,
selectedColor: const Color(0xFFEF5350).withValues(alpha: 0.3),
side: BorderSide(color: _isManualReversed ? const Color(0xFFEF5350) : const Color(0xFF2A2D4A)),
onSelected: (v) => setState(() => _isManualReversed = v),
),
],
),
const SizedBox(height: 6),
// 3 Rows of 8 runes
for (int aett = 0; aett < 3; aett++) ...[
Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
aettNames[aett],
style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 9.5, fontWeight: FontWeight.bold),
),
),
),
Row(
children: [
for (int col = 0; col < 8; col++) ...[
Builder(builder: (ctx) {
final idx = aett * 8 + col;
if (idx >= _runes.length) return const SizedBox.shrink();
final r = _runes[idx];
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: InkWell(
onTap: () => _applyManualChoice(idx),
borderRadius: BorderRadius.circular(6),
child: Container(
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Text(
r.symbol,
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
),
);
}),
],
],
),
],
],
),
);
}
Widget _buildResultRuneCard(({TraditionStation rune, bool isReversed, String positionTitle}) res, int index) {
final r = res.rune;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFFD54F)),
),
child: Text(
r.symbol,
style: const TextStyle(fontSize: 26, color: Color(0xFFFFD54F)),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'#$index. ${res.positionTitle}: ${r.name}',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 14, fontWeight: FontWeight.bold),
),
),
if (res.isReversed)
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF5C1D1D),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFEF5350)),
),
child: const Text('Перевёрнутая', style: TextStyle(color: Color(0xFFFF8A80), fontSize: 9.5, fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: 4),
Text(r.group, style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 11)),
const SizedBox(height: 6),
Text(
r.lore,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFD1D5DB), fontSize: 12, height: 1.4),
),
],
),
),
],
),
);
}
}
+276 -48
View File
@@ -1,11 +1,16 @@
import '../models/syncretic_session_model.dart';
import '../services/oracle_master_prompt_generator.dart';
import 'symbol_detail_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../models/history_entry_model.dart'; import '../models/history_entry_model.dart';
import '../models/word_model.dart'; import '../models/word_model.dart';
import '../services/ai_oracle_service.dart'; import '../services/ai_oracle_service.dart';
import '../services/settings_repository.dart';
import '../services/words_repository.dart'; import '../services/words_repository.dart';
import '../widgets/d10_dice_roller_widget.dart'; import '../widgets/d10_dice_roller_widget.dart';
import '../widgets/gradient_star_badge.dart'; import '../widgets/gradient_star_badge.dart';
import '../widgets/interpreter_persona_picker.dart';
class SessionResultScreen extends StatefulWidget { class SessionResultScreen extends StatefulWidget {
final OracleSession session; final OracleSession session;
@@ -35,6 +40,33 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
_session = widget.session; _session = widget.session;
_repo.saveSession(_session); _repo.saveSession(_session);
// Auto-record this session into active Syncretic Day Session
final readingItems = _session.words.map((w) {
final fw = _repo.getWordByName(w.word) ?? _repo.getWordBySessionNumber(w.sessionNumber);
return ReadingSignItem(
title: '#${w.formattedSessionNumber} ${w.word.toUpperCase()}',
subtitle: w.category,
symbol: '🎲',
lore: w.symbolicMeaning,
linkedWord: fw,
);
}).toList();
_repo.addReadingToSyncreticSession(
SyncreticReading(
id: 'reading_${_session.id}',
systemId: 'cleromancy',
systemName: 'Оракул (Клеромантия)',
systemIcon: '🎲',
timestamp: _session.timestamp,
layoutTitle: '${_session.words.length} ${_session.words.length == 1 ? "знак" : "знаков"}',
inputMethod: 'dice',
items: readingItems,
question: _session.question,
),
question: _session.question,
);
// If initial interpretation is not yet done, trigger it // If initial interpretation is not yet done, trigger it
if (_session.aiInterpretation == null) { if (_session.aiInterpretation == null) {
_fetchInitialAi(); _fetchInitialAi();
@@ -48,26 +80,32 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
super.dispose(); super.dispose();
} }
Future<void> _fetchInitialAi() async { Future<void> _fetchInitialAi({bool replaceOpening = true}) async {
setState(() => _isGeneratingInitialAi = true); setState(() => _isGeneratingInitialAi = true);
try { try {
final voice = SettingsRepository().persona;
final result = await _aiService.interpretSession( final result = await _aiService.interpretSession(
words: _session.words, words: _session.words,
question: _session.question ?? '', question: _session.question ?? '',
); );
if (mounted) { if (mounted) {
final initialMessage = SessionChatMessage( final labeled = '[${voice.title}] $result';
id: 'msg_0', final message = SessionChatMessage(
id: 'msg_${DateTime.now().millisecondsSinceEpoch}',
sender: 'oracle', sender: 'oracle',
text: result, text: labeled,
timestamp: DateTime.now(), timestamp: DateTime.now(),
); );
final nextMessages = replaceOpening && _session.messages.isEmpty
? [message]
: [..._session.messages, message];
final updated = _session.copyWith( final updated = _session.copyWith(
aiInterpretation: result, aiInterpretation: labeled,
messages: _session.messages.isEmpty ? [initialMessage] : _session.messages, messages: nextMessages,
); );
setState(() { setState(() {
@@ -190,9 +228,140 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
); );
} }
void _showMasterPromptDialog() {
HapticFeedback.mediumImpact();
final prompt = OracleMasterPromptGenerator.generateForSession(_session);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF0F1222),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F), size: 24),
const SizedBox(width: 10),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Мастер-Промпт Сессии',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold),
),
Text(
'Для отправки в Grok, Claude 3.5 Sonnet, Gemini 1.5 Pro, GPT-4o',
style: TextStyle(color: Color(0xFF90A4AE), fontSize: 11),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF90A4AE)),
onPressed: () => Navigator.pop(ctx),
),
],
),
const SizedBox(height: 12),
// Quick Actions Bar
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1C223D),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${prompt.length} символов • ${prompt.split(RegExp(r"\s+")).length} слов',
style: const TextStyle(color: Color(0xFF90CAF9), fontSize: 11),
),
),
const Spacer(),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: () {
Clipboard.setData(ClipboardData(text: prompt));
HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Мастер-промпт сессии скопирован! Вставьте в Grok или Claude.'),
backgroundColor: Color(0xFF388E3C),
behavior: SnackBarBehavior.floating,
),
);
},
icon: const Icon(Icons.copy_rounded, size: 16),
label: const Text('Скопировать всё', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12.5)),
),
],
),
const SizedBox(height: 12),
// Scrollable Prompt Text Area
Expanded(
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF090A14),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF232742), width: 1.0),
),
child: SingleChildScrollView(
controller: scrollController,
child: SelectableText(
prompt,
style: const TextStyle(
color: Color(0xFFECEFF1),
fontFamily: 'monospace',
fontSize: 11.5,
height: 1.45,
),
),
),
),
),
],
),
);
},
);
},
);
}
void _copySessionToClipboard() { void _copySessionToClipboard() {
final buf = StringBuffer(); final buf = StringBuffer();
buf.writeln('✨ Оракул 999 — Сессия Клеромантии ✨'); buf.writeln('✨ Оракул — Сессия Клеромантии ✨');
buf.writeln('Дата: ${_session.formattedDate}'); buf.writeln('Дата: ${_session.formattedDate}');
if (_session.question != null && _session.question!.isNotEmpty) { if (_session.question != null && _session.question!.isNotEmpty) {
buf.writeln('Вопрос: «${_session.question}»'); buf.writeln('Вопрос: «${_session.question}»');
@@ -247,6 +416,11 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
], ],
), ),
actions: [ actions: [
IconButton(
tooltip: 'Мастер-промпт для нейросетей',
icon: const Icon(Icons.psychology_alt_rounded, color: Color(0xFFFFD54F)),
onPressed: _showMasterPromptDialog,
),
IconButton( IconButton(
tooltip: 'Докинуть уточняющий знак', tooltip: 'Докинуть уточняющий знак',
icon: const Icon(Icons.add_circle_outline_rounded, color: Color(0xFFFFD54F)), icon: const Icon(Icons.add_circle_outline_rounded, color: Color(0xFFFFD54F)),
@@ -369,6 +543,48 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
], ],
), ),
), ),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: InterpreterPersonaPicker(
compact: true,
onSelected: (_) {
if (_isGeneratingInitialAi || _isChatLoading) return;
_fetchInitialAi(replaceOpening: false);
},
),
),
const SizedBox(width: 8),
InkWell(
onTap: _showMasterPromptDialog,
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF2A1F45),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFBA68C8).withValues(alpha: 0.6)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome_rounded, size: 14, color: Color(0xFFFFD54F)),
SizedBox(width: 5),
Text(
'Мастер-Промпт',
style: TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 11.5,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
], ],
), ),
), ),
@@ -498,7 +714,6 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
children: [ children: [
// Header: Sender icon & name // Header: Sender icon & name
Row( Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
isUser ? Icons.person_outline_rounded : Icons.psychology_rounded, isUser ? Icons.person_outline_rounded : Icons.psychology_rounded,
@@ -507,27 +722,56 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
Text( Text(
isUser ? 'Вы' : 'Оракул 999', isUser ? 'Вы' : 'Оракул',
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: isUser ? const Color(0xFFFFD54F) : const Color(0xFFE1BEE7), color: isUser ? const Color(0xFFFFD54F) : const Color(0xFFE1BEE7),
), ),
), ),
const Spacer(),
IconButton(
tooltip: 'Копировать текст',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 28, minHeight: 28),
icon: Icon(
Icons.copy_rounded,
size: 15,
color: isUser ? const Color(0xFFFFD54F) : const Color(0xFFE1BEE7),
),
onPressed: () {
Clipboard.setData(ClipboardData(text: msg.text));
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Текст скопирован'),
duration: Duration(seconds: 1),
behavior: SnackBarBehavior.floating,
),
);
},
),
], ],
), ),
// Attached new word pill if present // Attached new word pill if present
if (msg.attachedWord != null) ...[ if (msg.attachedWord != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Container( InkWell(
onTap: () => _showWordDetails(msg.attachedWord!),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF3B2B5E), color: const Color(0xFF3B2B5E),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)), border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
), ),
child: Text( child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'🎲 Добавлен знак: #${msg.attachedWord!.formattedSessionNumber} «${msg.attachedWord!.word.toUpperCase()}»', '🎲 Добавлен знак: #${msg.attachedWord!.formattedSessionNumber} «${msg.attachedWord!.word.toUpperCase()}»',
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
@@ -535,13 +779,17 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
color: Color(0xFFFFF9C4), color: Color(0xFFFFF9C4),
), ),
), ),
const SizedBox(width: 4),
const Icon(Icons.arrow_forward_ios_rounded, size: 10, color: Color(0xFFFFD54F)),
],
),
),
), ),
], ],
const SizedBox(height: 6), const SizedBox(height: 6),
// Message text SelectableText(
Text(
msg.text, msg.text,
style: TextStyle( style: TextStyle(
fontSize: 13.5, fontSize: 13.5,
@@ -589,40 +837,20 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
} }
void _showWordDetails(SessionWord word) { void _showWordDetails(SessionWord word) {
showDialog( final fullWord = _repo.getWordByName(word.word) ??
context: context, _repo.getWordBySessionNumber(word.sessionNumber) ??
builder: (ctx) => AlertDialog( WordItem(
title: Row( id: word.sessionNumber,
children: [ word: word.word,
const GradientStar(size: 20), partOfSpeech: word.partOfSpeech.isNotEmpty ? word.partOfSpeech : 'знак',
const SizedBox(width: 8), frequency: 0,
Text( category: word.category,
'#${word.formattedSessionNumber} ${word.word.toUpperCase()}', description: word.symbolicMeaning,
style: const TextStyle(fontWeight: FontWeight.bold), );
), Navigator.push(
], context,
), MaterialPageRoute(
content: Column( builder: (context) => SymbolDetailScreen(word: fullWord),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${word.partOfSpeech} • ${word.category}',
style: TextStyle(color: Theme.of(ctx).colorScheme.outline),
),
const SizedBox(height: 12),
Text(
word.symbolicMeaning,
style: const TextStyle(fontSize: 14, height: 1.45),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Закрыть'),
),
],
), ),
); );
} }
+927
View File
@@ -0,0 +1,927 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/interpreter_persona.dart';
import '../services/settings_repository.dart';
import '../services/words_repository.dart';
import '../services/true_random_service.dart';
import '../widgets/gradient_star_badge.dart';
import 'manual_dive_screen.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = SettingsRepository();
return Scaffold(
appBar: AppBar(
title: const Row(
children: [
GradientStar(size: 24),
SizedBox(width: 10),
Text(
'Настройки',
style: TextStyle(fontWeight: FontWeight.w900),
),
],
),
),
body: ListenableBuilder(
listenable: settings,
builder: (context, _) {
final selected = settings.personaId;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text(
'Голос толкователя',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'От этого зависит, как ИИ свяжет выпавшие знаки с вопросом. Базовый голос — прямой, без тумана.',
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
const SizedBox(height: 14),
for (final persona in InterpreterPersona.all) ...[
_PersonaCard(
persona: persona,
selected: persona.id == selected,
onTap: () {
HapticFeedback.selectionClick();
settings.setPersonaId(persona.id);
},
),
const SizedBox(height: 10),
],
const SizedBox(height: 22),
const _ManualDiveEntry(),
const SizedBox(height: 22),
const _SymbolDescriptionsBackupSection(),
const SizedBox(height: 24),
const _RandomOrgKeySection(),
],
);
},
),
);
}
}
class _PersonaCard extends StatelessWidget {
final InterpreterPersona persona;
final bool selected;
final VoidCallback onTap;
const _PersonaCard({
required this.persona,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected
? const Color(0xFFFFD54F)
: const Color(0xFFFFD54F).withValues(alpha: 0.22),
width: selected ? 1.6 : 1.0,
),
boxShadow: selected
? [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.14),
blurRadius: 12,
),
]
: null,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF2A2D4A),
borderRadius: BorderRadius.circular(12),
),
child: Icon(persona.icon, color: const Color(0xFFFFD54F), size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
persona.title,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15.5,
color: Color(0xFFFFF9C4),
),
),
),
Icon(
selected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: selected
? const Color(0xFFFFD54F)
: theme.colorScheme.outline,
),
],
),
const SizedBox(height: 4),
Text(
persona.subtitle,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
],
),
),
],
),
),
),
);
}
}
class _RandomOrgKeySection extends StatefulWidget {
const _RandomOrgKeySection();
@override
State<_RandomOrgKeySection> createState() => _RandomOrgKeySectionState();
}
class _RandomOrgKeySectionState extends State<_RandomOrgKeySection> {
late final TextEditingController _controller;
bool _obscure = true;
bool _saving = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: SettingsRepository().randomOrgApiKey,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
await SettingsRepository().setRandomOrgApiKey(_controller.text);
TrueRandomService.instance.invalidatePool();
TrueRandomService.instance.prefetch();
if (!mounted) return;
setState(() => _saving = false);
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
SettingsRepository().hasRandomOrgApiKey
? 'Ключ RANDOM.ORG сохранён. Броски идут через JSON-RPC.'
: 'Ключ снят. Броски пойдут через бесплатный HTTP или локальный жребий.',
),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = SettingsRepository();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RANDOM.ORG',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'В запрос уходит UUID apiKey, не hashed key с дашборда. '
'Developer-ключ бесплатный, 1000 запросов/день.\n'
'Матрица 1000 слов пересобирается раз в сутки (полночь UTC) тем же API: '
'перестановка 0–999 без повторов.',
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
obscureText: _obscure,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: 'API key (UUID)',
hintText: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
helperText: settings.hasRandomOrgApiKey
? 'Сейчас: ${settings.randomOrgApiKeyMasked}'
: 'Пусто — запасной HTTP без ключа',
filled: true,
fillColor: const Color(0xFF17192C),
suffixIcon: IconButton(
tooltip: _obscure ? 'Показать' : 'Скрыть',
icon: Icon(
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: const Color(0xFFFFD54F),
),
onPressed: () => setState(() => _obscure = !_obscure),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: const Color(0xFFFFD54F).withValues(alpha: 0.25),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFFFD54F), width: 1.5),
),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: Text(_saving ? 'Сохранение…' : 'Сохранить ключ'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2A2D4A),
foregroundColor: const Color(0xFFFFF9C4),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2),
),
),
),
),
],
);
}
}
class _ManualDiveEntry extends StatelessWidget {
const _ManualDiveEntry();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
HapticFeedback.selectionClick();
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ManualDiveScreen()),
);
},
child: Ink(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF17192C),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.45)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ручной обогатитель (Паспорт 999)',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
),
),
const SizedBox(height: 6),
Text(
'Система окошек по 6 разделам и 78 предпромптов: копируй строки, вставляй ответы или разбирай Markdown целиком.',
style: theme.textTheme.bodyMedium?.copyWith(height: 1.35),
),
],
),
),
),
);
}
}
class _SymbolDescriptionsBackupSection extends StatefulWidget {
const _SymbolDescriptionsBackupSection();
@override
State<_SymbolDescriptionsBackupSection> createState() =>
_SymbolDescriptionsBackupSectionState();
}
class _SymbolDescriptionsBackupSectionState
extends State<_SymbolDescriptionsBackupSection> {
final _repo = WordsRepository();
Future<void> _exportCustomEdits() async {
final editsCount = _repo.customEditsCount;
final jsonStr = _repo.exportCustomEditsJson();
final defaultFileName = 'oracle_999_custom_symbols_${WordsRepository.utcDateStamp()}.json';
_showExportDialog(
title: 'Экспорт новых описаний',
subtitle: editsCount > 0
? 'Выгрузка $editsCount отредактированных символов со всеми статьями'
: 'Пользовательских правок пока нет, файл содержит структуру выгрузки',
fileName: defaultFileName,
content: jsonStr,
itemCount: editsCount,
);
}
Future<void> _exportFullLexicon() async {
final jsonStr = _repo.exportFullLexiconJson();
final defaultFileName = 'oracle_999_full_canon_1000_${WordsRepository.utcDateStamp()}.json';
_showExportDialog(
title: 'Экспорт полной базы (1000 символов)',
subtitle: 'Полный корпус всех 1000 знаков Оракула с каноном, статьями и формулами',
fileName: defaultFileName,
content: jsonStr,
itemCount: 1000,
);
}
Future<void> _showExportDialog({
required String title,
required String subtitle,
required String fileName,
required String content,
required int itemCount,
}) async {
HapticFeedback.lightImpact();
final bytes = utf8.encode(content).length;
final sizeKb = (bytes / 1024).toStringAsFixed(1);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF151829),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
return Padding(
padding: EdgeInsets.fromLTRB(
20,
16,
20,
MediaQuery.of(context).viewInsets.bottom + 28,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFFFD54F).withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
Row(
children: [
const Icon(Icons.file_download_outlined, color: Color(0xFFFFD54F), size: 26),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFFF9C4),
),
),
),
],
),
const SizedBox(height: 6),
Text(
subtitle,
style: const TextStyle(fontSize: 13, color: Color(0xFF8C93B8), height: 1.35),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF0F111E),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Имя файла:', style: TextStyle(color: Color(0xFF8C93B8), fontSize: 12)),
Text(fileName, style: const TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.bold, fontSize: 12)),
],
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Объём данных:', style: TextStyle(color: Color(0xFF8C93B8), fontSize: 12)),
Text('$itemCount знаков • $sizeKb КБ', style: const TextStyle(color: Colors.white, fontSize: 12)),
],
),
],
),
),
const SizedBox(height: 18),
FilledButton.icon(
icon: const Icon(Icons.copy_rounded, color: Colors.black),
label: const Text(
'Скопировать весь JSON в буфер',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black),
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
Clipboard.setData(ClipboardData(text: content));
HapticFeedback.mediumImpact();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_rounded, color: Color(0xFFFFD54F), size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Файл ($sizeKb КБ, $itemCount знаков) скопирован в буфер обмена! Перенесите его в новую версию через "Импорт".',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
},
),
const SizedBox(height: 10),
OutlinedButton.icon(
icon: const Icon(Icons.save_alt_rounded, color: Color(0xFFFFD54F)),
label: const Text(
'Сохранить в папку Загрузки (Download)',
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFFFFD54F)),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () async {
final savedPath = await _saveContentToDownloads(fileName, content);
if (!context.mounted) return;
Navigator.pop(context);
HapticFeedback.mediumImpact();
if (savedPath != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Файл успешно сохранён: $savedPath'),
backgroundColor: const Color(0xFF1E2238),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
} else {
Clipboard.setData(ClipboardData(text: content));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Папка Download недоступна напрямую. Файл скопирован в буфер обмена!'),
duration: Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
],
),
);
},
);
}
Future<String?> _saveContentToDownloads(String fileName, String content) async {
try {
final candidates = <String>[];
if (Platform.isAndroid) {
candidates.add('/storage/emulated/0/Download');
candidates.add('/sdcard/Download');
candidates.add('/storage/emulated/0/Documents');
} else if (Platform.isLinux || Platform.isMacOS) {
final home = Platform.environment['HOME'];
if (home != null) {
candidates.add('$home/Downloads');
candidates.add('$home/Documents');
}
} else if (Platform.isWindows) {
final userProfile = Platform.environment['USERPROFILE'];
if (userProfile != null) {
candidates.add('$userProfile/Downloads');
candidates.add('$userProfile/Documents');
}
}
for (final dirPath in candidates) {
final dir = Directory(dirPath);
if (dir.existsSync()) {
final file = File('${dir.path}/$fileName');
await file.writeAsString(content, flush: true);
return file.path;
}
}
for (final dirPath in candidates) {
try {
final dir = Directory(dirPath);
await dir.create(recursive: true);
final file = File('${dir.path}/$fileName');
await file.writeAsString(content, flush: true);
return file.path;
} catch (_) {}
}
return null;
} catch (_) {
return null;
}
}
Future<void> _openImportDialog() async {
final textController = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF151829),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (context) {
return StatefulBuilder(
builder: (context, setModalState) {
return Padding(
padding: EdgeInsets.fromLTRB(
20,
16,
20,
MediaQuery.of(context).viewInsets.bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: const Color(0xFFFFD54F).withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 16),
const Row(
children: [
Icon(Icons.file_upload_outlined, color: Color(0xFFFFD54F), size: 26),
SizedBox(width: 10),
Text(
'Импорт описаний в эту версию',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Color(0xFFFFF9C4),
),
),
],
),
const SizedBox(height: 6),
const Text(
'Вставьте JSON-файл описаний, выгруженный из предыдущей версии приложения.',
style: TextStyle(fontSize: 13, color: Color(0xFF8C93B8), height: 1.35),
),
const SizedBox(height: 14),
TextField(
controller: textController,
maxLines: 6,
style: const TextStyle(fontSize: 12.5, fontFamily: 'monospace'),
decoration: InputDecoration(
hintText: 'Вставьте JSON сюда...',
hintStyle: const TextStyle(color: Color(0xFF555B7A)),
filled: true,
fillColor: const Color(0xFF0F111E),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFFFFD54F)),
),
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.paste_rounded, size: 18),
label: const Text('Вставить из буфера'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFFFD54F),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.1),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () async {
final data = await Clipboard.getData('text/plain');
if (data?.text != null) {
textController.text = data!.text!;
HapticFeedback.lightImpact();
}
},
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
icon: const Icon(Icons.check_rounded, color: Colors.black, size: 18),
label: const Text('Применить', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black)),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () async {
final raw = textController.text.trim();
if (raw.isEmpty) return;
final count = await _repo.importCustomEditsJson(raw);
if (!context.mounted) return;
Navigator.pop(context);
setState(() {});
HapticFeedback.heavyImpact();
if (count > 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.check_circle_rounded, color: Color(0xFFFFD54F), size: 20),
const SizedBox(width: 10),
Expanded(
child: Text(
'Успешно импортировано $count описаний символов! Они активированы в базе.',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
backgroundColor: const Color(0xFF1E2238),
duration: const Duration(seconds: 4),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Не удалось распознать формат JSON или нет данных о символах.'),
backgroundColor: Color(0xFF3E1E22),
duration: Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
},
),
),
],
),
],
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final editsCount = _repo.customEditsCount;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: const Color(0xFFFFD54F).withValues(alpha: 0.3),
width: 1.2,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF2A2D4A),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.auto_stories_outlined, color: Color(0xFFFFD54F), size: 20),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'База и статьи символов',
style: TextStyle(
fontWeight: FontWeight.w900,
fontSize: 16,
color: Color(0xFFFFF9C4),
),
),
Text(
'Перенос описаний между версиями',
style: TextStyle(fontSize: 12, color: Color(0xFF8C93B8)),
),
],
),
),
],
),
const SizedBox(height: 12),
Text(
'Выгрузите все отредактированные и обогащённые описания одним файлом JSON. В новой версии приложения нажмите «Импортировать» и вставьте файл — все ваши статьи мгновенно встанут на свои места.',
style: TextStyle(
fontSize: 13,
height: 1.4,
color: theme.colorScheme.onSurface.withValues(alpha: 0.8),
),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF0F111E),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text('Пользовательских статей/правок:', style: TextStyle(color: Color(0xFF8C93B8), fontSize: 12)),
),
const SizedBox(width: 8),
Text(
'$editsCount знаков',
style: const TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.bold, fontSize: 13),
),
],
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _exportCustomEdits,
icon: const Icon(Icons.file_download_outlined, color: Colors.black, size: 18),
label: const Text(
'Выгрузить новые',
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.black, fontSize: 12.5),
),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _openImportDialog,
icon: const Icon(Icons.file_upload_outlined, color: Color(0xFFFFD54F), size: 18),
label: const Text(
'Импортировать',
style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFFFFD54F), fontSize: 12.5),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.1),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: TextButton.icon(
onPressed: _exportFullLexicon,
icon: const Icon(Icons.inventory_2_outlined, size: 16, color: Color(0xFF80DEEA)),
label: const Text(
'Выгрузить полный канон (все 1000 символов одним файлом)',
style: TextStyle(fontSize: 12, color: Color(0xFF80DEEA)),
),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+511
View File
@@ -0,0 +1,511 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/divination_layout.dart';
import '../models/syncretic_session_model.dart';
import '../models/tradition_model.dart';
import '../models/word_model.dart';
import '../services/traditions_repository.dart';
import '../services/words_repository.dart';
import '../widgets/divination_instruction_modal.dart';
import 'lenormand_station_screen.dart';
import '../services/tarot_majors_repository.dart';
class TarotDivinationScreen extends StatefulWidget {
final String? initialQuestion;
const TarotDivinationScreen({super.key, this.initialQuestion});
@override
State<TarotDivinationScreen> createState() => _TarotDivinationScreenState();
}
class _TarotDivinationScreenState extends State<TarotDivinationScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
final _questionController = TextEditingController();
final _repo = WordsRepository();
List<TraditionStation> _arcana = [];
bool _isShuffling = false;
// Layouts: 1 arcanum, 3 arcana, 5 arcana
DivinationLayout _selectedLayout = DivinationLayouts.tarotLayouts[0];
int _currentStepIndex = 0;
bool _isManualReversed = false;
// Drawn results
List<({TraditionStation arcanum, bool isReversed, String positionTitle})> _drawnResults = [];
bool _savedToSession = false;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
if (widget.initialQuestion != null) {
_questionController.text = widget.initialQuestion!;
}
_loadArcana();
}
void _loadArcana() {
final canon = TraditionsRepository().byId('tarot');
if (canon != null) {
_arcana = canon.stations;
}
}
@override
void dispose() {
_tabController.dispose();
_questionController.dispose();
super.dispose();
}
void _onLayoutChanged(DivinationLayout layout) {
setState(() {
_selectedLayout = layout;
_currentStepIndex = 0;
_drawnResults.clear();
_savedToSession = false;
});
}
Future<void> _drawRandomCardForStep() async {
if (_arcana.isEmpty || _currentStepIndex >= _selectedLayout.count) return;
setState(() => _isShuffling = true);
HapticFeedback.mediumImpact();
final rnd = Random();
await Future.delayed(const Duration(milliseconds: 350));
final finalIdx = rnd.nextInt(_arcana.length);
final isRev = rnd.nextBool() && rnd.nextBool(); // 25% chance
final posTitle = _selectedLayout.positionTitles[_currentStepIndex];
if (mounted) {
setState(() {
_isShuffling = false;
_drawnResults.add((arcanum: _arcana[finalIdx], isReversed: isRev, positionTitle: posTitle));
_currentStepIndex++;
if (_currentStepIndex >= _selectedLayout.count) {
_saveToSession('dice');
}
});
HapticFeedback.heavyImpact();
}
}
void _selectManualArcanum(int idx) {
if (idx < 0 || idx >= _arcana.length || _currentStepIndex >= _selectedLayout.count) return;
final posTitle = _selectedLayout.positionTitles[_currentStepIndex];
setState(() {
_drawnResults.add((arcanum: _arcana[idx], isReversed: _isManualReversed, positionTitle: posTitle));
_isManualReversed = false;
_currentStepIndex++;
if (_currentStepIndex >= _selectedLayout.count) {
_saveToSession('manual');
}
});
HapticFeedback.selectionClick();
}
void _resetSpread() {
setState(() {
_currentStepIndex = 0;
_drawnResults.clear();
_savedToSession = false;
});
}
Future<void> _saveToSession(String inputMethod) async {
if (_drawnResults.isEmpty) return;
final items = _drawnResults.map((r) {
WordItem? linkedWord;
if (r.arcanum.primary != null) {
linkedWord = _repo.getWordById(r.arcanum.primary!.id) ?? _repo.getWordByName(r.arcanum.primary!.word);
}
return ReadingSignItem(
title: '${r.positionTitle}: ${r.arcanum.name}',
subtitle: r.arcanum.group,
symbol: r.arcanum.symbol.isNotEmpty ? r.arcanum.symbol : '🎴',
lore: r.arcanum.lore,
isReversed: r.isReversed,
linkedWord: linkedWord,
);
}).toList();
final reading = SyncreticReading(
id: 'reading_${DateTime.now().millisecondsSinceEpoch}',
systemId: 'tarot',
systemName: 'Таро (Старшие Арканы)',
systemIcon: '🎴',
timestamp: DateTime.now(),
layoutTitle: '${_selectedLayout.title} (${inputMethod == "dice" ? "колода" : "со стола"})',
inputMethod: inputMethod,
items: items,
question: _questionController.text.trim(),
);
await _repo.addReadingToSyncreticSession(reading, question: _questionController.text.trim());
if (mounted) {
setState(() => _savedToSession = true);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Расклад Таро «${_selectedLayout.title}» сохранён в сессию дня!'),
backgroundColor: const Color(0xFF2E7D32),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final isSpreadComplete = _currentStepIndex >= _selectedLayout.count;
return Scaffold(
backgroundColor: const Color(0xFF0C0E1A),
appBar: AppBar(
actions: [
IconButton(
tooltip: 'Инструкция по Таро',
icon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F)),
onPressed: () => DivinationInstructionModal.show(context, 'tarot'),
),
if (_drawnResults.isNotEmpty)
IconButton(
tooltip: 'Сбросить расклад',
icon: const Icon(Icons.refresh_rounded),
onPressed: _resetSpread,
),
],
title: const Row(
children: [
Text('🎴', style: TextStyle(fontSize: 22)),
SizedBox(width: 8),
Text('Таро (Старшие Арканы)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 17)),
],
),
bottom: TabBar(
controller: _tabController,
indicatorColor: const Color(0xFFFFD54F),
tabs: const [
Tab(icon: Icon(Icons.style_rounded, size: 18), text: 'Жребий колоды'),
Tab(icon: Icon(Icons.touch_app_rounded, size: 18), text: 'Выбор из 22 Арканов'),
],
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: _questionController,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'Вопрос к Старшим Арканам...',
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 13),
prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 18),
filled: true,
fillColor: const Color(0xFF151829),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
),
),
),
const SizedBox(height: 14),
// Layout Picker Row
Row(
children: [
const Icon(Icons.dashboard_customize_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 6),
const Text('РАСКЛАД:', style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: DivinationLayouts.tarotLayouts.map((l) {
final isSel = _selectedLayout.id == l.id;
return Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(l.title, style: TextStyle(fontSize: 11.5, fontWeight: isSel ? FontWeight.bold : FontWeight.normal)),
selected: isSel,
selectedColor: const Color(0xFF4A3E1E),
side: BorderSide(color: isSel ? const Color(0xFFFFD54F) : const Color(0xFF2A2D4A)),
onSelected: (_) => _onLayoutChanged(l),
),
);
}).toList(),
),
),
),
],
),
const SizedBox(height: 14),
// Progress Banner
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Row(
children: [
Text(
'Позиция: ${_currentStepIndex + 1} из ${_selectedLayout.count}',
style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 12, fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (!isSpreadComplete)
Expanded(
child: Text(
'«${_selectedLayout.positionTitles[_currentStepIndex]}»',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 12, fontWeight: FontWeight.bold),
overflow: TextOverflow.ellipsis,
),
)
else
const Text('✓ Расклад собран', style: TextStyle(color: Color(0xFF81C784), fontSize: 12, fontWeight: FontWeight.bold)),
],
),
),
const SizedBox(height: 14),
if (!isSpreadComplete)
SizedBox(
height: 280,
child: TabBarView(
controller: _tabController,
children: [
_buildDeckTab(),
_buildManualTab(),
],
),
),
if (isSpreadComplete) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF1E2238),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF81C784)),
),
child: Row(
children: [
const Icon(Icons.check_circle_rounded, color: Color(0xFF81C784), size: 20),
const SizedBox(width: 10),
const Expanded(
child: Text(
'Все арканы расклада извлечены и записаны в сессию дня!',
style: TextStyle(color: Color(0xFFECEFF1), fontSize: 12.5, fontWeight: FontWeight.bold),
),
),
TextButton(
onPressed: _resetSpread,
child: const Text('Заново', style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.bold)),
),
],
),
),
],
const SizedBox(height: 16),
if (_drawnResults.isNotEmpty) ...[
const Text(
'ВЫПАВШИЕ АРКАНЫ В РАСКЛАДЕ:',
style: TextStyle(color: Color(0xFFFFD54F), fontSize: 11.5, fontWeight: FontWeight.w900, letterSpacing: 0.8),
),
const SizedBox(height: 10),
for (int i = 0; i < _drawnResults.length; i++) ...[
_buildResultArcanumCard(_drawnResults[i], i + 1),
const SizedBox(height: 10),
],
],
],
),
),
);
}
Widget _buildDeckTab() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 115,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF39295C), Color(0xFF1E1738)],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F), width: 1.5),
),
child: const Center(
child: Icon(Icons.auto_awesome_rounded, color: Color(0xFFFFD54F), size: 30),
),
),
const SizedBox(height: 12),
Text(
'Извлечение позиции: «' + _selectedLayout.positionTitles[_currentStepIndex] + '»',
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF8C93B8), fontSize: 11.5),
),
const SizedBox(height: 14),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: _isShuffling ? null : _drawRandomCardForStep,
icon: const Icon(Icons.style_rounded, size: 20),
label: Text(
_isShuffling ? 'Тасовка колоды...' : 'Вытянуть для шага ${_currentStepIndex + 1}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5),
),
),
],
),
);
}
Widget _buildManualTab() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF2A2D4A)),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('АРКАН ДЛЯ ШАГА ${_currentStepIndex + 1}:', style: const TextStyle(color: Color(0xFFFFD54F), fontSize: 11, fontWeight: FontWeight.bold)),
FilterChip(
label: const Text('Перевёрнутый', style: TextStyle(fontSize: 10.5)),
selected: _isManualReversed,
selectedColor: const Color(0xFFEF5350).withValues(alpha: 0.3),
side: BorderSide(color: _isManualReversed ? const Color(0xFFEF5350) : const Color(0xFF2A2D4A)),
onSelected: (v) => setState(() => _isManualReversed = v),
),
],
),
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: _arcana.length,
itemBuilder: (ctx, i) {
final a = _arcana[i];
return ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 10),
leading: Text(
'${i == 0 ? "0" : i}',
style: const TextStyle(color: Color(0xFF8C93B8), fontWeight: FontWeight.bold),
),
title: Text(
a.name,
style: const TextStyle(color: Color(0xFFE0E2EC), fontSize: 13),
),
trailing: const Icon(Icons.touch_app_rounded, size: 16, color: Color(0xFFFFD54F)),
onTap: () => _selectManualArcanum(i),
);
},
),
),
],
),
);
}
Widget _buildResultArcanumCard(({TraditionStation arcanum, bool isReversed, String positionTitle}) res, int index) {
final a = res.arcanum;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF181C30),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFFD54F)),
),
child: const Icon(Icons.style_rounded, color: Color(0xFFFFD54F), size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'#$index. ${res.positionTitle}: ${a.name}',
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 14, fontWeight: FontWeight.bold),
),
),
if (res.isReversed)
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF5C1D1D),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFFEF5350)),
),
child: const Text('Перевёрнутый', style: TextStyle(color: Color(0xFFFF8A80), fontSize: 9.5, fontWeight: FontWeight.bold)),
),
],
),
const SizedBox(height: 4),
Text(a.group, style: const TextStyle(color: Color(0xFF80DEEA), fontSize: 11)),
const SizedBox(height: 6),
Text(
a.lore,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFD1D5DB), fontSize: 12, height: 1.4),
),
],
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
/// Template for [secrets.dart] (gitignored).
/// Copy to lib/secrets.dart and paste the UUID apiKey from
/// https://api.random.org/dashboard — not the hashed key.
const String kLocalRandomOrgApiKey = '';

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