feat: complete academic symbol lexicon heal, 2-column Cooper extraction, and updated assets
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user