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