feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets

This commit is contained in:
2026-10-09 15:26:58 +05:00
parent 7f91366201
commit 0b12e4583b
244 changed files with 611461 additions and 1617 deletions
+2 -2
View File
@@ -55,7 +55,7 @@ class CleromancyGuideDialog extends StatelessWidget {
const SizedBox(width: 10),
Expanded(
child: Text(
'Клеромантия и Жребий 999',
'Клеромантия и Священный Жребий',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0.5,
@@ -74,7 +74,7 @@ class CleromancyGuideDialog extends StatelessWidget {
title: 'Что такое Клеромантия?',
content:
'Клеромантия (от греч. *klēros* — жребий, *manteia* — гадание) — древнейшая священная практика вопрошания Неведомого через случайное выпадение знаков. '
'В системе «Оракул 999» случайность не случайна: она служит мостом между вашим глубинным подсознанием и полем синхронистичности.',
'В системе «Оракул» случайность не случайна: она служит мостом между вашим глубинным подсознанием и полем синхронистичности.',
isDark: isDark,
),
@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import '../models/divination_instructions.dart';
class DivinationInstructionModal extends StatelessWidget {
final DivinationSystemInstruction instruction;
const DivinationInstructionModal({super.key, required this.instruction});
static void show(BuildContext context, String systemId) {
final inst = DivinationInstructionsRepository.get(systemId);
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF0F1222),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) => DivinationInstructionModal(instruction: inst),
);
}
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (ctx, scroll) {
return Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: ListView(
controller: scroll,
children: [
Center(
child: Container(
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
Container(
width: 44,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF232742),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
),
child: Text(instruction.icon, style: const TextStyle(fontSize: 22)),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
instruction.title,
style: const TextStyle(color: Color(0xFFFFF9C4), fontSize: 17, fontWeight: FontWeight.bold),
),
const Text(
'Каноническое руководство и методы гадания',
style: TextStyle(color: Color(0xFF8C93B8), fontSize: 11),
),
],
),
),
IconButton(
icon: const Icon(Icons.close_rounded, color: Color(0xFF90A4AE)),
onPressed: () => Navigator.pop(ctx),
),
],
),
const SizedBox(height: 16),
const Divider(color: Color(0xFF2A2D4A)),
const SizedBox(height: 12),
// Essence
_buildSection(
title: 'СУТЬ СИСТЕМЫ И ИСТОРИЯ',
icon: Icons.auto_stories_rounded,
content: instruction.historyAndEssence,
accentColor: const Color(0xFF80DEEA),
),
const SizedBox(height: 16),
// Physical dice
_buildSection(
title: 'БРОСОК ФИЗИЧЕСКИХ КОСТЕЙ НА СТОЛЕ',
icon: Icons.casino_rounded,
content: instruction.physicalDiceMethod,
accentColor: const Color(0xFFFFD54F),
),
const SizedBox(height: 16),
// Direct input
_buildSection(
title: 'РУЧНОЙ ВВОД СО СТОЛА В ПРИЛОЖЕНИЕ',
icon: Icons.touch_app_rounded,
content: instruction.directInputMethod,
accentColor: const Color(0xFF81C784),
),
const SizedBox(height: 16),
// Interpretation
_buildSection(
title: 'ПРАВИЛА И СЕКРЕТЫ ТОЛКОВАНИЯ',
icon: Icons.psychology_rounded,
content: instruction.interpretationGuide,
accentColor: const Color(0xFFBA68C8),
),
const SizedBox(height: 16),
// Sacred rules
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1738),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.shield_moon_rounded, color: Color(0xFFFFD54F), size: 18),
SizedBox(width: 8),
Text(
'САДЕЙСТВИЕ И САХРАННОСТЬ ПРАКТИКИ',
style: TextStyle(
color: Color(0xFFFFD54F),
fontSize: 11,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 10),
for (final r in instruction.sacredRules)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ', style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.bold)),
Expanded(
child: Text(
r,
style: const TextStyle(color: Color(0xFFECEFF1), fontSize: 13, height: 1.4),
),
),
],
),
),
],
),
),
],
),
);
},
);
}
Widget _buildSection({
required String title,
required IconData icon,
required String content,
required Color accentColor,
}) {
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: [
Icon(icon, color: accentColor, size: 16),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
color: accentColor,
fontSize: 11,
fontWeight: FontWeight.w900,
letterSpacing: 0.8,
),
),
],
),
const SizedBox(height: 10),
Text(
content,
style: const TextStyle(
color: Color(0xFFD1D5DB),
fontSize: 13.5,
height: 1.5,
),
),
],
),
);
}
}
+416
View File
@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/external_symbol_model.dart';
import '../services/external_symbols_repository.dart';
class ExternalSymbolsBrowser extends StatefulWidget {
final String searchQuery;
const ExternalSymbolsBrowser({
super.key,
this.searchQuery = '',
});
@override
State<ExternalSymbolsBrowser> createState() => _ExternalSymbolsBrowserState();
}
class _ExternalSymbolsBrowserState extends State<ExternalSymbolsBrowser> {
final _repo = ExternalSymbolsRepository();
bool _onlyWithImages = false;
String? _selectedAuthor;
@override
void initState() {
super.initState();
_repo.init().then((_) {
if (mounted) setState(() {});
});
}
void _showDetailModal(ExternalSymbol symbol) {
HapticFeedback.selectionClick();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF121424),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) => DraggableScrollableSheet(
initialChildSize: 0.85,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) => Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: ListView(
controller: scrollController,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
symbol.term.toUpperCase(),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: Color(0xFFFFD54F),
letterSpacing: 0.5,
),
),
const SizedBox(height: 4),
Text(
'Внешний пантеон • ${symbol.sources.length} источн.',
style: const TextStyle(
fontSize: 13,
color: Color(0xFF8E90A4),
),
),
],
),
),
IconButton(
tooltip: 'Поделиться символом',
icon: const Icon(Icons.share_rounded, color: Color(0xFFFFD54F)),
onPressed: () {
final buf = StringBuffer();
buf.writeln('📚 ОРАКУЛ • АКАДЕМИЧЕСКИЙ СИМВОЛ: «${symbol.term.toUpperCase()}»');
buf.writeln('');
for (final s in symbol.sources) {
buf.writeln('🏛 [${s.author} · ${s.work}]:');
buf.writeln(s.fullText.isNotEmpty ? s.fullText : s.excerpt);
buf.writeln('');
}
buf.writeln('— Академический корпус системы «Оракул»');
Clipboard.setData(ClipboardData(text: buf.toString()));
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.share_rounded, color: Color(0xFFFFD54F), size: 18),
const SizedBox(width: 8),
Expanded(child: Text('Символ «${symbol.term}» скопирован для отправки')),
],
),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
},
),
IconButton(
icon: const Icon(Icons.close_rounded, color: Colors.white70),
onPressed: () => Navigator.pop(ctx),
),
],
),
const Divider(color: Color(0xFF24273E), height: 24),
// Иллюстрации (если есть)
if (symbol.hasIllustrations) ...[
const Text(
'ГРАВЮРЫ И ИЛЛЮСТРАЦИИ',
style: TextStyle(
fontSize: 11,
letterSpacing: 1.2,
fontWeight: FontWeight.w800,
color: Color(0xFFFFD54F),
),
),
const SizedBox(height: 10),
for (final ill in symbol.illustrations) ...[
ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Container(
color: const Color(0xFF161828),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
ill['file'] as String? ?? '',
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(
height: 140,
color: const Color(0xFF161828),
child: Center(
child: Text('Илл.: ${ill['file']}', style: const TextStyle(fontSize: 12, color: Colors.white54)),
),
),
),
Padding(
padding: const EdgeInsets.all(10),
child: Text(
ill['caption'] as String? ?? '',
style: const TextStyle(fontSize: 12, color: Color(0xFFFFD54F), fontWeight: FontWeight.w600),
),
),
],
),
),
),
const SizedBox(height: 12),
],
const SizedBox(height: 12),
],
// Академические источники
const Text(
'АКАДЕМИЧЕСКИЕ СТАТЬИ И ПЕРВОИСТОЧНИКИ',
style: TextStyle(
fontSize: 11,
letterSpacing: 1.2,
fontWeight: FontWeight.w800,
color: Color(0xFFFFD54F),
),
),
const SizedBox(height: 10),
for (final src in symbol.sources) ...[
Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.15)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.menu_book_rounded, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Expanded(
child: Text(
'${src.author} • ${src.work}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white),
),
),
IconButton(
icon: const Icon(Icons.copy_rounded, size: 16, color: Color(0xFFFFD54F)),
tooltip: 'Скопировать цитату',
onPressed: () {
Clipboard.setData(ClipboardData(text: '[${src.author} · ${src.work}]: «${src.fullText}»'));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Цитата скопирована в буфер')),
);
},
),
],
),
const SizedBox(height: 8),
Text(
src.fullText.isNotEmpty ? src.fullText : src.excerpt,
style: const TextStyle(fontSize: 13, height: 1.5, color: Color(0xFFE0E0E0)),
),
],
),
),
],
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
if (!_repo.isLoaded) {
return const Center(child: CircularProgressIndicator(color: Color(0xFFFFD54F)));
}
final query = widget.searchQuery;
var list = _repo.search(query, onlyWithImages: _onlyWithImages);
if (_selectedAuthor != null) {
list = list.where((s) => s.sources.any((src) => src.author == _selectedAuthor)).toList();
}
// Собираем уникальных авторов для быстрого фильтра
final authors = <String>{};
for (final s in _repo.allSymbols) {
for (final src in s.sources) {
if (src.author.isNotEmpty) authors.add(src.author);
}
}
return Column(
children: [
// Плашка фильтров внешнего пантеона
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
color: const Color(0xFF101222),
child: Row(
children: [
FilterChip(
label: Text('🖼️ С иллюстрациями (${_repo.search("", onlyWithImages: true).length})'),
selected: _onlyWithImages,
selectedColor: const Color(0xFFFFD54F),
labelStyle: TextStyle(
color: _onlyWithImages ? Colors.black : const Color(0xFFD1D5DB),
fontSize: 12,
fontWeight: FontWeight.bold,
),
backgroundColor: const Color(0xFF161828),
side: BorderSide(color: _onlyWithImages ? const Color(0xFFFFD54F) : const Color(0xFF2A2D4A)),
onSelected: (val) => setState(() => _onlyWithImages = val),
),
const SizedBox(width: 8),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (final author in authors) ...[
Padding(
padding: const EdgeInsets.only(right: 6),
child: ChoiceChip(
label: Text(author, style: const TextStyle(fontSize: 11)),
selected: _selectedAuthor == author,
selectedColor: const Color(0xFFFFD54F).withValues(alpha: 0.3),
labelStyle: TextStyle(
color: _selectedAuthor == author ? const Color(0xFFFFD54F) : Colors.white70,
fontWeight: _selectedAuthor == author ? FontWeight.bold : FontWeight.normal,
),
backgroundColor: const Color(0xFF161828),
onSelected: (val) {
setState(() {
_selectedAuthor = val ? author : null;
});
},
),
),
],
],
),
),
),
],
),
),
// Счетчик
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Row(
children: [
Text(
'Внешний реестр: ${list.length} понятий (из ${_repo.totalCount})',
style: const TextStyle(fontSize: 12, color: Color(0xFF8E90A4), fontWeight: FontWeight.bold),
),
],
),
),
// Список символов
Expanded(
child: list.isEmpty
? const Center(
child: Text(
'Понятия не найдены',
style: TextStyle(color: Color(0xFF8E90A4)),
),
)
: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: list.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, idx) {
final item = list[idx];
return InkWell(
onTap: () => _showDetailModal(item),
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: item.hasIllustrations
? const Color(0xFFFFD54F).withValues(alpha: 0.3)
: const Color(0xFF2A2D4A),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (item.hasIllustrations) ...[
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
item.illustrations.first['file'] as String? ?? '',
width: 48,
height: 48,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const Icon(Icons.image, size: 36, color: Color(0xFFFFD54F)),
),
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item.term.toUpperCase(),
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15,
color: Color(0xFFFFD54F),
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF24273E),
borderRadius: BorderRadius.circular(6),
),
child: Text(
item.primaryAuthor,
style: const TextStyle(fontSize: 10, color: Colors.white70),
),
),
],
),
const SizedBox(height: 6),
Text(
item.primaryExcerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, color: Color(0xFFC0C4D6), height: 1.4),
),
],
),
),
],
),
),
);
},
),
),
],
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/interpreter_persona.dart';
import '../services/settings_repository.dart';
class InterpreterPersonaPicker extends StatelessWidget {
final ValueChanged<InterpreterPersona>? onSelected;
final bool compact;
const InterpreterPersonaPicker({
super.key,
this.onSelected,
this.compact = false,
});
@override
Widget build(BuildContext context) {
final settings = SettingsRepository();
return ListenableBuilder(
listenable: settings,
builder: (context, _) {
final selectedId = settings.personaId;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.record_voice_over_rounded, size: 16, color: Color(0xFFFFD54F)),
SizedBox(width: 6),
Text(
'Кто толкует знаки',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w900,
letterSpacing: 0.3,
color: Color(0xFFFFD54F),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final persona in InterpreterPersona.all)
_PersonaChip(
persona: persona,
selected: persona.id == selectedId,
compact: compact,
onTap: () {
HapticFeedback.selectionClick();
settings.setPersonaId(persona.id);
onSelected?.call(persona);
},
),
],
),
],
);
},
);
}
}
class _PersonaChip extends StatelessWidget {
final InterpreterPersona persona;
final bool selected;
final bool compact;
final VoidCallback onTap;
const _PersonaChip({
required this.persona,
required this.selected,
required this.compact,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
padding: EdgeInsets.symmetric(
horizontal: compact ? 10 : 12,
vertical: compact ? 8 : 10,
),
decoration: BoxDecoration(
color: selected ? const Color(0xFF2A2D4A) : const Color(0xFF17192C),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: selected
? const Color(0xFFFFD54F)
: const Color(0xFFFFD54F).withValues(alpha: 0.22),
width: selected ? 1.5 : 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
persona.icon,
size: 16,
color: const Color(0xFFFFD54F),
),
const SizedBox(width: 6),
Text(
persona.title,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
color: selected ? const Color(0xFFFFF9C4) : const Color(0xFFE8EAF6),
),
),
],
),
),
),
);
}
}
+755
View File
@@ -0,0 +1,755 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/tradition_model.dart';
import '../screens/lenormand_extras_screen.dart';
import '../screens/lenormand_station_screen.dart';
import '../services/lenormand_pairs_repository.dart';
import '../services/ogham_feda_repository.dart';
import '../services/tarot_majors_repository.dart';
typedef OpenLexiconWord = void Function(int id, String word);
class TraditionsAtlasView extends StatelessWidget {
final List<TraditionCanon> traditions;
final TraditionCanon? selected;
final String searchQuery;
final String? focusStationId;
final ValueChanged<TraditionCanon> onSelectTradition;
final VoidCallback onBackToOverview;
final OpenLexiconWord onOpenWord;
const TraditionsAtlasView({
super.key,
required this.traditions,
required this.selected,
required this.searchQuery,
this.focusStationId,
required this.onSelectTradition,
required this.onBackToOverview,
required this.onOpenWord,
});
@override
Widget build(BuildContext context) {
if (selected == null) {
return _Overview(
traditions: traditions,
onSelect: onSelectTradition,
);
}
return _TraditionStations(
tradition: selected!,
traditions: traditions,
searchQuery: searchQuery,
focusStationId: focusStationId,
onBack: onBackToOverview,
onOpenWord: onOpenWord,
onSelectTradition: onSelectTradition,
);
}
}
class _Overview extends StatelessWidget {
final List<TraditionCanon> traditions;
final ValueChanged<TraditionCanon> onSelect;
const _Overview({required this.traditions, required this.onSelect});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xCC1E2238), Color(0xE6151829)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'🏛️ Сакральные каноны',
style: TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 6),
Text(
'Выберите традицию, чтобы открыть её станции, первородные архетипы и созвучия базы 999 слов.',
style: TextStyle(color: Color(0xFFCBD5E1), fontSize: 13, height: 1.45),
),
],
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
'${traditions.length} великих систем',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
const SizedBox(height: 12),
...traditions.map(
(t) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _TraditionCard(tradition: t, onTap: () => onSelect(t)),
),
),
],
);
}
}
class _TraditionCard extends StatelessWidget {
final TraditionCanon tradition;
final VoidCallback onTap;
const _TraditionCard({required this.tradition, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x1AFFFFFF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x1AFFD54F),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Text(tradition.icon, style: const TextStyle(fontSize: 26)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tradition.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 16,
),
),
const SizedBox(height: 2),
Text(
'${tradition.count} сакральных станций',
style: const TextStyle(
color: Color(0xFF90CDF4),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text(
tradition.description,
style: const TextStyle(
color: Color(0xFFCBD5E1),
fontSize: 13,
height: 1.45,
),
),
const SizedBox(height: 12),
const Row(
children: [
Text(
'Открыть атлас традиции',
style: TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
SizedBox(width: 6),
Icon(Icons.arrow_forward_rounded, color: Color(0xFFFFD54F), size: 16),
],
),
],
),
),
),
);
}
}
class _TraditionStations extends StatefulWidget {
final TraditionCanon tradition;
final List<TraditionCanon> traditions;
final String searchQuery;
final String? focusStationId;
final VoidCallback onBack;
final OpenLexiconWord onOpenWord;
final ValueChanged<TraditionCanon> onSelectTradition;
const _TraditionStations({
required this.tradition,
required this.traditions,
required this.searchQuery,
required this.focusStationId,
required this.onBack,
required this.onOpenWord,
required this.onSelectTradition,
});
@override
State<_TraditionStations> createState() => _TraditionStationsState();
}
class _TraditionStationsState extends State<_TraditionStations> {
final Map<String, GlobalKey> _stationKeys = {};
String? _highlightedId;
TraditionCanon get tradition => widget.tradition;
String get searchQuery => widget.searchQuery;
VoidCallback get onBack => widget.onBack;
OpenLexiconWord get onOpenWord => widget.onOpenWord;
bool get _isLenormand => tradition.id == 'lenormand';
bool get _isTarot => tradition.id == 'tarot';
bool get _isOgham => tradition.id == 'ogham';
bool get _hasDeck => _isLenormand || _isTarot || _isOgham;
GlobalKey _keyFor(String id) => _stationKeys.putIfAbsent(id, GlobalKey.new);
@override
void initState() {
super.initState();
_scheduleFocus();
}
@override
void didUpdateWidget(covariant _TraditionStations oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.focusStationId != widget.focusStationId ||
oldWidget.tradition.id != widget.tradition.id) {
_scheduleFocus();
}
}
void _scheduleFocus() {
final id = widget.focusStationId;
if (id == null || id.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _stationKeys[id]?.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
alignment: 0.25,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOut,
);
}
setState(() => _highlightedId = id);
});
}
@override
Widget build(BuildContext context) {
final stations = tradition.stations
.where((s) => s.matchesQuery(searchQuery))
.toList();
final groups = <String, List<TraditionStation>>{};
final order = <String>[];
for (final st in stations) {
final key = st.groupKey.isEmpty ? tradition.name : st.groupKey;
if (!groups.containsKey(key)) {
groups[key] = [];
order.add(key);
}
groups[key]!.add(st);
}
final hasGroups = order.length > 1;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xCC1E2238), Color(0xE6151829)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
tooltip: 'К списку слов',
onPressed: onBack,
icon: const Icon(Icons.arrow_back_rounded, color: Color(0xFFFFD54F)),
),
Text(tradition.icon, style: const TextStyle(fontSize: 26)),
const SizedBox(width: 10),
Expanded(
child: Text(
tradition.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0x26FFD54F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x66FFD54F)),
),
child: Text(
'${stations.length} из ${tradition.count}',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 8, 4),
child: Text(
tradition.description,
style: const TextStyle(color: Color(0xFFCBD5E1), fontSize: 13, height: 1.45),
),
),
if (_isLenormand)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 4),
child: OutlinedButton.icon(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const LenormandExtrasScreen(),
),
);
},
icon: const Icon(Icons.layers_outlined, color: Color(0xFFFFD54F), size: 18),
label: const Text(
'Триады, любовь, работа, карма',
style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.w700),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0x66FFD54F)),
),
),
),
],
),
),
const SizedBox(height: 8),
if (stations.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(
child: Text(
'Станций не найдено',
style: TextStyle(color: Color(0xFF8C93B8), fontWeight: FontWeight.bold),
),
),
)
else
for (final gKey in order) ...[
if (hasGroups) _GroupHeader(label: gKey),
for (final st in groups[gKey]!)
Padding(
key: _keyFor(st.id),
padding: const EdgeInsets.only(bottom: 12),
child: _StationCard(
station: st,
highlighted: st.id == _highlightedId,
onOpenWord: onOpenWord,
deckButtonLabel: _isOgham ? 'Значение феды' : (_isTarot ? 'Значение аркана и пары' : 'Описания и сочетания'),
onOpenLenormand: _hasDeck
? () {
HapticFeedback.selectionClick();
if (_isTarot) {
final card = TarotMajorsRepository().byCanonId(st.id);
if (card == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LenormandStationScreen(
number: card.n,
lookup: TarotMajorsRepository().byNumber,
title: 'Таро',
),
),
);
return;
}
if (_isOgham) {
final card = OghamFedaRepository().byCanonId(st.id);
if (card == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LenormandStationScreen(
number: card.n,
lookup: OghamFedaRepository().byNumber,
title: 'Огам',
),
),
);
return;
}
final card = LenormandPairsRepository().byStationId(st.id);
if (card == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LenormandStationScreen(number: card.n),
),
);
}
: null,
),
),
],
],
);
}
}
class _GroupHeader extends StatelessWidget {
final String label;
const _GroupHeader({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 10),
child: Row(
children: [
const Expanded(child: Divider(color: Color(0x4DFBBF24), height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0x14FBBF24),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0x40FBBF24)),
),
child: Text(
label.toUpperCase(),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
),
),
const Expanded(child: Divider(color: Color(0x4DFBBF24), height: 1)),
],
),
);
}
}
class _StationCard extends StatelessWidget {
final TraditionStation station;
final bool highlighted;
final OpenLexiconWord onOpenWord;
final VoidCallback? onOpenLenormand;
final String? deckButtonLabel;
const _StationCard({
required this.station,
required this.onOpenWord,
this.highlighted = false,
this.onOpenLenormand,
this.deckButtonLabel,
});
@override
Widget build(BuildContext context) {
final sub = station.arcanum?.isNotEmpty == true
? station.arcanum!
: station.groupSub;
final primary = station.primary;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: highlighted ? const Color(0xFFFFD54F) : const Color(0x1AFFFFFF),
width: highlighted ? 1.6 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x1AFFD54F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: station.geomancyRows.isNotEmpty
? _GeomancyGlyph(rows: station.geomancyRows)
: Text(
station.symbol,
style: const TextStyle(
fontSize: 22,
color: Color(0xFFFFD54F),
height: 1.1,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
text: station.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 15,
height: 1.25,
),
children: [
if (station.hebrew != null && station.hebrew!.isNotEmpty)
TextSpan(
text: ' ${station.hebrew}',
style: const TextStyle(
color: Color(0xD9D4A855),
fontWeight: FontWeight.w500,
fontSize: 18,
height: 1.2,
),
),
if (station.hanzi != null && station.hanzi!.isNotEmpty)
TextSpan(
text: ' ${station.hanzi}',
style: const TextStyle(
color: Color(0xD9D4A855),
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.2,
),
),
if (station.arabic != null && station.arabic!.isNotEmpty)
TextSpan(
text: ' ${station.arabic}',
style: const TextStyle(
color: Color(0xD9D4A855),
fontWeight: FontWeight.w500,
fontSize: 17,
height: 1.2,
),
),
],
),
),
if (sub.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
sub,
style: const TextStyle(
color: Color(0xFF90CDF4),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text(
station.lore,
style: const TextStyle(
color: Color(0xFFD1D5DB),
fontSize: 13,
height: 1.45,
),
),
if (primary != null) ...[
const SizedBox(height: 12),
Material(
color: const Color(0x14FFD54F),
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () => onOpenWord(primary.id, primary.word),
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Row(
children: [
if (primary.emoji.isNotEmpty)
Text(primary.emoji, style: const TextStyle(fontSize: 16)),
if (primary.emoji.isNotEmpty) const SizedBox(width: 8),
Expanded(
child: Text(
primary.word.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14,
letterSpacing: 0.4,
),
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFFFFD54F), size: 18),
],
),
),
),
),
],
if (station.cluster.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final cw in station.cluster)
InkWell(
onTap: () => onOpenWord(cw.id, cw.word),
borderRadius: BorderRadius.circular(6),
child: Tooltip(
message: 'Открыть знак «${cw.word}»',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0x0FFFFFFF),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0x1FFFFFFF)),
),
child: Text(
'${cw.emoji} ${cw.word}',
style: const TextStyle(
color: Color(0xFFE2E8F0),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
],
if (onOpenLenormand != null) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: onOpenLenormand,
icon: const Icon(Icons.hub_outlined, size: 18),
label: Text(deckButtonLabel ?? 'Описания и сочетания'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0x26FFD54F),
foregroundColor: const Color(0xFFFFD54F),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
],
),
);
}
}
/// Four-row geomantic figure: one centered dot or a centered pair per row.
class _GeomancyGlyph extends StatelessWidget {
final List<int> rows;
const _GeomancyGlyph({required this.rows});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final n in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < n; i++) ...[
if (i > 0) const SizedBox(width: 5),
Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: Color(0xFFFFD54F),
shape: BoxShape.circle,
),
),
],
],
),
),
],
);
}
}
+109 -55
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/history_entry_model.dart';
import '../models/word_model.dart';
import '../screens/symbol_detail_screen.dart';
import '../services/ai_oracle_service.dart';
import '../services/words_repository.dart';
import 'gradient_star_badge.dart';
@@ -61,6 +62,8 @@ class _WordCardWidgetState extends State<WordCardWidget> {
}
}
Color _getPosColor(String pos) {
switch (pos.toLowerCase()) {
case 'существительное':
@@ -174,11 +177,11 @@ class _WordCardWidgetState extends State<WordCardWidget> {
final min = now.minute.toString().padLeft(2, '0');
var text =
'✨ Оракул 999 — Толкование символа ✨\n'
'✨ Оракул — Толкование символа ✨\n'
'Дата и время: $day.$month.$year $hour:$min\n'
'${widget.question != null && widget.question!.isNotEmpty ? "Вопрос: «${widget.question}»\n" : ""}'
'Знак №${widget.word.formattedSessionNumber}: ${widget.word.word.toUpperCase()}\n'
'Аспект: ${widget.word.partOfSpeech} • ${widget.word.category}\n\n'
'Знак №${widget.word.formattedSessionNumber}: ${widget.word.emoji.isNotEmpty ? widget.word.emoji + " " : ""}${widget.word.word.toUpperCase()}\n'
'${widget.word.category.isNotEmpty ? "Категория: " + widget.word.category + "\n\n" : ""}'
'🔮 Символическое значение:\n${widget.word.description}';
if (_aiInterpretation != null) {
@@ -203,11 +206,51 @@ class _WordCardWidgetState extends State<WordCardWidget> {
);
}
void _shareWord(BuildContext context) {
final buffer = StringBuffer();
buffer.writeln('🔮 ОРАКУЛ • САХРАЛЬНЫЙ ЗНАК #${widget.word.formattedSessionNumber}');
buffer.writeln('«${widget.word.word.toUpperCase()}» ${widget.word.emoji}');
if (widget.word.category.isNotEmpty) {
buffer.writeln('Категория: ${widget.word.category}');
}
buffer.writeln('');
if (widget.word.core.isNotEmpty) {
buffer.writeln('📖 Суть:');
buffer.writeln(widget.word.core);
buffer.writeln('');
}
buffer.writeln('🔮 Толкование:');
buffer.writeln(widget.word.description);
if (_aiInterpretation != null && _aiInterpretation!.isNotEmpty) {
buffer.writeln('');
buffer.writeln('🤖 Персональный отклик Оракула:');
buffer.writeln(_aiInterpretation);
}
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('Символ «${widget.word.word}» скопирован для отправки!')),
],
),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final posColor = _getPosColor(widget.word.partOfSpeech);
return Card(
elevation: 8,
@@ -330,11 +373,28 @@ class _WordCardWidgetState extends State<WordCardWidget> {
),
Row(
children: [
IconButton(
tooltip: 'Полное досье знака',
icon: const Icon(Icons.menu_book_rounded, size: 21, color: Color(0xFF80DEEA)),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SymbolDetailScreen(word: widget.word),
),
);
},
),
IconButton(
tooltip: 'Скопировать толкование',
icon: const Icon(Icons.copy_rounded, size: 20),
onPressed: () => _copyToClipboard(context),
),
IconButton(
tooltip: 'Поделиться символом',
icon: const Icon(Icons.share_rounded, size: 20, color: Color(0xFFFFD54F)),
onPressed: () => _shareWord(context),
),
IconButton(
tooltip: _savedToHistory ? 'Сохранено в историю' : 'Сохранить в историю',
icon: Icon(
@@ -353,59 +413,53 @@ class _WordCardWidgetState extends State<WordCardWidget> {
// Big Word Title
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.word.word.toUpperCase(),
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
color: isDark ? const Color(0xFFFFF9C4) : const Color(0xFF1B1D30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.word.emoji.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Text(
widget.word.emoji,
style: const TextStyle(fontSize: 36),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
widget.word.word.toUpperCase(),
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 1.5,
color: isDark ? const Color(0xFFFFF9C4) : const Color(0xFF1B1D30),
),
),
),
],
),
),
// Category Badge
if (widget.word.category.isNotEmpty)
Center(
child: Chip(
avatar: const Icon(Icons.auto_awesome, size: 14, color: Color(0xFFFFD54F)),
label: Text(
widget.word.category,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black87,
),
),
backgroundColor: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 4),
),
),
),
// Part of Speech & Category Badges
Center(
child: Wrap(
spacing: 8,
runSpacing: 6,
alignment: WrapAlignment.center,
children: [
Chip(
avatar: Icon(Icons.auto_awesome, size: 14, color: posColor),
label: Text(
widget.word.partOfSpeech,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 12,
color: posColor,
),
),
backgroundColor: posColor.withValues(alpha: 0.12),
side: BorderSide(color: posColor.withValues(alpha: 0.3)),
padding: const EdgeInsets.symmetric(horizontal: 4),
),
if (widget.word.category.isNotEmpty)
Chip(
label: Text(
widget.word.category,
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white70 : Colors.black87,
),
),
backgroundColor: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05),
side: BorderSide.none,
padding: const EdgeInsets.symmetric(horizontal: 2),
),
],
),
),
const SizedBox(height: 14),
Divider(
@@ -447,7 +501,7 @@ class _WordCardWidgetState extends State<WordCardWidget> {
),
const SizedBox(height: 8),
Text(
widget.word.description,
widget.word.displayMeaning,
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.45,
fontSize: 13.5,