1028 lines
38 KiB
Dart
1028 lines
38 KiB
Dart
import '../widgets/external_symbols_browser.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import '../models/word_model.dart';
|
||
import '../services/traditions_repository.dart';
|
||
import '../services/words_repository.dart';
|
||
import '../widgets/gradient_star_badge.dart';
|
||
import '../widgets/traditions_atlas_view.dart';
|
||
import 'symbol_detail_screen.dart';
|
||
|
||
class CatalogScreen extends StatefulWidget {
|
||
final String? initialAtlasMode;
|
||
final String? initialTraditionId;
|
||
|
||
const CatalogScreen({
|
||
super.key,
|
||
this.initialAtlasMode,
|
||
this.initialTraditionId,
|
||
});
|
||
|
||
@override
|
||
State<CatalogScreen> createState() => _CatalogScreenState();
|
||
}
|
||
|
||
class _CatalogScreenState extends State<CatalogScreen> {
|
||
final _repo = WordsRepository();
|
||
final _traditions = TraditionsRepository();
|
||
final _searchController = TextEditingController();
|
||
|
||
String _selectedFilter = 'Все';
|
||
String _sortBy = 'id'; // 'id', 'alpha', 'rank', 'layers', 'system'
|
||
String _atlasMode = 'words'; // 'words' | 'canon'
|
||
String? _selectedTraditionId;
|
||
String? _focusStationId;
|
||
|
||
List<WordItem> _filteredWords = [];
|
||
|
||
final List<String> _filters = [
|
||
'Все',
|
||
'🌐 Другие символы',
|
||
'✨ Все системы',
|
||
'ᚱ Руны',
|
||
'᚛ Огам',
|
||
'✡️ Каббала',
|
||
'⚗️ Алхимия',
|
||
'🎴 Таро',
|
||
'🪶 Ленорман',
|
||
'☯️ И Цзин',
|
||
'⌛ Геомантия',
|
||
'♈ Гороскоп',
|
||
'🐉 Китайский зодиак',
|
||
'🌌 Космология',
|
||
'📅 Дни недели',
|
||
'🗓️ Месяцы года',
|
||
'🔢 Нумерология',
|
||
'Мои правки',
|
||
'Со слоями',
|
||
'Архетипы',
|
||
'Действия',
|
||
];
|
||
|
||
int _getSystemOrder(String desc) {
|
||
if (desc.contains('[Руны:')) return 1;
|
||
if (desc.contains('[Огам:')) return 2;
|
||
if (desc.contains('[Каббала:')) return 3;
|
||
if (desc.contains('[Алхимия:')) return 4;
|
||
if (desc.contains('[Таро:')) return 5;
|
||
if (desc.contains('[Ленорман:')) return 6;
|
||
if (desc.contains('[И Цзин:')) return 7;
|
||
if (desc.contains('[Геомантия:')) return 8;
|
||
if (desc.contains('[Додекада Созвездий:')) return 9;
|
||
if (desc.contains('[Китайский гороскоп:')) return 10;
|
||
if (desc.contains('[Космология:')) return 11;
|
||
if (desc.contains('[Септенер:')) return 12;
|
||
if (desc.contains('[Додекада:')) return 13;
|
||
if (desc.contains('[Нумерология:') || desc.contains('[Матрица:')) return 14;
|
||
if (desc.contains('[')) return 15;
|
||
return 999;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (widget.initialAtlasMode != null) {
|
||
_atlasMode = widget.initialAtlasMode!;
|
||
}
|
||
if (widget.initialTraditionId != null) {
|
||
_selectedTraditionId = widget.initialTraditionId;
|
||
}
|
||
_applyFilter();
|
||
_searchController.addListener(_applyFilter);
|
||
_traditions.init().then((_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_searchController.removeListener(_applyFilter);
|
||
_searchController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _applyFilter() {
|
||
final query = _searchController.text.trim();
|
||
List<WordItem> list;
|
||
|
||
if (query.isEmpty) {
|
||
list = _repo.allWords;
|
||
} else {
|
||
list = _repo.search(query);
|
||
}
|
||
|
||
// Apply category / tag filter
|
||
if (_selectedFilter != 'Все') {
|
||
switch (_selectedFilter) {
|
||
case '✨ Все системы':
|
||
list = list.where((w) => _getSystemOrder(w.description) < 999).toList();
|
||
break;
|
||
case 'ᚱ Руны':
|
||
list = list.where((w) => w.description.contains('[Руны:')).toList();
|
||
break;
|
||
case '᚛ Огам':
|
||
list = list.where((w) => w.description.contains('[Огам:')).toList();
|
||
break;
|
||
case '✡️ Каббала':
|
||
list = list.where((w) => w.description.contains('[Каббала:')).toList();
|
||
break;
|
||
case '⚗️ Алхимия':
|
||
list = list.where((w) => w.description.contains('[Алхимия:')).toList();
|
||
break;
|
||
case '🎴 Таро':
|
||
list = list.where((w) => w.description.contains('[Таро:')).toList();
|
||
break;
|
||
case '🪶 Ленорман':
|
||
list = list.where((w) => w.description.contains('[Ленорман:')).toList();
|
||
break;
|
||
case '☯️ И Цзин':
|
||
list = list.where((w) => w.description.contains('[И Цзин:')).toList();
|
||
break;
|
||
case '⌛ Геомантия':
|
||
list = list.where((w) => w.description.contains('[Геомантия:')).toList();
|
||
break;
|
||
case '♈ Гороскоп':
|
||
list = list.where((w) => w.description.contains('[Додекада Созвездий:')).toList();
|
||
break;
|
||
case '🐉 Китайский зодиак':
|
||
list = list.where((w) => w.description.contains('[Китайский гороскоп:')).toList();
|
||
break;
|
||
case '🌌 Космология':
|
||
list = list.where((w) => w.description.contains('[Космология:')).toList();
|
||
break;
|
||
case '📅 Дни недели':
|
||
list = list.where((w) => w.description.contains('[Септенер:')).toList();
|
||
break;
|
||
case '🗓️ Месяцы года':
|
||
list = list.where((w) => w.description.contains('[Додекада:')).toList();
|
||
break;
|
||
case '🔢 Нумерология':
|
||
list = list.where((w) => w.description.contains('[Нумерология:') || w.description.contains('[Матрица:')).toList();
|
||
break;
|
||
case 'Мои правки':
|
||
list = list.where((w) => w.isUserEdited).toList();
|
||
break;
|
||
case 'Со слоями':
|
||
list = list.where((w) => w.layers.isNotEmpty).toList();
|
||
break;
|
||
case 'Архетипы':
|
||
list = list
|
||
.where((w) =>
|
||
w.category.toLowerCase().contains('архетип') ||
|
||
w.family.toLowerCase().contains('totem') ||
|
||
w.operator == 'substance')
|
||
.toList();
|
||
break;
|
||
case 'Действия':
|
||
list = list
|
||
.where((w) =>
|
||
w.operator == 'shifter' ||
|
||
w.category.toLowerCase().contains('действие'))
|
||
.toList();
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Apply Sorting
|
||
switch (_sortBy) {
|
||
case 'system':
|
||
list = List.of(list)..sort((a, b) {
|
||
final sysA = _getSystemOrder(a.description);
|
||
final sysB = _getSystemOrder(b.description);
|
||
if (sysA != sysB) return sysA.compareTo(sysB);
|
||
return a.word.compareTo(b.word);
|
||
});
|
||
break;
|
||
case 'alpha':
|
||
list = List.of(list)..sort((a, b) => a.word.compareTo(b.word));
|
||
break;
|
||
case 'rank':
|
||
list = List.of(list)..sort((a, b) {
|
||
final r = a.lookup.compareTo(b.lookup);
|
||
if (r != 0) return r;
|
||
return a.sessionNumber.compareTo(b.sessionNumber);
|
||
});
|
||
break;
|
||
case 'layers':
|
||
list = List.of(list)..sort((a, b) {
|
||
final diff = b.layers.length.compareTo(a.layers.length);
|
||
if (diff != 0) return diff;
|
||
return a.sessionNumber.compareTo(b.sessionNumber);
|
||
});
|
||
break;
|
||
case 'id':
|
||
default:
|
||
list = List.of(list)..sort((a, b) => a.sessionNumber.compareTo(b.sessionNumber));
|
||
break;
|
||
}
|
||
|
||
setState(() {
|
||
_filteredWords = list;
|
||
});
|
||
}
|
||
|
||
Future<void> _openDetail(WordItem word) async {
|
||
HapticFeedback.selectionClick();
|
||
await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (context) => SymbolDetailScreen(word: word),
|
||
),
|
||
);
|
||
if (mounted) {
|
||
setState(() {
|
||
_applyFilter();
|
||
});
|
||
}
|
||
}
|
||
|
||
static const _tagToCanonId = {
|
||
'Руны': 'runes',
|
||
'Огам': 'ogham',
|
||
'Каббала': 'kabbalah',
|
||
'Алхимия': 'alchemy',
|
||
'Таро': 'tarot',
|
||
'Ленорман': 'lenormand',
|
||
'И Цзин': 'iching',
|
||
'Геомантия': 'geomancy',
|
||
'Додекада Созвездий': 'western_zodiac',
|
||
'Китайский гороскоп': 'zodiac',
|
||
'Космология': 'cosmology',
|
||
'Септенер': 'days_of_week',
|
||
'Дни недели': 'days_of_week',
|
||
'Планетарный Септенер': 'days_of_week',
|
||
'Додекада': 'months_of_year',
|
||
'Месяцы года': 'months_of_year',
|
||
'Зодиак': 'western_zodiac',
|
||
'Нумерология': 'numerology',
|
||
'Матрица': 'numerology',
|
||
};
|
||
|
||
void _openTaggedStation(String sys, String detail) {
|
||
final canonId = _tagToCanonId[sys];
|
||
if (canonId == null) return;
|
||
final trad = _traditions.byId(canonId);
|
||
if (trad == null) return;
|
||
final hay = detail.toLowerCase();
|
||
var bestScore = 0;
|
||
String? bestId;
|
||
for (final st in trad.stations) {
|
||
var score = 0;
|
||
final name = st.name.toLowerCase();
|
||
if (name.isNotEmpty && (hay.contains(name) || name.contains(hay))) {
|
||
score += 12;
|
||
}
|
||
for (final tok in name.split(RegExp(r'[^0-9a-zа-яё]+'))) {
|
||
if (tok.length > 2 && hay.contains(tok)) score += 2;
|
||
}
|
||
final sid = st.id.replaceAll('_', ' ').toLowerCase();
|
||
if (sid.isNotEmpty && hay.contains(sid)) score += 4;
|
||
final prim = st.primary?.word.toLowerCase();
|
||
if (prim != null && prim.isNotEmpty && hay.contains(prim)) score += 3;
|
||
if (score > bestScore) {
|
||
bestScore = score;
|
||
bestId = st.id;
|
||
}
|
||
}
|
||
final filter = TraditionsRepository.filterToCanonId.entries
|
||
.where((e) => e.value == canonId);
|
||
_searchController.removeListener(_applyFilter);
|
||
_searchController.clear();
|
||
_searchController.addListener(_applyFilter);
|
||
setState(() {
|
||
_atlasMode = 'canon';
|
||
_selectedTraditionId = canonId;
|
||
_focusStationId = bestScore >= 2 ? bestId : trad.stations.first.id;
|
||
if (filter.isNotEmpty) _selectedFilter = filter.first.key;
|
||
});
|
||
}
|
||
|
||
Future<void> _openWordById(int id, [String name = '']) async {
|
||
final named = name.trim().isEmpty ? null : _repo.getWordByName(name.trim());
|
||
if (named != null) {
|
||
await _openDetail(named);
|
||
return;
|
||
}
|
||
if (name.trim().isNotEmpty) return;
|
||
final word = _repo.getWordById(id) ?? _repo.getWordBySessionNumber(id);
|
||
if (word == null) return;
|
||
await _openDetail(word);
|
||
}
|
||
|
||
void _showSyncDialog() {
|
||
final count = _repo.customEditsCount;
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: const Color(0xFF1E2238),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||
title: const Row(
|
||
children: [
|
||
Icon(Icons.sync_alt_rounded, color: Color(0xFFFFD54F)),
|
||
SizedBox(width: 8),
|
||
Text('Синхронизация правок', style: TextStyle(color: Color(0xFFFFF9C4), fontSize: 18, fontWeight: FontWeight.bold)),
|
||
],
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Ваших правок на телефоне: $count',
|
||
style: const TextStyle(color: Color(0xFFE8EAF6), fontWeight: FontWeight.bold, fontSize: 14),
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'Вы можете выгрузить все свои исправления в виде JSON и перенести на ПК в релиз, либо загрузить ранее сохраненные правки.',
|
||
style: TextStyle(color: Color(0xFFB0BEC5), fontSize: 13, height: 1.4),
|
||
),
|
||
const SizedBox(height: 16),
|
||
ElevatedButton.icon(
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: const Color(0xFF2A2D4A),
|
||
foregroundColor: const Color(0xFFFFD54F),
|
||
minimumSize: const Size.fromHeight(42),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
icon: const Icon(Icons.copy_rounded, size: 18),
|
||
label: const Text('Скопировать JSON правок'),
|
||
onPressed: () {
|
||
final jsonStr = _repo.exportCustomEditsJson();
|
||
Clipboard.setData(ClipboardData(text: jsonStr));
|
||
HapticFeedback.mediumImpact();
|
||
Navigator.pop(ctx);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('JSON с правками ($count шт.) скопирован в буфер! Передайте его на ПК.'),
|
||
backgroundColor: const Color(0xFF2E7D32),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
OutlinedButton.icon(
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: const Color(0xFF81C784),
|
||
side: const BorderSide(color: Color(0xFF81C784)),
|
||
minimumSize: const Size.fromHeight(42),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
icon: const Icon(Icons.file_download_outlined, size: 18),
|
||
label: const Text('Импортировать JSON правок'),
|
||
onPressed: () {
|
||
Navigator.pop(ctx);
|
||
_showImportDialog();
|
||
},
|
||
),
|
||
if (count > 0) ...[
|
||
const SizedBox(height: 8),
|
||
TextButton.icon(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: const Color(0xFFE57373),
|
||
minimumSize: const Size.fromHeight(36),
|
||
),
|
||
icon: const Icon(Icons.delete_outline_rounded, size: 16),
|
||
label: const Text('Сбросить все мои правки'),
|
||
onPressed: () async {
|
||
Navigator.pop(ctx);
|
||
_confirmClearAll();
|
||
},
|
||
),
|
||
],
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: const Text('Закрыть', style: TextStyle(color: Color(0xFF9FA8DA))),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showImportDialog() {
|
||
final controller = TextEditingController();
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: const Color(0xFF1E2238),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
||
title: const Text('Импорт JSON правок', style: TextStyle(color: Color(0xFFFFF9C4), fontSize: 17, fontWeight: FontWeight.bold)),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('Вставьте текст экспортированного JSON:', style: TextStyle(color: Color(0xFFB0BEC5), fontSize: 13)),
|
||
const SizedBox(height: 8),
|
||
TextField(
|
||
controller: controller,
|
||
maxLines: 6,
|
||
style: const TextStyle(color: Colors.white, fontSize: 12, fontFamily: 'monospace'),
|
||
decoration: InputDecoration(
|
||
hintText: '{\n "знак": { "core": "...", ... }\n}',
|
||
hintStyle: const TextStyle(color: Color(0xFF546E7A)),
|
||
filled: true,
|
||
fillColor: const Color(0xFF151829),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: const Text('Отмена', style: TextStyle(color: Color(0xFF9FA8DA))),
|
||
),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: const Color(0xFF2E7D32)),
|
||
onPressed: () async {
|
||
final text = controller.text.trim();
|
||
if (text.isEmpty) return;
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
final nav = Navigator.of(ctx);
|
||
final count = await _repo.importCustomEditsJson(text);
|
||
if (!mounted) return;
|
||
nav.pop();
|
||
setState(() => _applyFilter());
|
||
messenger.showSnackBar(
|
||
SnackBar(
|
||
content: Text('Успешно импортировано правок: $count'),
|
||
backgroundColor: const Color(0xFF2E7D32),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
},
|
||
child: const Text('Применить'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _confirmClearAll() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: const Color(0xFF1E2238),
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
title: const Text('Удалить ВСЕ правки?', style: TextStyle(color: Color(0xFFE57373), fontWeight: FontWeight.bold)),
|
||
content: const Text(
|
||
'Вы действительно хотите сбросить все сделанные вами редакции знаков? База вернется к состоянию из релиза.',
|
||
style: TextStyle(color: Color(0xFFE0E0E0)),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx),
|
||
child: const Text('Отмена', style: TextStyle(color: Color(0xFF9FA8DA))),
|
||
),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: const Color(0xFFE57373)),
|
||
onPressed: () async {
|
||
Navigator.pop(ctx);
|
||
await _repo.clearAllCustomEdits();
|
||
if (mounted) {
|
||
setState(() => _applyFilter());
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Все пользовательские правки удалены'),
|
||
behavior: SnackBarBehavior.floating,
|
||
),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('Удалить всё'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final count = _filteredWords.length;
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFF0C0E1A),
|
||
appBar: AppBar(
|
||
title: const Row(
|
||
children: [
|
||
GradientStar(size: 22),
|
||
SizedBox(width: 8),
|
||
Text(
|
||
'Справочник 999',
|
||
style: TextStyle(fontWeight: FontWeight.w900),
|
||
),
|
||
],
|
||
),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: 'Синхронизация и экспорт правок',
|
||
icon: Badge(
|
||
isLabelVisible: _repo.customEditsCount > 0,
|
||
label: Text('${_repo.customEditsCount}'),
|
||
backgroundColor: const Color(0xFF81C784),
|
||
child: const Icon(Icons.sync_alt_rounded, color: Color(0xFFFFD54F)),
|
||
),
|
||
onPressed: _showSyncDialog,
|
||
),
|
||
if (_atlasMode == 'words')
|
||
PopupMenuButton<String>(
|
||
tooltip: 'Сортировка',
|
||
icon: const Icon(Icons.sort_rounded, color: Color(0xFFFFD54F)),
|
||
initialValue: _sortBy,
|
||
onSelected: (val) {
|
||
setState(() {
|
||
_sortBy = val;
|
||
_applyFilter();
|
||
});
|
||
},
|
||
itemBuilder: (context) => const [
|
||
PopupMenuItem(
|
||
value: 'system',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.auto_awesome_rounded, size: 18, color: Color(0xFFFFD54F)),
|
||
SizedBox(width: 8),
|
||
Text('По сакральным системам'),
|
||
],
|
||
),
|
||
),
|
||
PopupMenuItem(
|
||
value: 'id',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.format_list_numbered, size: 18),
|
||
SizedBox(width: 8),
|
||
Text('По номеру (#000..#999)'),
|
||
],
|
||
),
|
||
),
|
||
PopupMenuItem(
|
||
value: 'alpha',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.sort_by_alpha, size: 18),
|
||
SizedBox(width: 8),
|
||
Text('По алфавиту (А..Я)'),
|
||
],
|
||
),
|
||
),
|
||
PopupMenuItem(
|
||
value: 'layers',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.layers_rounded, size: 18),
|
||
SizedBox(width: 8),
|
||
Text('Сначала со слоями'),
|
||
],
|
||
),
|
||
),
|
||
PopupMenuItem(
|
||
value: 'rank',
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.military_tech_rounded, size: 18),
|
||
SizedBox(width: 8),
|
||
Text('По рангу (A → B → C)'),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
body: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||
child: TextField(
|
||
controller: _searchController,
|
||
decoration: InputDecoration(
|
||
hintText: _atlasMode == 'canon'
|
||
? 'Поиск по станции, лору или слову...'
|
||
: 'Поиск по слову, номеру #000, закону...',
|
||
hintStyle: const TextStyle(color: Color(0xFF8C93B8), fontSize: 14),
|
||
prefixIcon: const Icon(Icons.search_rounded, color: Color(0xFFFFD54F)),
|
||
suffixIcon: _searchController.text.isNotEmpty
|
||
? IconButton(
|
||
icon: const Icon(Icons.clear_rounded, size: 18),
|
||
onPressed: () {
|
||
_searchController.clear();
|
||
},
|
||
)
|
||
: null,
|
||
filled: true,
|
||
fillColor: const Color(0xFF151829),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(color: Color(0xFF2A2D4A)),
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
borderSide: const BorderSide(color: Color(0xFFFFD54F), width: 1.5),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
|
||
// Filters row
|
||
SizedBox(
|
||
height: 38,
|
||
child: SingleChildScrollView(
|
||
key: const Key('catalog-filters'),
|
||
scrollDirection: Axis.horizontal,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
children: [
|
||
for (final filter in _filters) ...[
|
||
if (filter != _filters.first) const SizedBox(width: 8),
|
||
ChoiceChip(
|
||
label: Text(filter),
|
||
labelStyle: TextStyle(
|
||
color: filter == _selectedFilter ? const Color(0xFF1B1D30) : const Color(0xFFD1D5DB),
|
||
fontSize: 12,
|
||
fontWeight: filter == _selectedFilter ? FontWeight.bold : FontWeight.normal,
|
||
),
|
||
selected: filter == _selectedFilter,
|
||
selectedColor: const Color(0xFFFFD54F),
|
||
backgroundColor: const Color(0xFF151829),
|
||
side: BorderSide(
|
||
color: filter == _selectedFilter ? const Color(0xFFFFD54F) : const Color(0xFF2A2D4A),
|
||
),
|
||
onSelected: (selected) {
|
||
if (selected) {
|
||
setState(() {
|
||
_selectedFilter = filter;
|
||
final canonId = TraditionsRepository.filterToCanonId[filter];
|
||
if (canonId != null) {
|
||
_atlasMode = 'canon';
|
||
_selectedTraditionId = canonId;
|
||
} else {
|
||
_atlasMode = 'words';
|
||
_selectedTraditionId = null;
|
||
}
|
||
_applyFilter();
|
||
});
|
||
}
|
||
},
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
if (_atlasMode == 'words' && _selectedFilter != '🌐 Другие символы')
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||
child: Row(
|
||
children: [
|
||
Flexible(
|
||
child: Text(
|
||
'Найдено знаков: $count',
|
||
style: const TextStyle(
|
||
color: Color(0xFF8C93B8),
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
const Flexible(
|
||
child: Text(
|
||
'Нажмите для полного досье',
|
||
style: TextStyle(
|
||
color: Color(0xFF5C6280),
|
||
fontSize: 11,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
Expanded(
|
||
child: _selectedFilter == '🌐 Другие символы'
|
||
? ExternalSymbolsBrowser(searchQuery: _searchController.text.trim())
|
||
: _atlasMode == 'canon' &&
|
||
_selectedTraditionId != null &&
|
||
_traditions.byId(_selectedTraditionId!) != null
|
||
? TraditionsAtlasView(
|
||
traditions: _traditions.traditions,
|
||
selected: _traditions.byId(_selectedTraditionId!),
|
||
searchQuery: _searchController.text.trim(),
|
||
focusStationId: _focusStationId,
|
||
onSelectTradition: (t) {
|
||
setState(() {
|
||
_selectedTraditionId = t.id;
|
||
_focusStationId = null;
|
||
final match = TraditionsRepository.filterToCanonId.entries
|
||
.where((e) => e.value == t.id);
|
||
if (match.isNotEmpty) {
|
||
_selectedFilter = match.first.key;
|
||
}
|
||
});
|
||
},
|
||
onBackToOverview: () {
|
||
setState(() {
|
||
_selectedTraditionId = null;
|
||
_selectedFilter = 'Все';
|
||
_atlasMode = 'words';
|
||
});
|
||
},
|
||
onOpenWord: _openWordById,
|
||
)
|
||
: _filteredWords.isEmpty
|
||
? const Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
GradientStar(size: 40),
|
||
SizedBox(height: 16),
|
||
Text(
|
||
'Символ не найден',
|
||
style: TextStyle(
|
||
color: Color(0xFF8C93B8),
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)
|
||
: ListView.separated(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||
itemCount: _filteredWords.length,
|
||
separatorBuilder: (context, index) => const SizedBox(height: 10),
|
||
itemBuilder: (context, index) {
|
||
final item = _filteredWords[index];
|
||
return _buildSymbolCard(item);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _buildSystemBadges(String desc) {
|
||
final List<Widget> badges = [];
|
||
final reg = RegExp(r'\[([А-Яа-яЁёA-Za-z\s]+):([^\]]+)\]');
|
||
for (final m in reg.allMatches(desc)) {
|
||
final sys = m.group(1)?.trim() ?? '';
|
||
final detail = m.group(2)?.trim() ?? '';
|
||
Color color;
|
||
String icon;
|
||
switch (sys) {
|
||
case 'Руны':
|
||
color = const Color(0xFF90CAF9);
|
||
icon = 'ᚱ';
|
||
break;
|
||
case 'Огам':
|
||
color = const Color(0xFFA5D6A7);
|
||
icon = '᚛';
|
||
break;
|
||
case 'Каббала':
|
||
color = const Color(0xFFCE93D8);
|
||
icon = '✡️';
|
||
break;
|
||
case 'Алхимия':
|
||
color = const Color(0xFFFFCC80);
|
||
icon = '⚗️';
|
||
break;
|
||
case 'Таро':
|
||
color = const Color(0xFFEF9A9A);
|
||
icon = '🎴';
|
||
break;
|
||
case 'Ленорман':
|
||
color = const Color(0xFF80CBC4);
|
||
icon = '🪶';
|
||
break;
|
||
case 'И Цзин':
|
||
color = const Color(0xFFECEFF1);
|
||
icon = '☯️';
|
||
break;
|
||
case 'Геомантия':
|
||
color = const Color(0xFFFFAB91);
|
||
icon = '⌛';
|
||
break;
|
||
case 'Додекада Созвездий':
|
||
color = const Color(0xFF80DEEA);
|
||
icon = '♈';
|
||
break;
|
||
case 'Китайский гороскоп':
|
||
color = const Color(0xFFFFE082);
|
||
icon = '🐉';
|
||
break;
|
||
case 'Космология':
|
||
color = const Color(0xFFB39DDB);
|
||
icon = '🌌';
|
||
break;
|
||
default:
|
||
color = const Color(0xFFFFD54F);
|
||
icon = '✨';
|
||
break;
|
||
}
|
||
badges.add(
|
||
GestureDetector(
|
||
onTap: () => _openTaggedStation(sys, detail),
|
||
child: Container(
|
||
margin: const EdgeInsets.only(right: 6, bottom: 4),
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: color.withValues(alpha: 0.35)),
|
||
),
|
||
child: Text(
|
||
'$icon ${WordItemLocalization.localizeSystem(sys)}: $detail',
|
||
style: TextStyle(
|
||
color: color,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
return badges;
|
||
}
|
||
|
||
Widget _buildSymbolCard(WordItem item) {
|
||
final previewText = item.displayMeaning;
|
||
final systemBadges = _buildSystemBadges(item.description);
|
||
|
||
return InkWell(
|
||
onTap: () => _openDetail(item),
|
||
borderRadius: BorderRadius.circular(18),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF151829),
|
||
borderRadius: BorderRadius.circular(18),
|
||
border: Border.all(
|
||
color: item.layers.isNotEmpty
|
||
? const Color(0xFFFFD54F).withValues(alpha: 0.35)
|
||
: const Color(0xFF2A2D4A),
|
||
),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.2),
|
||
blurRadius: 6,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Top Row: Number, Word, Pills
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF2A2D4A),
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)),
|
||
),
|
||
child: Text(
|
||
'#${item.formattedSessionNumber}',
|
||
style: const TextStyle(
|
||
color: Color(0xFFFFD54F),
|
||
fontWeight: FontWeight.w900,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
item.word.toUpperCase(),
|
||
style: const TextStyle(
|
||
color: Color(0xFFFFF9C4),
|
||
fontWeight: FontWeight.w900,
|
||
fontSize: 17,
|
||
letterSpacing: 0.8,
|
||
),
|
||
),
|
||
),
|
||
if (item.isUserEdited)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||
margin: const EdgeInsets.only(right: 6),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF2E7D32).withValues(alpha: 0.3),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: const Color(0xFF81C784)),
|
||
),
|
||
child: const Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.edit_note_rounded, color: Color(0xFFA5D6A7), size: 12),
|
||
SizedBox(width: 3),
|
||
Text('Правка', style: TextStyle(color: Color(0xFFA5D6A7), fontWeight: FontWeight.bold, fontSize: 11)),
|
||
],
|
||
),
|
||
),
|
||
if (item.layers.isNotEmpty)
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
|
||
margin: const EdgeInsets.only(right: 6),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF1E2238),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.layers_rounded, color: Color(0xFFFFD54F), size: 12),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
'${item.layers.length}',
|
||
style: const TextStyle(
|
||
color: Color(0xFFFFD54F),
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 11,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Icon(Icons.chevron_right_rounded, color: Color(0xFF8C93B8), size: 20),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
|
||
// Law / Core snippet
|
||
Text(
|
||
previewText,
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: Color(0xFFC5CAE9),
|
||
fontSize: 13,
|
||
height: 1.4,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
|
||
// System Badges
|
||
if (systemBadges.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
children: systemBadges,
|
||
),
|
||
],
|
||
|
||
// Category & POS Badges
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 6,
|
||
children: [
|
||
if (item.category.isNotEmpty)
|
||
_miniBadge(item.category, const Color(0xFFCE93D8)),
|
||
_miniBadge(item.partOfSpeech, const Color(0xFF80D8FF)),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _miniBadge(String text, Color color) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||
),
|
||
child: Text(
|
||
text,
|
||
style: TextStyle(
|
||
color: color,
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
|