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

This commit is contained in:
2026-10-09 15:26:58 +05:00
parent 7f91366201
commit 0b12e4583b
244 changed files with 611461 additions and 1617 deletions
+755
View File
@@ -0,0 +1,755 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/tradition_model.dart';
import '../screens/lenormand_extras_screen.dart';
import '../screens/lenormand_station_screen.dart';
import '../services/lenormand_pairs_repository.dart';
import '../services/ogham_feda_repository.dart';
import '../services/tarot_majors_repository.dart';
typedef OpenLexiconWord = void Function(int id, String word);
class TraditionsAtlasView extends StatelessWidget {
final List<TraditionCanon> traditions;
final TraditionCanon? selected;
final String searchQuery;
final String? focusStationId;
final ValueChanged<TraditionCanon> onSelectTradition;
final VoidCallback onBackToOverview;
final OpenLexiconWord onOpenWord;
const TraditionsAtlasView({
super.key,
required this.traditions,
required this.selected,
required this.searchQuery,
this.focusStationId,
required this.onSelectTradition,
required this.onBackToOverview,
required this.onOpenWord,
});
@override
Widget build(BuildContext context) {
if (selected == null) {
return _Overview(
traditions: traditions,
onSelect: onSelectTradition,
);
}
return _TraditionStations(
tradition: selected!,
traditions: traditions,
searchQuery: searchQuery,
focusStationId: focusStationId,
onBack: onBackToOverview,
onOpenWord: onOpenWord,
onSelectTradition: onSelectTradition,
);
}
}
class _Overview extends StatelessWidget {
final List<TraditionCanon> traditions;
final ValueChanged<TraditionCanon> onSelect;
const _Overview({required this.traditions, required this.onSelect});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xCC1E2238), Color(0xE6151829)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'🏛️ Сакральные каноны',
style: TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
SizedBox(height: 6),
Text(
'Выберите традицию, чтобы открыть её станции, первородные архетипы и созвучия базы 999 слов.',
style: TextStyle(color: Color(0xFFCBD5E1), fontSize: 13, height: 1.45),
),
],
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: Text(
'${traditions.length} великих систем',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w800,
fontSize: 13,
),
),
),
const SizedBox(height: 12),
...traditions.map(
(t) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _TraditionCard(tradition: t, onTap: () => onSelect(t)),
),
),
],
);
}
}
class _TraditionCard extends StatelessWidget {
final TraditionCanon tradition;
final VoidCallback onTap;
const _TraditionCard({required this.tradition, required this.onTap});
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x1AFFFFFF)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x1AFFD54F),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Text(tradition.icon, style: const TextStyle(fontSize: 26)),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tradition.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 16,
),
),
const SizedBox(height: 2),
Text(
'${tradition.count} сакральных станций',
style: const TextStyle(
color: Color(0xFF90CDF4),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text(
tradition.description,
style: const TextStyle(
color: Color(0xFFCBD5E1),
fontSize: 13,
height: 1.45,
),
),
const SizedBox(height: 12),
const Row(
children: [
Text(
'Открыть атлас традиции',
style: TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w700,
fontSize: 13,
),
),
SizedBox(width: 6),
Icon(Icons.arrow_forward_rounded, color: Color(0xFFFFD54F), size: 16),
],
),
],
),
),
),
);
}
}
class _TraditionStations extends StatefulWidget {
final TraditionCanon tradition;
final List<TraditionCanon> traditions;
final String searchQuery;
final String? focusStationId;
final VoidCallback onBack;
final OpenLexiconWord onOpenWord;
final ValueChanged<TraditionCanon> onSelectTradition;
const _TraditionStations({
required this.tradition,
required this.traditions,
required this.searchQuery,
required this.focusStationId,
required this.onBack,
required this.onOpenWord,
required this.onSelectTradition,
});
@override
State<_TraditionStations> createState() => _TraditionStationsState();
}
class _TraditionStationsState extends State<_TraditionStations> {
final Map<String, GlobalKey> _stationKeys = {};
String? _highlightedId;
TraditionCanon get tradition => widget.tradition;
String get searchQuery => widget.searchQuery;
VoidCallback get onBack => widget.onBack;
OpenLexiconWord get onOpenWord => widget.onOpenWord;
bool get _isLenormand => tradition.id == 'lenormand';
bool get _isTarot => tradition.id == 'tarot';
bool get _isOgham => tradition.id == 'ogham';
bool get _hasDeck => _isLenormand || _isTarot || _isOgham;
GlobalKey _keyFor(String id) => _stationKeys.putIfAbsent(id, GlobalKey.new);
@override
void initState() {
super.initState();
_scheduleFocus();
}
@override
void didUpdateWidget(covariant _TraditionStations oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.focusStationId != widget.focusStationId ||
oldWidget.tradition.id != widget.tradition.id) {
_scheduleFocus();
}
}
void _scheduleFocus() {
final id = widget.focusStationId;
if (id == null || id.isEmpty) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final ctx = _stationKeys[id]?.currentContext;
if (ctx != null) {
Scrollable.ensureVisible(
ctx,
alignment: 0.25,
duration: const Duration(milliseconds: 320),
curve: Curves.easeOut,
);
}
setState(() => _highlightedId = id);
});
}
@override
Widget build(BuildContext context) {
final stations = tradition.stations
.where((s) => s.matchesQuery(searchQuery))
.toList();
final groups = <String, List<TraditionStation>>{};
final order = <String>[];
for (final st in stations) {
final key = st.groupKey.isEmpty ? tradition.name : st.groupKey;
if (!groups.containsKey(key)) {
groups[key] = [];
order.add(key);
}
groups[key]!.add(st);
}
final hasGroups = order.length > 1;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xCC1E2238), Color(0xE6151829)],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
tooltip: 'К списку слов',
onPressed: onBack,
icon: const Icon(Icons.arrow_back_rounded, color: Color(0xFFFFD54F)),
),
Text(tradition.icon, style: const TextStyle(fontSize: 26)),
const SizedBox(width: 10),
Expanded(
child: Text(
tradition.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0x26FFD54F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x66FFD54F)),
),
child: Text(
'${stations.length} из ${tradition.count}',
style: const TextStyle(
color: Color(0xFFFFD54F),
fontWeight: FontWeight.w800,
fontSize: 12,
),
),
),
],
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 8, 4),
child: Text(
tradition.description,
style: const TextStyle(color: Color(0xFFCBD5E1), fontSize: 13, height: 1.45),
),
),
if (_isLenormand)
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 4),
child: OutlinedButton.icon(
onPressed: () {
HapticFeedback.selectionClick();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const LenormandExtrasScreen(),
),
);
},
icon: const Icon(Icons.layers_outlined, color: Color(0xFFFFD54F), size: 18),
label: const Text(
'Триады, любовь, работа, карма',
style: TextStyle(color: Color(0xFFFFD54F), fontWeight: FontWeight.w700),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Color(0x66FFD54F)),
),
),
),
],
),
),
const SizedBox(height: 8),
if (stations.isEmpty)
const Padding(
padding: EdgeInsets.only(top: 48),
child: Center(
child: Text(
'Станций не найдено',
style: TextStyle(color: Color(0xFF8C93B8), fontWeight: FontWeight.bold),
),
),
)
else
for (final gKey in order) ...[
if (hasGroups) _GroupHeader(label: gKey),
for (final st in groups[gKey]!)
Padding(
key: _keyFor(st.id),
padding: const EdgeInsets.only(bottom: 12),
child: _StationCard(
station: st,
highlighted: st.id == _highlightedId,
onOpenWord: onOpenWord,
deckButtonLabel: _isOgham ? 'Значение феды' : (_isTarot ? 'Значение аркана и пары' : 'Описания и сочетания'),
onOpenLenormand: _hasDeck
? () {
HapticFeedback.selectionClick();
if (_isTarot) {
final card = TarotMajorsRepository().byCanonId(st.id);
if (card == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LenormandStationScreen(
number: card.n,
lookup: TarotMajorsRepository().byNumber,
title: 'Таро',
),
),
);
return;
}
if (_isOgham) {
final card = OghamFedaRepository().byCanonId(st.id);
if (card == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LenormandStationScreen(
number: card.n,
lookup: OghamFedaRepository().byNumber,
title: 'Огам',
),
),
);
return;
}
final card = LenormandPairsRepository().byStationId(st.id);
if (card == null) return;
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LenormandStationScreen(number: card.n),
),
);
}
: null,
),
),
],
],
);
}
}
class _GroupHeader extends StatelessWidget {
final String label;
const _GroupHeader({required this.label});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 16, 0, 10),
child: Row(
children: [
const Expanded(child: Divider(color: Color(0x4DFBBF24), height: 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0x14FBBF24),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0x40FBBF24)),
),
child: Text(
label.toUpperCase(),
style: const TextStyle(
color: Color(0xFFFBBF24),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
),
),
),
),
const Expanded(child: Divider(color: Color(0x4DFBBF24), height: 1)),
],
),
);
}
}
class _StationCard extends StatelessWidget {
final TraditionStation station;
final bool highlighted;
final OpenLexiconWord onOpenWord;
final VoidCallback? onOpenLenormand;
final String? deckButtonLabel;
const _StationCard({
required this.station,
required this.onOpenWord,
this.highlighted = false,
this.onOpenLenormand,
this.deckButtonLabel,
});
@override
Widget build(BuildContext context) {
final sub = station.arcanum?.isNotEmpty == true
? station.arcanum!
: station.groupSub;
final primary = station.primary;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF151829),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: highlighted ? const Color(0xFFFFD54F) : const Color(0x1AFFFFFF),
width: highlighted ? 1.6 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x1AFFD54F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: station.geomancyRows.isNotEmpty
? _GeomancyGlyph(rows: station.geomancyRows)
: Text(
station.symbol,
style: const TextStyle(
fontSize: 22,
color: Color(0xFFFFD54F),
height: 1.1,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
text: station.name,
style: const TextStyle(
color: Color(0xFFFFF9C4),
fontWeight: FontWeight.w900,
fontSize: 15,
height: 1.25,
),
children: [
if (station.hebrew != null && station.hebrew!.isNotEmpty)
TextSpan(
text: ' ${station.hebrew}',
style: const TextStyle(
color: Color(0xD9D4A855),
fontWeight: FontWeight.w500,
fontSize: 18,
height: 1.2,
),
),
if (station.hanzi != null && station.hanzi!.isNotEmpty)
TextSpan(
text: ' ${station.hanzi}',
style: const TextStyle(
color: Color(0xD9D4A855),
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.2,
),
),
if (station.arabic != null && station.arabic!.isNotEmpty)
TextSpan(
text: ' ${station.arabic}',
style: const TextStyle(
color: Color(0xD9D4A855),
fontWeight: FontWeight.w500,
fontSize: 17,
height: 1.2,
),
),
],
),
),
if (sub.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
sub,
style: const TextStyle(
color: Color(0xFF90CDF4),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
],
),
const SizedBox(height: 10),
Text(
station.lore,
style: const TextStyle(
color: Color(0xFFD1D5DB),
fontSize: 13,
height: 1.45,
),
),
if (primary != null) ...[
const SizedBox(height: 12),
Material(
color: const Color(0x14FFD54F),
borderRadius: BorderRadius.circular(10),
child: InkWell(
onTap: () => onOpenWord(primary.id, primary.word),
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x4DFFD54F)),
),
child: Row(
children: [
if (primary.emoji.isNotEmpty)
Text(primary.emoji, style: const TextStyle(fontSize: 16)),
if (primary.emoji.isNotEmpty) const SizedBox(width: 8),
Expanded(
child: Text(
primary.word.toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14,
letterSpacing: 0.4,
),
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFFFFD54F), size: 18),
],
),
),
),
),
],
if (station.cluster.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final cw in station.cluster)
InkWell(
onTap: () => onOpenWord(cw.id, cw.word),
borderRadius: BorderRadius.circular(6),
child: Tooltip(
message: 'Открыть знак «${cw.word}»',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: const Color(0x0FFFFFFF),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0x1FFFFFFF)),
),
child: Text(
'${cw.emoji} ${cw.word}',
style: const TextStyle(
color: Color(0xFFE2E8F0),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
),
],
),
],
if (onOpenLenormand != null) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: onOpenLenormand,
icon: const Icon(Icons.hub_outlined, size: 18),
label: Text(deckButtonLabel ?? 'Описания и сочетания'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0x26FFD54F),
foregroundColor: const Color(0xFFFFD54F),
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
],
],
),
);
}
}
/// Four-row geomantic figure: one centered dot or a centered pair per row.
class _GeomancyGlyph extends StatelessWidget {
final List<int> rows;
const _GeomancyGlyph({required this.rows});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final n in rows)
Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < n; i++) ...[
if (i > 0) const SizedBox(width: 5),
Container(
width: 7,
height: 7,
decoration: const BoxDecoration(
color: Color(0xFFFFD54F),
shape: BoxShape.circle,
),
),
],
],
),
),
],
);
}
}