Files
999/lib/screens/manual_dive_screen.dart
T

964 lines
35 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/word_model.dart';
import '../models/word_passport_model.dart';
import '../services/passport_schema_dart.dart';
import '../services/words_repository.dart';
import '../widgets/gradient_star_badge.dart';
/// Ручной обогатитель единого паспорта знака «Оракула 999»
/// Полноценная система окошек и предпромптов по всем 6 разделам канона (78 полей).
class ManualDiveScreen extends StatefulWidget {
final WordItem? initialWord;
const ManualDiveScreen({super.key, this.initialWord});
@override
State<ManualDiveScreen> createState() => _ManualDiveScreenState();
}
class _ManualDiveScreenState extends State<ManualDiveScreen> with SingleTickerProviderStateMixin {
final _repo = WordsRepository();
final _searchController = TextEditingController();
late final TabController _tabController;
// 78 контроллеров по ключам полей
final Map<String, TextEditingController> _controllers = {};
// Контроллер для умной вставки сырого текста
final _pasteRawController = TextEditingController();
WordItem? _selectedWord;
bool _isSaving = false;
final List<String> _tabTitles = const [
'I. Метаданные (4)',
'II. Мантика (17)',
'III. Языки (17)',
'IV. Матрица (3)',
'V. Культуры (33)',
'VI. Синтез (4)',
'📄 Паспорт',
];
@override
void initState() {
super.initState();
_tabController = TabController(length: _tabTitles.length, vsync: this);
// Инициализируем контроллеры для всех 78 полей
for (final field in kPassportFields78) {
_controllers[field.key] = TextEditingController();
_controllers[field.key]!.addListener(_onFieldChanged);
}
// Если передан начальный знак, выбираем его
if (widget.initialWord != null) {
_selectWord(widget.initialWord!);
} else if (_repo.allWords.isNotEmpty) {
_selectWord(_repo.allWords.first);
}
}
void _onFieldChanged() {
if (mounted) setState(() {});
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_pasteRawController.dispose();
for (final c in _controllers.values) {
c.removeListener(_onFieldChanged);
c.dispose();
}
super.dispose();
}
void _selectWord(WordItem word) {
final fresh = _repo.getWordByName(word.word) ?? word;
final passport = _repo.getPassport(fresh.id) ??
WordPassport(
id: fresh.id,
word: fresh.word,
pos: fresh.partOfSpeech,
category: fresh.category,
emoji: fresh.emoji,
);
setState(() {
_selectedWord = fresh;
_searchController.text = fresh.word;
// Заполняем контроллеры из паспорта
for (final field in kPassportFields78) {
final val = passport.getField(field.key);
_controllers[field.key]!.text = val;
}
});
}
int get _filledCount {
var count = 0;
for (final c in _controllers.values) {
if (c.text.trim().isNotEmpty) count++;
}
return count;
}
int _countFilledInSection(String secNum) {
var count = 0;
for (final field in kPassportFields78) {
if (field.sectionNum == secNum) {
if (_controllers[field.key]!.text.trim().isNotEmpty) {
count++;
}
}
}
return count;
}
int _totalInSection(String secNum) {
return kPassportFields78.where((f) => f.sectionNum == secNum).length;
}
Future<void> _savePassport() async {
final word = _selectedWord;
if (word == null) return;
setState(() => _isSaving = true);
final fieldsMap = <String, String>{};
for (final entry in _controllers.entries) {
fieldsMap[entry.key] = entry.value.text.trim();
}
final existingPassport = _repo.getPassport(word.id);
final updated = WordPassport(
id: word.id,
word: word.word,
pos: word.partOfSpeech,
category: word.category,
emoji: word.emoji,
fields: fieldsMap,
filledCount: _filledCount,
isComplete: _filledCount >= 78,
updatedAt: DateTime.now().toUtc().toIso8601String(),
illustrations: existingPassport?.illustrations ?? const [],
);
await _repo.savePassport(updated);
if (!mounted) return;
setState(() {
_isSaving = false;
});
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFF2E7D32),
content: Text('Паспорт знака «${word.word.toUpperCase()}» сохранен! (Заполнено $_filledCount/78)'),
),
);
}
void _copyToClipboard(String text, String label) {
Clipboard.setData(ClipboardData(text: text));
HapticFeedback.selectionClick();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text('✓ $label скопирован в буфер обмена'),
),
);
}
Future<void> _pasteFromClipboard(TextEditingController controller) async {
final data = await Clipboard.getData(Clipboard.kTextPlain);
if (data?.text != null && data!.text!.isNotEmpty) {
controller.text = data.text!.trim();
HapticFeedback.selectionClick();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
duration: Duration(milliseconds: 1500),
content: Text('✓ Вставлено из буфера'),
),
);
}
}
void _applyParsedFields(Map<String, String> parsed) {
if (parsed.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Не удалось распознать поля шаблона в тексте.')),
);
return;
}
var applied = 0;
setState(() {
for (final entry in parsed.entries) {
if (_controllers.containsKey(entry.key) && entry.value.isNotEmpty) {
_controllers[entry.key]!.text = entry.value;
applied++;
}
}
});
HapticFeedback.heavyImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: const Color(0xFF1565C0),
content: Text('Успешно распознано и заполнено $applied полей! Проверьте и сохраните.'),
),
);
}
void _showSmartParserDialog() {
_pasteRawController.clear();
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF1E2138),
title: const Row(
children: [
Icon(Icons.auto_awesome, color: Color(0xFFFFD54F)),
SizedBox(width: 8),
Text('Умный разбор Markdown'),
],
),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Вставьте сюда любой скопированный ответ нейросети (карточку целиком или отдельный раздел). Система автоматически распознает строки и заполнит соответствующие окошки.',
style: TextStyle(color: Color(0xFFB7B9C8), fontSize: 13),
),
const SizedBox(height: 12),
TextField(
controller: _pasteRawController,
minLines: 6,
maxLines: 12,
decoration: InputDecoration(
hintText: 'Вставьте ответ модели сюда...',
filled: true,
fillColor: const Color(0xFF14172B),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Отмена'),
),
FilledButton.icon(
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFFFD54F), foregroundColor: Colors.black),
onPressed: () {
final raw = _pasteRawController.text.trim();
Navigator.pop(ctx);
if (raw.isNotEmpty) {
final parsed = parsePassportMarkdownToFields(raw);
_applyParsedFields(parsed);
}
},
icon: const Icon(Icons.flash_on_rounded),
label: const Text('Разобрать и заполнить'),
),
],
),
);
}
void _sharePassport(WordItem word) {
final buffer = StringBuffer();
buffer.writeln('🔮 ОРАКУЛ • ПАСПОРТ СИМВОЛА #${word.formattedSessionNumber}');
buffer.writeln('«${word.word.toUpperCase()}» ${word.emoji}');
if (word.category.isNotEmpty) {
buffer.writeln('Категория: ${word.category}');
}
buffer.writeln('');
if (word.core.isNotEmpty) {
buffer.writeln('📖 Суть:');
buffer.writeln(word.core);
buffer.writeln('');
}
buffer.writeln('🔮 Базовое толкование:');
buffer.writeln(word.description);
buffer.writeln('');
final passport = _repo.getPassport(word.id);
if (passport != null) {
final f = passport.fields;
final sm = f['sacred_meaning']?.toString() ?? '';
if (sm.isNotEmpty) {
buffer.writeln('✨ Сакральный смысл:');
buffer.writeln(sm);
buffer.writeln('');
}
final arch = f['archetype']?.toString() ?? '';
if (arch.isNotEmpty) {
buffer.writeln('🏛 Архетип:');
buffer.writeln(arch);
buffer.writeln('');
}
}
buffer.writeln('— Система сакрального жребия «Оракул»');
Clipboard.setData(ClipboardData(text: buffer.toString()));
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(Icons.share_rounded, color: Color(0xFFFFD54F), size: 18),
const SizedBox(width: 10),
Expanded(child: Text('Паспорт «${word.word}» скопирован для отправки!')),
],
),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
);
}
@override
Widget build(BuildContext context) {
final word = _selectedWord;
final query = _searchController.text.trim().toLowerCase();
final matches = query.isEmpty
? const <WordItem>[]
: _repo.allWords
.where((w) => w.word.toLowerCase().contains(query) || w.formattedSessionNumber.contains(query))
.take(8)
.toList();
final filled = _filledCount;
final progress = filled / 78.0;
return Scaffold(
backgroundColor: const Color(0xFF0F111E),
appBar: AppBar(
backgroundColor: const Color(0xFF161828),
title: const Row(
children: [
GradientStar(size: 22),
SizedBox(width: 10),
Text('Паспорт знака', style: TextStyle(fontWeight: FontWeight.w900)),
],
),
actions: [
IconButton(
tooltip: 'Поделиться паспортом символа',
onPressed: word == null ? null : () => _sharePassport(word),
icon: const Icon(Icons.share_rounded, color: Color(0xFFFFD54F)),
),
IconButton(
tooltip: 'Умная вставка Markdown',
onPressed: word == null ? null : _showSmartParserDialog,
icon: const Icon(Icons.auto_awesome, color: Color(0xFFFFD54F)),
),
PopupMenuButton<String>(
tooltip: 'Пакеты промптов',
icon: const Icon(Icons.more_vert),
onSelected: (action) {
if (word == null) return;
if (action == 'all_prompts') {
_copyToClipboard(buildAllPassportPrompts(word), 'Пакет из 78 промптов');
} else if (action == 'smart_paste') {
_showSmartParserDialog();
} else if (action == 'export_md') {
_copyToClipboard(_generateCurrentMarkdown(), 'Канонический Markdown карточки');
}
},
itemBuilder: (_) => const [
PopupMenuItem(
value: 'all_prompts',
child: Row(
children: [
Icon(Icons.copy_all_rounded, size: 18),
SizedBox(width: 8),
Text('Скопировать все 78 промптов'),
],
),
),
PopupMenuItem(
value: 'smart_paste',
child: Row(
children: [
Icon(Icons.auto_awesome, size: 18, color: Color(0xFFFFD54F)),
SizedBox(width: 8),
Text('Умная вставка ответа'),
],
),
),
PopupMenuItem(
value: 'export_md',
child: Row(
children: [
Icon(Icons.description_outlined, size: 18),
SizedBox(width: 8),
Text('Скопировать готовую карточку'),
],
),
),
],
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
),
onPressed: word == null || _isSaving ? null : _savePassport,
icon: Icon(_isSaving ? Icons.hourglass_top : Icons.save_rounded, size: 18),
label: Text(_isSaving ? 'Сохранение...' : 'Записать знак'),
),
),
],
bottom: word == null
? null
: TabBar(
controller: _tabController,
isScrollable: true,
labelColor: const Color(0xFFFFD54F),
unselectedLabelColor: const Color(0xFF8E90A4),
indicatorColor: const Color(0xFFFFD54F),
indicatorWeight: 3,
tabs: [
for (int i = 0; i < _tabTitles.length; i++)
Tab(
child: Row(
children: [
Text(_tabTitles[i]),
if (i < 6) ...[
const SizedBox(width: 6),
_badge(
'${_countFilledInSection(_sectionNumForTab(i))}/${_totalInSection(_sectionNumForTab(i))}',
isComplete: _countFilledInSection(_sectionNumForTab(i)) ==
_totalInSection(_sectionNumForTab(i)),
),
],
],
),
),
],
),
),
body: Column(
children: [
// Поле поиска и быстрый выбор
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 6),
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: 'Выбор знака из 1000 (номер или слово)',
hintText: '000 или огонь...',
filled: true,
fillColor: const Color(0xFF161828),
prefixIcon: const Icon(Icons.search, color: Color(0xFFFFD54F)),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_searchController.clear();
setState(() {});
},
)
: null,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(14)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: const Color(0xFFFFD54F).withValues(alpha: 0.2)),
),
),
),
),
// Выпадающий список совпадений
if (matches.isNotEmpty && (word == null || word.word.toLowerCase() != query))
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
height: 120,
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.3)),
),
child: ListView.separated(
itemCount: matches.length,
separatorBuilder: (_, _) => const Divider(height: 1, color: Color(0xFF24273E)),
itemBuilder: (context, idx) {
final m = matches[idx];
return ListTile(
dense: true,
title: Text('#${m.formattedSessionNumber} ${m.word.toUpperCase()} ${m.emoji}',
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text('${m.partOfSpeech} • ${m.category}', maxLines: 1),
onTap: () {
_selectWord(m);
FocusScope.of(context).unfocus();
},
);
},
),
),
// Шапка выбранного знака и шкала заполнения
if (word != null)
Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.15)),
),
child: Column(
children: [
Row(
children: [
Text(
'#${word.formattedSessionNumber} ${word.word.toUpperCase()}',
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w900, color: Color(0xFFFFD54F)),
),
const SizedBox(width: 8),
Text(word.emoji, style: const TextStyle(fontSize: 18)),
const Spacer(),
Text(
'$filled / 78 заполнено (${(progress * 100).toInt()}%)',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: progress >= 1.0 ? const Color(0xFF66BB6A) : const Color(0xFFE0E0E0),
),
),
],
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor: const Color(0xFF24273E),
valueColor: AlwaysStoppedAnimation<Color>(
progress >= 1.0
? const Color(0xFF66BB6A)
: (progress > 0.5 ? const Color(0xFFFFD54F) : const Color(0xFFFFA726)),
),
),
),
if ((_repo.getPassport(word.id)?.illustrations ?? const []).isNotEmpty) ...[
const SizedBox(height: 8),
_buildIllustrationsBar(_repo.getPassport(word.id)!.illustrations),
],
],
),
),
// Контент: Вкладки с окошками
Expanded(
child: word == null
? const Center(child: Text('Выберите знак для обогащения'))
: TabBarView(
controller: _tabController,
children: [
_buildSectionTab('I', 'Заголовочный паспорт (Метаданные)'),
_buildSectionTab('II', 'Кросс-мантическая сеть и станции'),
_buildSectionTab('III', 'Мультилингвальный и графический паспорт'),
_buildSectionTab('IV', 'Трехполярная матрица состояний'),
_buildSectionTab('V', 'Культурологическая матрица и Мета-синтез'),
_buildSectionTab('VI', 'Мантический & Обучающий синтез'),
_buildPassportPreviewTab(),
],
),
),
],
),
);
}
String _sectionNumForTab(int tabIndex) {
switch (tabIndex) {
case 0:
return 'I';
case 1:
return 'II';
case 2:
return 'III';
case 3:
return 'IV';
case 4:
return 'V';
case 5:
return 'VI';
default:
return 'I';
}
}
Widget _badge(String text, {required bool isComplete}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: isComplete ? const Color(0xFF2E7D32) : const Color(0xFF24273E),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: isComplete ? Colors.white : const Color(0xFFB7B9C8),
),
),
);
}
Widget _buildSectionTab(String sectionNum, String sectionTitle) {
final fields = kPassportFields78.where((f) => f.sectionNum == sectionNum).toList();
final word = _selectedWord!;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
children: [
// Панель управления разделом
Container(
margin: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.15)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Раздел $sectionNum: $sectionTitle',
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 13, color: Color(0xFFFFD54F)),
),
const SizedBox(height: 2),
Text(
'${_countFilledInSection(sectionNum)} из ${fields.length} полей заполнено',
style: const TextStyle(fontSize: 12, color: Color(0xFFB7B9C8)),
),
],
),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFFFD54F),
side: const BorderSide(color: Color(0xFFFFD54F)),
),
onPressed: () => _copyToClipboard(
buildSectionPrePrompt(sectionNum, word),
'Промпты раздела $sectionNum',
),
icon: const Icon(Icons.copy_rounded, size: 15),
label: const Text('Скопировать раздел', style: TextStyle(fontSize: 12)),
),
],
),
),
// Список окошек для каждого поля
for (final field in fields) _buildFieldCard(field, word),
],
);
}
Widget _buildFieldCard(PassportFieldDef field, WordItem word) {
final controller = _controllers[field.key]!;
final isFilled = controller.text.trim().isNotEmpty;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isFilled
? const Color(0xFF66BB6A).withValues(alpha: 0.4)
: const Color(0xFFFFD54F).withValues(alpha: 0.15),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Шапка поля
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: isFilled ? const Color(0xFF1B5E20) : const Color(0xFF24273E),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'#${field.index.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: isFilled ? const Color(0xFF81C784) : const Color(0xFFB7B9C8),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
field.title,
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 14, color: Color(0xFFFFF9C4)),
),
),
if (isFilled)
const Icon(Icons.check_circle_rounded, color: Color(0xFF66BB6A), size: 18)
else
const Icon(Icons.radio_button_unchecked_rounded, color: Color(0xFF757575), size: 18),
],
),
const SizedBox(height: 8),
// Кнопки предпромпта и вставки
Wrap(
spacing: 8,
runSpacing: 6,
children: [
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
side: BorderSide(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
foregroundColor: const Color(0xFFFFD54F),
),
onPressed: () => _copyToClipboard(
field.buildPrompt(word),
'Предпромпт «${field.title}»',
),
icon: const Icon(Icons.copy_rounded, size: 14),
label: const Text('Скопировать предпромпт', style: TextStyle(fontSize: 12)),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
foregroundColor: const Color(0xFFB7B9C8),
),
onPressed: () => _pasteFromClipboard(controller),
icon: const Icon(Icons.paste_rounded, size: 14),
label: const Text('Вставить', style: TextStyle(fontSize: 12)),
),
if (isFilled)
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Очистить поле',
onPressed: () {
controller.clear();
HapticFeedback.selectionClick();
},
icon: const Icon(Icons.delete_outline, size: 16, color: Color(0xFFEF5350)),
),
],
),
const SizedBox(height: 8),
// Текстовое окошко
TextField(
controller: controller,
minLines: field.sectionNum == 'IV' || field.sectionNum == 'VI' ? 3 : 2,
maxLines: 8,
style: const TextStyle(fontSize: 13, height: 1.35),
decoration: InputDecoration(
hintText: 'Ожидаемый формат: ${field.formatTemplate}',
hintStyle: const TextStyle(color: Color(0xFF555870), fontSize: 12),
filled: true,
fillColor: const Color(0xFF0F111E),
contentPadding: const EdgeInsets.all(12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: const Color(0xFF24273E)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFFFFD54F)),
),
),
),
],
),
);
}
String _generateCurrentMarkdown() {
final word = _selectedWord;
if (word == null) return '';
final fieldsMap = <String, String>{};
for (final entry in _controllers.entries) {
fieldsMap[entry.key] = entry.value.text.trim();
}
final tempPassport = WordPassport(
id: word.id,
word: word.word,
pos: word.partOfSpeech,
category: word.category,
emoji: word.emoji,
fields: fieldsMap,
);
return tempPassport.toMarkdown();
}
Widget _buildPassportPreviewTab() {
final md = _generateCurrentMarkdown();
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 32),
children: [
Row(
children: [
Expanded(
child: Text(
'Каноническая карточка знака ($_filledCount/78 заполнено)',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFFFFD54F)),
),
),
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFD54F),
foregroundColor: Colors.black,
),
onPressed: () => _copyToClipboard(md, 'Каноническая карточка знака'),
icon: const Icon(Icons.copy_rounded, size: 16),
label: const Text('Скопировать карточку'),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF161828),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.2)),
),
child: SelectableText(
md,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12.5,
height: 1.45,
color: Color(0xFFE2E4F0),
),
),
),
],
);
}
Widget _buildIllustrationsBar(List<Map<String, dynamic>> illustrations) {
return InkWell(
onTap: () => _showIllustrationsDialog(illustrations),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF24273E).withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(Icons.photo_library_outlined, size: 16, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Академические гравюры: ${illustrations.length} илл. («${illustrations.first['caption']}»)',
style: const TextStyle(fontSize: 12, color: Color(0xFFFFD54F), fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_forward_ios_rounded, size: 12, color: Color(0xFFFFD54F)),
],
),
),
);
}
void _showIllustrationsDialog(List<Map<String, dynamic>> illustrations) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: const Color(0xFF161828),
title: Row(
children: [
const Icon(Icons.image_search_rounded, color: Color(0xFFFFD54F)),
const SizedBox(width: 8),
Text('Гравюры и схемы (${illustrations.length})', style: const TextStyle(fontSize: 18, color: Colors.white)),
],
),
content: SizedBox(
width: 500,
child: ListView.separated(
shrinkWrap: true,
itemCount: illustrations.length,
separatorBuilder: (_, __) => const Divider(color: Color(0xFF24273E)),
itemBuilder: (context, i) {
final ill = illustrations[i];
final filePath = ill['file'] as String? ?? '';
final caption = ill['caption'] as String? ?? '';
final source = ill['source'] as String? ?? '';
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
filePath,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Container(
height: 120,
color: const Color(0xFF0F111E),
child: Center(child: Text('Изображение: $filePath', style: const TextStyle(fontSize: 12))),
),
),
),
const SizedBox(height: 8),
Text(caption, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Color(0xFFFFD54F))),
const SizedBox(height: 2),
Text(source, style: const TextStyle(fontSize: 11, color: Colors.grey)),
],
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Закрыть', style: TextStyle(color: Color(0xFFFFD54F))),
),
],
),
);
}
}