From 6f9d4d4cca024a1488b2af0f7bbaa31385316ee1 Mon Sep 17 00:00:00 2001 From: Alma Alexyz Date: Mon, 31 Aug 2026 15:23:59 +0500 Subject: [PATCH] feat: unified 1..9 words session narrative, dice input before reveal, dedicated results screen, and clean session history grouping --- lib/main.dart | 9 +- lib/models/history_entry_model.dart | 135 ++++--- lib/screens/history_screen.dart | 390 +++++++++---------- lib/screens/home_search_screen.dart | 512 +++++++++++++++---------- lib/screens/oracle_spread_screen.dart | 371 ------------------ lib/screens/session_result_screen.dart | 359 +++++++++++++++++ lib/services/ai_oracle_service.dart | 284 +++++++------- lib/services/words_repository.dart | 168 ++++---- lib/widgets/word_card_widget.dart | 16 +- test/widget_test.dart | 5 +- test/words_repository_test.dart | 57 ++- 11 files changed, 1207 insertions(+), 1099 deletions(-) delete mode 100644 lib/screens/oracle_spread_screen.dart create mode 100644 lib/screens/session_result_screen.dart diff --git a/lib/main.dart b/lib/main.dart index 12e2195..eefde01 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import 'screens/catalog_screen.dart'; import 'screens/history_screen.dart'; import 'screens/home_search_screen.dart'; -import 'screens/oracle_spread_screen.dart'; import 'services/words_repository.dart'; import 'widgets/gradient_star_badge.dart'; @@ -92,7 +91,6 @@ class _MainNavigationContainerState extends State { final List _screens = const [ HomeSearchScreen(), - OracleSpreadScreen(), CatalogScreen(), HistoryScreen(), ]; @@ -112,15 +110,10 @@ class _MainNavigationContainerState extends State { }, destinations: const [ NavigationDestination( - icon: Icon(Icons.dialpad_outlined), + icon: Icon(Icons.casino_outlined), selectedIcon: GradientStar(size: 22), label: 'Оракул', ), - NavigationDestination( - icon: Icon(Icons.auto_awesome_outlined), - selectedIcon: GradientStar(size: 22), - label: 'Расклады', - ), NavigationDestination( icon: Icon(Icons.menu_book_outlined), selectedIcon: GradientStar(size: 22), diff --git a/lib/models/history_entry_model.dart b/lib/models/history_entry_model.dart index 2d81cd1..887c3f0 100644 --- a/lib/models/history_entry_model.dart +++ b/lib/models/history_entry_model.dart @@ -1,93 +1,124 @@ -class HistoryEntry { - final String id; - final DateTime timestamp; +import 'dart:convert'; +import 'word_model.dart'; + +class SessionWord { final String word; final int sessionNumber; final String partOfSpeech; + final String category; final String symbolicMeaning; - final String readingType; final String? positionTitle; - final String? question; - final String? aiInterpretation; - const HistoryEntry({ - required this.id, - required this.timestamp, + const SessionWord({ required this.word, required this.sessionNumber, required this.partOfSpeech, + required this.category, required this.symbolicMeaning, - this.readingType = 'Жребий', this.positionTitle, + }); + + String get formattedSessionNumber => sessionNumber.toString().padLeft(3, '0'); + + Map toMap() => { + 'word': word, + 'sessionNumber': sessionNumber, + 'partOfSpeech': partOfSpeech, + 'category': category, + 'symbolicMeaning': symbolicMeaning, + if (positionTitle != null) 'positionTitle': positionTitle, + }; + + factory SessionWord.fromMap(Map map) => SessionWord( + word: map['word'] as String? ?? '', + sessionNumber: map['sessionNumber'] as int? ?? 0, + partOfSpeech: map['partOfSpeech'] as String? ?? '', + category: map['category'] as String? ?? '', + symbolicMeaning: map['symbolicMeaning'] as String? ?? '', + positionTitle: map['positionTitle'] as String?, + ); + + factory SessionWord.fromWordItem(WordItem item, {String? positionTitle}) => SessionWord( + word: item.word, + sessionNumber: item.sessionNumber, + partOfSpeech: item.partOfSpeech, + category: item.category, + symbolicMeaning: item.description, + positionTitle: positionTitle, + ); +} + +class OracleSession { + final String id; + final DateTime timestamp; + final String? question; + final int wordsCount; + final List words; + final String? aiInterpretation; + + const OracleSession({ + required this.id, + required this.timestamp, this.question, + required this.wordsCount, + required this.words, this.aiInterpretation, }); - HistoryEntry copyWith({ + OracleSession copyWith({ String? id, DateTime? timestamp, - String? word, - int? sessionNumber, - String? partOfSpeech, - String? symbolicMeaning, - String? readingType, - String? positionTitle, String? question, + int? wordsCount, + List? words, String? aiInterpretation, }) { - return HistoryEntry( + return OracleSession( id: id ?? this.id, timestamp: timestamp ?? this.timestamp, - word: word ?? this.word, - sessionNumber: sessionNumber ?? this.sessionNumber, - partOfSpeech: partOfSpeech ?? this.partOfSpeech, - symbolicMeaning: symbolicMeaning ?? this.symbolicMeaning, - readingType: readingType ?? this.readingType, - positionTitle: positionTitle ?? this.positionTitle, question: question ?? this.question, + wordsCount: wordsCount ?? this.wordsCount, + words: words ?? this.words, aiInterpretation: aiInterpretation ?? this.aiInterpretation, ); } - String get formattedSessionNumber => sessionNumber.toString().padLeft(3, '0'); - String get formattedDate { final day = timestamp.day.toString().padLeft(2, '0'); final month = timestamp.month.toString().padLeft(2, '0'); final year = timestamp.year.toString(); final hour = timestamp.hour.toString().padLeft(2, '0'); - final minute = timestamp.minute.toString().padLeft(2, '0'); - final second = timestamp.second.toString().padLeft(2, '0'); - return '$day.$month.$year $hour:$minute:$second'; + final min = timestamp.minute.toString().padLeft(2, '0'); + final sec = timestamp.second.toString().padLeft(2, '0'); + return '$day.$month.$year $hour:$min:$sec'; } - Map toMap() { - return { - 'id': id, - 'timestamp': timestamp.toIso8601String(), - 'word': word, - 'sessionNumber': sessionNumber, - 'partOfSpeech': partOfSpeech, - 'symbolicMeaning': symbolicMeaning, - 'readingType': readingType, - 'positionTitle': positionTitle, - 'question': question, - 'aiInterpretation': aiInterpretation, - }; - } + Map toMap() => { + 'id': id, + 'timestamp': timestamp.toIso8601String(), + if (question != null) 'question': question, + 'wordsCount': wordsCount, + 'words': words.map((w) => w.toMap()).toList(), + if (aiInterpretation != null) 'aiInterpretation': aiInterpretation, + }; - factory HistoryEntry.fromMap(Map map) { - return HistoryEntry( - id: map['id'] as String? ?? DateTime.now().millisecondsSinceEpoch.toString(), + factory OracleSession.fromMap(Map map) { + return OracleSession( + id: map['id'] as String? ?? '', timestamp: DateTime.tryParse(map['timestamp'] as String? ?? '') ?? DateTime.now(), - word: map['word'] as String? ?? '', - sessionNumber: map['sessionNumber'] as int? ?? 0, - partOfSpeech: map['partOfSpeech'] as String? ?? '', - symbolicMeaning: map['symbolicMeaning'] as String? ?? '', - readingType: map['readingType'] as String? ?? 'Жребий', - positionTitle: map['positionTitle'] as String?, question: map['question'] as String?, + wordsCount: map['wordsCount'] as int? ?? (map['words'] as List?)?.length ?? 1, + words: (map['words'] as List?) + ?.map((item) => SessionWord.fromMap(item as Map)) + .toList() ?? + [], aiInterpretation: map['aiInterpretation'] as String?, ); } + + String toJson() => jsonEncode(toMap()); + factory OracleSession.fromJson(String source) => OracleSession.fromMap(jsonDecode(source)); } + +// Backward compatibility alias +typedef HistoryEntry = OracleSession; diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index a264306..9ebff07 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import '../models/history_entry_model.dart'; import '../services/words_repository.dart'; import '../widgets/gradient_star_badge.dart'; +import 'session_result_screen.dart'; class HistoryScreen extends StatefulWidget { const HistoryScreen({super.key}); @@ -26,7 +27,7 @@ class _HistoryScreenState extends State { ], ), content: const Text( - 'Все сохранённые записи гаданий, вопросов и AI-трактовок будут удалены.', + 'Все сохранённые сессии гаданий и AI-трактовки будут удалены.', ), actions: [ TextButton( @@ -48,21 +49,27 @@ class _HistoryScreenState extends State { ); } - void _copyEntry(HistoryEntry entry) { - var text = - '✨ Оракул 999 — Запись из Истории ✨\n' - 'Время: ${entry.formattedDate}\n' - '${entry.question != null && entry.question!.isNotEmpty ? "Вопрос: «${entry.question}»\n" : ""}' - 'Тип: ${entry.readingType}${entry.positionTitle != null ? ' (${entry.positionTitle})' : ''}\n' - 'Знак №${entry.formattedSessionNumber}: ${entry.word.toUpperCase()}\n' - 'Часть речи: ${entry.partOfSpeech}\n\n' - '🔮 Толкование:\n${entry.symbolicMeaning}'; + void _copySession(OracleSession session) { + final buf = StringBuffer(); + buf.writeln('✨ Оракул 999 — Запись Сессии ✨'); + buf.writeln('Дата: ${session.formattedDate}'); + if (session.question != null && session.question!.isNotEmpty) { + buf.writeln('Вопрос: «${session.question}»'); + } + buf.writeln('Знаков: ${session.words.length}\n'); - if (entry.aiInterpretation != null && entry.aiInterpretation!.isNotEmpty) { - text += '\n\n🤖 AI Трактовка (G4F):\n${entry.aiInterpretation}'; + for (int i = 0; i < session.words.length; i++) { + final w = session.words[i]; + buf.writeln('🔮 Знак #${w.formattedSessionNumber}: ${w.word.toUpperCase()} (${w.partOfSpeech})'); + buf.writeln(' ${w.symbolicMeaning}\n'); } - Clipboard.setData(ClipboardData(text: text)); + if (session.aiInterpretation != null && session.aiInterpretation!.isNotEmpty) { + buf.writeln('🤖 AI Трактовка (G4F):'); + buf.writeln(session.aiInterpretation); + } + + Clipboard.setData(ClipboardData(text: buf.toString())); HapticFeedback.lightImpact(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -70,7 +77,7 @@ class _HistoryScreenState extends State { children: [ GradientStar(size: 18), SizedBox(width: 10), - Expanded(child: Text('Запись скопирована в буфер обмена')), + Expanded(child: Text('Сессия скопирована в буфер обмена')), ], ), duration: const Duration(seconds: 2), @@ -84,7 +91,7 @@ class _HistoryScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - final historyList = _repo.history; + final sessionsList = _repo.sessions; return Scaffold( appBar: AppBar( @@ -93,13 +100,13 @@ class _HistoryScreenState extends State { GradientStar(size: 24), SizedBox(width: 10), Text( - 'История гаданий', + 'История сессий', style: TextStyle(fontWeight: FontWeight.w900), ), ], ), actions: [ - if (historyList.isNotEmpty) + if (sessionsList.isNotEmpty) IconButton( tooltip: 'Очистить историю', icon: const Icon(Icons.delete_outline_rounded), @@ -107,7 +114,7 @@ class _HistoryScreenState extends State { ), ], ), - body: historyList.isEmpty + body: sessionsList.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -115,7 +122,7 @@ class _HistoryScreenState extends State { const GradientStar(size: 48), const SizedBox(height: 16), Text( - 'История пока пуста', + 'История сессий пока пуста', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -124,7 +131,7 @@ class _HistoryScreenState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - 'Задайте вопрос и бросьте жребий, чтобы получить ответ оракула и сохранить его здесь.', + 'Задайте вопрос, совершите броски и откройте знаки, чтобы сохранить сессию здесь.', textAlign: TextAlign.center, style: TextStyle(color: theme.colorScheme.outline), ), @@ -134,207 +141,200 @@ class _HistoryScreenState extends State { ) : ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - itemCount: historyList.length, - separatorBuilder: (_, _) => const SizedBox(height: 10), + itemCount: sessionsList.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (context, index) { - final item = historyList[index]; + final session = sessionsList[index]; return Card( - elevation: 3, + elevation: 4, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(20), side: BorderSide( - color: const Color(0xFFFFD54F).withValues(alpha: 0.2), + color: const Color(0xFFFFD54F).withValues(alpha: 0.25), ), ), color: isDark ? const Color(0xFF1B1D30) : theme.colorScheme.surface, - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Top header: Timestamp and Reading type - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon( - Icons.access_time_rounded, - size: 14, - color: Color(0xFFFFD54F), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SessionResultScreen(session: session), + ), + ).then((_) => setState(() {})); + }, + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top Header: Date, Words Count, Actions + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + Icons.access_time_rounded, + size: 14, + color: Color(0xFFFFD54F), + ), + const SizedBox(width: 5), + Text( + session.formattedDate, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFFFD54F), + ), + ), + ], + ), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF2A2D4A), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.3), + ), + ), + child: Text( + '${session.words.length} ${session.words.length == 1 ? "знак" : (session.words.length < 5 ? "знака" : "знаков")}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFFFFF9C4), + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.copy_rounded, size: 16), + tooltip: 'Скопировать', + onPressed: () => _copySession(session), + ), + IconButton( + icon: const Icon(Icons.delete_outline, size: 16), + tooltip: 'Удалить', + onPressed: () async { + await _repo.deleteSession(session.id); + setState(() {}); + }, + ), + ], + ), + ], + ), + + // Question Box if present + if (session.question != null && session.question!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF22243C), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const Icon(Icons.help_outline_rounded, size: 14, color: Color(0xFFFFD54F)), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Вопрос: «${session.question}»', + style: const TextStyle( + fontSize: 12.5, + fontStyle: FontStyle.italic, + color: Color(0xFFFFF9C4), + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 10), + + // Words Chips Row + Wrap( + spacing: 6, + runSpacing: 6, + children: session.words.map((w) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF131524) : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.3), + ), ), - const SizedBox(width: 5), - Text( - item.formattedDate, + child: Text( + '#${w.formattedSessionNumber} ${w.word.toUpperCase()}', style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 12, - fontWeight: FontWeight.w600, color: Color(0xFFFFD54F), ), ), - ], - ), + ); + }).toList(), + ), + + // AI Interpretation Snippet if generated + if (session.aiInterpretation != null && session.aiInterpretation!.isNotEmpty) ...[ + const SizedBox(height: 10), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + width: double.infinity, + padding: const EdgeInsets.all(10), decoration: BoxDecoration( - color: const Color(0xFF2A2D4A), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - item.readingType, - style: const TextStyle( - fontSize: 11, - color: Color(0xFFFFF9C4), - fontWeight: FontWeight.w600, + color: const Color(0xFF261D3A), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFBA68C8).withValues(alpha: 0.4), ), ), - ), - ], - ), - - // Question snippet if present - if (item.question != null && item.question!.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF22243C), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - const Icon(Icons.help_outline_rounded, size: 14, color: Color(0xFFFFD54F)), - const SizedBox(width: 6), - Expanded( - child: Text( - 'Вопрос: «${item.question}»', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Row( + children: [ + Icon(Icons.psychology_rounded, size: 14, color: Color(0xFFE1BEE7)), + SizedBox(width: 6), + Text( + 'AI Трактовка (G4F)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFFE1BEE7), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + session.aiInterpretation!, + maxLines: 3, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12, - fontStyle: FontStyle.italic, - color: Color(0xFFFFF9C4), + height: 1.4, + color: Color(0xFFF3E5F5), ), ), - ), - ], - ), - ), - ], - - if (item.positionTitle != null) ...[ - const SizedBox(height: 6), - Text( - item.positionTitle!, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), - ), - ], - - const SizedBox(height: 10), - - // Word row with number badge - Row( - children: [ - GradientStarBadge( - text: '#${item.formattedSessionNumber}', - size: 14, - ), - const SizedBox(width: 10), - Expanded( - child: Text( - item.word.toUpperCase(), - style: const TextStyle( - fontWeight: FontWeight.w900, - fontSize: 18, - letterSpacing: 0.8, - ), + ], ), ), - IconButton( - icon: const Icon(Icons.copy_rounded, size: 18), - tooltip: 'Скопировать', - onPressed: () => _copyEntry(item), - ), - IconButton( - icon: const Icon(Icons.delete_outline, size: 18), - tooltip: 'Удалить', - onPressed: () async { - await _repo.deleteHistoryEntry(item.id); - setState(() {}); - }, - ), ], - ), - - const SizedBox(height: 8), - - // Base Meaning snippet - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: isDark - ? const Color(0xFF131524) - : theme.colorScheme.surfaceContainerLow, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - item.symbolicMeaning, - style: TextStyle( - fontSize: 13, - height: 1.4, - color: isDark ? const Color(0xFFD4D6E6) : Colors.black87, - ), - ), - ), - - // AI Interpretation snippet if present - if (item.aiInterpretation != null && item.aiInterpretation!.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF261D3A), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFFBA68C8).withValues(alpha: 0.4), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(Icons.psychology_rounded, size: 14, color: Color(0xFFE1BEE7)), - SizedBox(width: 6), - Text( - 'AI Трактовка (G4F)', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - color: Color(0xFFE1BEE7), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - item.aiInterpretation!, - style: const TextStyle( - fontSize: 12.5, - height: 1.45, - color: Color(0xFFF3E5F5), - ), - ), - ], - ), - ), ], - ], + ), ), ), ); diff --git a/lib/screens/home_search_screen.dart b/lib/screens/home_search_screen.dart index eb2c3f0..0dbbf49 100644 --- a/lib/screens/home_search_screen.dart +++ b/lib/screens/home_search_screen.dart @@ -6,7 +6,7 @@ import '../services/words_repository.dart'; import '../widgets/cleromancy_dialog.dart'; import '../widgets/d10_dice_roller_widget.dart'; import '../widgets/gradient_star_badge.dart'; -import '../widgets/word_card_widget.dart'; +import 'session_result_screen.dart'; class HomeSearchScreen extends StatefulWidget { const HomeSearchScreen({super.key}); @@ -18,44 +18,54 @@ class HomeSearchScreen extends StatefulWidget { class _HomeSearchScreenState extends State { final _repo = WordsRepository(); final _questionController = TextEditingController(); + final _countController = TextEditingController(text: '1'); final _diceRollerKey = GlobalKey(); - WordItem? _currentWord; - String? _activeHistoryId; + int _wordsCount = 1; // 1 to 9 words in the session + int _activeWordIndex = 0; // 0.._wordsCount-1 + List _wordsDigits = ['']; // 3-digit strings for each word bool _showKeypad = true; - String _currentDigits = ''; // Tracks 0..3 digits input: e.g. "", "7", "74", "742" + + @override + void initState() { + super.initState(); + _countController.addListener(_onCountChanged); + } @override void dispose() { + _countController.removeListener(_onCountChanged); + _countController.dispose(); _questionController.dispose(); super.dispose(); } - Future _setWord(WordItem word, {bool recordHistory = true}) async { - final question = _questionController.text.trim(); - String? newHistId; - - if (recordHistory) { - newHistId = DateTime.now().millisecondsSinceEpoch.toString(); - final entry = HistoryEntry( - id: newHistId, - timestamp: DateTime.now(), - word: word.word, - sessionNumber: word.sessionNumber, - partOfSpeech: word.partOfSpeech, - symbolicMeaning: word.description, - readingType: 'Клеромантия (3× d10)', - question: question.isNotEmpty ? question : null, - ); - await _repo.addHistoryEntry(entry); + void _onCountChanged() { + final text = _countController.text.trim(); + final count = int.tryParse(text); + if (count != null && count >= 1 && count <= 9) { + if (count != _wordsCount) { + setState(() { + _wordsCount = count; + _adjustDigitsList(); + }); + } } + } - setState(() { - _currentWord = word; - _activeHistoryId = newHistId ?? _activeHistoryId; - _currentDigits = word.formattedSessionNumber; - }); - HapticFeedback.selectionClick(); + void _adjustDigitsList() { + final current = List.from(_wordsDigits); + if (current.length < _wordsCount) { + while (current.length < _wordsCount) { + current.add(''); + } + } else if (current.length > _wordsCount) { + current.removeRange(_wordsCount, current.length); + } + _wordsDigits = current; + if (_activeWordIndex >= _wordsCount) { + _activeWordIndex = _wordsCount - 1; + } } void _onKeypadTap(String val) { @@ -63,74 +73,118 @@ class _HomeSearchScreenState extends State { if (val == 'C') { setState(() { - _currentDigits = ''; + _wordsDigits[_activeWordIndex] = ''; }); return; } if (val == '⌫') { - if (_currentDigits.isNotEmpty) { + final current = _wordsDigits[_activeWordIndex]; + if (current.isNotEmpty) { setState(() { - _currentDigits = _currentDigits.substring(0, _currentDigits.length - 1); + _wordsDigits[_activeWordIndex] = current.substring(0, current.length - 1); + }); + } else if (_activeWordIndex > 0) { + setState(() { + _activeWordIndex--; }); } return; } - // Append digit up to 3 digits (1st d10, 2nd d10, 3rd d10) + // Append digit to current active word (up to 3 digits) + final current = _wordsDigits[_activeWordIndex]; String newDigits; - if (_currentDigits.length >= 3) { + if (current.length >= 3) { newDigits = val; } else { - newDigits = _currentDigits + val; + newDigits = current + val; } setState(() { - _currentDigits = newDigits; + _wordsDigits[_activeWordIndex] = newDigits; }); - // Only resolve word when all 3 digits are provided! - if (newDigits.length == 3) { - final num = int.tryParse(newDigits); - if (num != null && num <= 999) { - final word = _repo.getWordBySessionNumber(num); - if (word != null) { - _setWord(word, recordHistory: true); + // If 3 digits filled and more words remain, auto-advance to next word! + if (newDigits.length == 3 && _activeWordIndex < _wordsCount - 1) { + Future.delayed(const Duration(milliseconds: 150), () { + if (mounted) { + setState(() { + _activeWordIndex++; + }); } - } + }); } } void _onVirtualRollCompleted(int finalNumber) { - final word = _repo.getWordBySessionNumber(finalNumber); - if (word != null) { - _setWord(word, recordHistory: true); + final formatted = finalNumber.toString().padLeft(3, '0'); + setState(() { + _wordsDigits[_activeWordIndex] = formatted; + }); + + // Advance to next word if not last + if (_activeWordIndex < _wordsCount - 1) { + Future.delayed(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _activeWordIndex++; + }); + } + }); } } - void _resetToWelcome() { + bool get _isSessionReady { + if (_wordsDigits.length != _wordsCount) return false; + return _wordsDigits.every((d) => d.length == 3); + } + + void _openSessionResults() { + final sessionWords = []; + for (int i = 0; i < _wordsCount; i++) { + final digits = _wordsDigits[i]; + final num = int.tryParse(digits) ?? 0; + final wordItem = _repo.getWordBySessionNumber(num) ?? + WordItem( + id: num, + word: 'Символ #$digits', + partOfSpeech: 'знак', + frequency: 0, + category: 'Символ', + description: 'Сакральный знак под номером $digits', + ); + + sessionWords.add(SessionWord.fromWordItem(wordItem)); + } + + final question = _questionController.text.trim(); + final session = OracleSession( + id: DateTime.now().millisecondsSinceEpoch.toString(), + timestamp: DateTime.now(), + question: question.isNotEmpty ? question : null, + wordsCount: _wordsCount, + words: sessionWords, + ); + + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => SessionResultScreen(session: session), + ), + ); + } + + void _resetSession() { setState(() { - _currentWord = null; - _currentDigits = ''; - _activeHistoryId = null; + _activeWordIndex = 0; + _wordsDigits = List.filled(_wordsCount, ''); }); } - void _goToNext() { - if (_currentWord == null) return; - final next = _repo.getNextWord(_currentWord!.sessionNumber); - _setWord(next, recordHistory: false); - } - - void _goToPrevious() { - if (_currentWord == null) return; - final prev = _repo.getPreviousWord(_currentWord!.sessionNumber); - _setWord(prev, recordHistory: false); - } - - void _reshuffleSession() { + void _reshuffleMatrix() { _repo.reshuffleSessionNumbers(); - _resetToWelcome(); + _resetSession(); HapticFeedback.heavyImpact(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -157,6 +211,8 @@ class _HomeSearchScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; + final currentActiveDigits = + _activeWordIndex < _wordsDigits.length ? _wordsDigits[_activeWordIndex] : ''; return Scaffold( appBar: AppBar( @@ -180,16 +236,15 @@ class _HomeSearchScreenState extends State { icon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F)), onPressed: () => CleromancyGuideDialog.show(context), ), - if (_currentWord != null) - IconButton( - tooltip: 'Новый вопрос / Сброс', - icon: const Icon(Icons.refresh_rounded), - onPressed: _resetToWelcome, - ), + IconButton( + tooltip: 'Очистить броски', + icon: const Icon(Icons.refresh_rounded), + onPressed: _resetSession, + ), IconButton( tooltip: 'Перетасовать матрицу (#000..#999)', icon: const Icon(Icons.shuffle_rounded), - onPressed: _reshuffleSession, + onPressed: _reshuffleMatrix, ), IconButton( tooltip: _showKeypad ? 'Скрыть панель' : 'Показать панель', @@ -206,49 +261,161 @@ class _HomeSearchScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Question Input Field - TextField( - controller: _questionController, - decoration: InputDecoration( - labelText: 'Ваш вопрос или намерение (необязательно)', - hintText: 'Например: В чем корень текущей ситуации?', - prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 20), - suffixIcon: _questionController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () => _questionController.clear(), - ) - : null, - filled: true, - fillColor: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: const Color(0xFFFFD54F).withValues(alpha: 0.25), + // Row: Question & Words count selector (1..9) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Question Input + Expanded( + child: TextField( + controller: _questionController, + decoration: InputDecoration( + labelText: 'Ваш вопрос или контекст', + hintText: 'В чем корень ситуации?', + prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 20), + suffixIcon: _questionController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () => _questionController.clear(), + ) + : null, + filled: true, + fillColor: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: const Color(0xFFFFD54F).withValues(alpha: 0.25), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: const Color(0xFFFFD54F).withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: const BorderSide( + color: Color(0xFFFFD54F), + width: 1.5, + ), + ), + ), ), ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: const Color(0xFFFFD54F).withValues(alpha: 0.2), + + const SizedBox(width: 10), + + // Words Count Input Box (1..9) + Container( + width: 78, + height: 56, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.4), + ), + ), + child: TextField( + controller: _countController, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + maxLength: 1, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFFFFF9C4), + ), + decoration: const InputDecoration( + counterText: '', + labelText: 'Слов', + labelStyle: TextStyle(fontSize: 11, color: Color(0xFFFFD54F)), + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8), + ), ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFFFD54F), - width: 1.5, - ), - ), - ), + ], ), const SizedBox(height: 12), - // D10 Dice Roller Slots Widget (Clean & without prefilled junk) + // Multi-Word Slots Progress Indicator (if > 1 word) + if (_wordsCount > 1) ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF151829) : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.2), + ), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(_wordsCount, (i) { + final isSelected = i == _activeWordIndex; + final digits = i < _wordsDigits.length ? _wordsDigits[i] : ''; + final isFilled = digits.length == 3; + + return Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: () { + setState(() => _activeWordIndex = i); + HapticFeedback.selectionClick(); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF2A2D4A) + : (isFilled ? const Color(0xFF1E2138) : Colors.transparent), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? const Color(0xFFFFD54F) + : (isFilled ? const Color(0xFFFFD54F).withValues(alpha: 0.4) : Colors.white12), + width: isSelected ? 1.5 : 1.0, + ), + ), + child: Row( + children: [ + Text( + 'Слово ${i + 1}: ', + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? const Color(0xFFFFF9C4) : theme.colorScheme.outline, + ), + ), + Text( + isFilled ? '#$digits' : (digits.isEmpty ? '• • •' : '$digits..'), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w900, + color: isFilled ? const Color(0xFFFFD54F) : Colors.white54, + ), + ), + ], + ), + ), + ), + ); + }), + ), + ), + ), + const SizedBox(height: 10), + ], + + // D10 Dice Roller Slots Widget (Purely for rolling/entering faces before opening word) D10DiceRollerWidget( key: _diceRollerKey, - digits: _currentDigits, + digits: currentActiveDigits, isRolling: false, onRollPressed: () {}, onRollCompleted: _onVirtualRollCompleted, @@ -256,105 +423,42 @@ class _HomeSearchScreenState extends State { const SizedBox(height: 14), - // Welcome Idle State OR Active Word Card - if (_currentWord == null) ...[ - // Welcome Initiation Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - const Color(0xFF1F223B), - const Color(0xFF131526), - ], - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: const Color(0xFFFFD54F).withValues(alpha: 0.35), - width: 1.2, + // Button to open the Result Screen when ready + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _isSessionReady ? _openSessionResults : null, + icon: const Icon(Icons.visibility_rounded, size: 20), + label: Text( + _isSessionReady + ? '🔮 ОТКРЫТЬ ЗНАКИ И ТРАКТОВКУ ($_wordsCount ${_wordsCount == 1 ? "знак" : (_wordsCount < 5 ? "знака" : "знаков")})' + : 'Заполните броски (Слово ${_activeWordIndex + 1} из $_wordsCount)', + style: const TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 0.6, + fontSize: 13.5, ), ), - child: Column( - children: [ - const GradientStar(size: 38), - const SizedBox(height: 12), - const Text( - 'СВЯЩЕННЫЙ ЖРЕБИЙ КЛЕРОМАНТИИ', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w900, - letterSpacing: 0.8, - color: Color(0xFFFFF9C4), - ), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2D4A), + foregroundColor: const Color(0xFFFFF9C4), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide( + color: _isSessionReady + ? const Color(0xFFFFD54F) + : const Color(0xFFFFD54F).withValues(alpha: 0.3), + width: 1.5, ), - const SizedBox(height: 8), - Text( - 'Сформулируйте вопрос, затем совершите 3 броска кубика d10 кнопкой выше или наберите 3 выпавших грани на панели.', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 13, - height: 1.4, - color: theme.colorScheme.onSurface.withValues(alpha: 0.85), - ), - ), - const SizedBox(height: 14), - OutlinedButton.icon( - onPressed: () => CleromancyGuideDialog.show(context), - icon: const Icon(Icons.menu_book_rounded, size: 16, color: Color(0xFFFFD54F)), - label: const Text( - 'Инструкция по броскам d10', - style: TextStyle( - color: Color(0xFFFFD54F), - fontWeight: FontWeight.bold, - fontSize: 12.5, - ), - ), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Color(0xFFFFD54F), width: 1), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - ), - ], + ), ), ), - ] else ...[ - // Main Sacred Word Card Display - AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - transitionBuilder: (child, animation) { - return FadeTransition( - opacity: animation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.04), - end: Offset.zero, - ).animate(animation), - child: child, - ), - ); - }, - child: WordCardWidget( - key: ValueKey('${_currentWord!.id}_${_currentWord!.sessionNumber}'), - word: _currentWord!, - question: _questionController.text.trim(), - historyEntryId: _activeHistoryId, - onNext: _goToNext, - onPrevious: _goToPrevious, - onRandom: () { - _diceRollerKey.currentState?.triggerRollAnimation(); - }, - ), - ), - ], + ), const SizedBox(height: 14), - // Interactive Numeric Keypad (Directly maps to 3 d10 dice slots) + // Interactive Numeric Keypad (Directly inputs to active d10 word slots) if (_showKeypad) ...[ Container( padding: const EdgeInsets.all(12), @@ -372,13 +476,13 @@ class _HomeSearchScreenState extends State { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Row( + Row( children: [ - Icon(Icons.casino_outlined, size: 15, color: Color(0xFFFFD54F)), - SizedBox(width: 6), + const Icon(Icons.casino_outlined, size: 15, color: Color(0xFFFFD54F)), + const SizedBox(width: 6), Text( - 'Ввод граней d10:', - style: TextStyle( + 'Ввод граней (Слово ${_activeWordIndex + 1} из $_wordsCount):', + style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Color(0xFFFFD54F), @@ -387,15 +491,13 @@ class _HomeSearchScreenState extends State { ], ), Text( - _currentDigits.isEmpty - ? 'Ожидание 3 цифр' - : (_currentDigits.length == 3 - ? 'Код: #$_currentDigits' - : 'Введено: ${_currentDigits.length} из 3'), + currentActiveDigits.length == 3 + ? 'Код: #$currentActiveDigits' + : 'Введено: ${currentActiveDigits.length} из 3', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, - color: _currentDigits.length == 3 + color: currentActiveDigits.length == 3 ? const Color(0xFFFFD54F) : theme.colorScheme.outline, ), diff --git a/lib/screens/oracle_spread_screen.dart b/lib/screens/oracle_spread_screen.dart deleted file mode 100644 index f9bb264..0000000 --- a/lib/screens/oracle_spread_screen.dart +++ /dev/null @@ -1,371 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../models/history_entry_model.dart'; -import '../models/word_model.dart'; -import '../services/words_repository.dart'; -import '../widgets/gradient_star_badge.dart'; -import '../widgets/word_card_widget.dart'; - -class OracleSpreadScreen extends StatefulWidget { - const OracleSpreadScreen({super.key}); - - @override - State createState() => _OracleSpreadScreenState(); -} - -class _OracleSpreadScreenState extends State { - final _repo = WordsRepository(); - final _questionController = TextEditingController(); - int _selectedMode = 0; // 0 = Single Card (Жребий момента), 1 = Triad (3 карты) - - WordItem? _singleDraw; - String? _singleHistoryId; - List _triadDraw = []; - List _triadHistoryIds = []; - bool _isGenerating = false; - - @override - void initState() { - super.initState(); - _singleDraw = _repo.getRandomWord(); - } - - @override - void dispose() { - _questionController.dispose(); - super.dispose(); - } - - Future _performSingleDraw({bool saveHistory = true}) async { - setState(() => _isGenerating = true); - HapticFeedback.mediumImpact(); - await Future.delayed(const Duration(milliseconds: 200)); - - final word = _repo.getRandomWord(); - String? histId; - - if (saveHistory) { - histId = DateTime.now().millisecondsSinceEpoch.toString(); - final question = _questionController.text.trim(); - final entry = HistoryEntry( - id: histId, - timestamp: DateTime.now(), - word: word.word, - sessionNumber: word.sessionNumber, - partOfSpeech: word.partOfSpeech, - symbolicMeaning: word.description, - readingType: 'Жребий момента (1 символ)', - question: question.isNotEmpty ? question : null, - ); - await _repo.addHistoryEntry(entry); - } - - setState(() { - _singleDraw = word; - _singleHistoryId = histId ?? _singleHistoryId; - _isGenerating = false; - }); - } - - Future _performTriadDraw() async { - setState(() => _isGenerating = true); - HapticFeedback.heavyImpact(); - await Future.delayed(const Duration(milliseconds: 250)); - - final words = []; - final titles = [ - 'Истоки / Скрытая причина', - 'Настоящее / Вызов', - 'Совет / Исход', - ]; - - while (words.length < 3) { - final w = _repo.getRandomWord(); - if (!words.any((item) => item.id == w.id)) { - words.add(w); - } - } - - final question = _questionController.text.trim(); - final ids = []; - - for (int i = 0; i < words.length; i++) { - final histId = '${DateTime.now().millisecondsSinceEpoch}_$i'; - ids.add(histId); - final entry = HistoryEntry( - id: histId, - timestamp: DateTime.now(), - word: words[i].word, - sessionNumber: words[i].sessionNumber, - partOfSpeech: words[i].partOfSpeech, - symbolicMeaning: words[i].description, - readingType: 'Триада судьбы', - positionTitle: titles[i], - question: question.isNotEmpty ? question : null, - ); - await _repo.addHistoryEntry(entry); - } - - setState(() { - _triadDraw = words; - _triadHistoryIds = ids; - _isGenerating = false; - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isDark = theme.brightness == Brightness.dark; - - return Scaffold( - appBar: AppBar( - title: const Row( - children: [ - GradientStar(size: 24), - SizedBox(width: 10), - Text( - 'Расклады Оракула', - style: TextStyle(fontWeight: FontWeight.w900), - ), - ], - ), - ), - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Question Input - TextField( - controller: _questionController, - decoration: InputDecoration( - labelText: 'Ваш вопрос к раскладу (необязательно)', - hintText: 'Например: Чему учит эта ситуация?', - prefixIcon: const Icon(Icons.help_outline_rounded, color: Color(0xFFFFD54F), size: 20), - suffixIcon: _questionController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear, size: 18), - onPressed: () => _questionController.clear(), - ) - : null, - filled: true, - fillColor: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: const Color(0xFFFFD54F).withValues(alpha: 0.25), - ), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: BorderSide( - color: const Color(0xFFFFD54F).withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: Color(0xFFFFD54F), - width: 1.5, - ), - ), - ), - ), - - const SizedBox(height: 12), - - // Mode Selector (1 символ / Триада 3 символа) - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF1B1D30) : theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: const Color(0xFFFFD54F).withValues(alpha: 0.3), - ), - ), - child: Row( - children: [ - Expanded( - child: InkWell( - onTap: () { - setState(() => _selectedMode = 0); - if (_singleDraw == null) _performSingleDraw(saveHistory: false); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: _selectedMode == 0 - ? const Color(0xFF2A2D4A) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: _selectedMode == 0 - ? Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)) - : null, - ), - alignment: Alignment.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const GradientStar(size: 14), - const SizedBox(width: 6), - Text( - 'Жребий момента (1)', - style: TextStyle( - fontWeight: FontWeight.bold, - color: _selectedMode == 0 - ? const Color(0xFFFFF9C4) - : theme.colorScheme.outline, - ), - ), - ], - ), - ), - ), - ), - Expanded( - child: InkWell( - onTap: () { - setState(() => _selectedMode = 1); - if (_triadDraw.isEmpty) _performTriadDraw(); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: _selectedMode == 1 - ? const Color(0xFF2A2D4A) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: _selectedMode == 1 - ? Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.5)) - : null, - ), - alignment: Alignment.center, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const GradientStar(size: 14), - const SizedBox(width: 6), - Text( - 'Триада судьбы (3)', - style: TextStyle( - fontWeight: FontWeight.bold, - color: _selectedMode == 1 - ? const Color(0xFFFFF9C4) - : theme.colorScheme.outline, - ), - ), - ], - ), - ), - ), - ), - ], - ), - ), - - const SizedBox(height: 16), - - // Action draw button - FilledButton.icon( - onPressed: _isGenerating - ? null - : () { - if (_selectedMode == 0) { - _performSingleDraw(saveHistory: true); - } else { - _performTriadDraw(); - } - }, - icon: const GradientStar(size: 20), - label: Text( - _selectedMode == 0 ? 'БРОСИТЬ ЖРЕБИЙ МОМЕНТА' : 'РАСКРЫТЬ ТРИАДУ СУДЬБЫ', - style: const TextStyle( - fontWeight: FontWeight.w900, - letterSpacing: 0.8, - ), - ), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF2A2D4A), - foregroundColor: const Color(0xFFFFF9C4), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - side: const BorderSide( - color: Color(0xFFFFD54F), - width: 1.2, - ), - ), - ), - ), - - const SizedBox(height: 20), - - if (_isGenerating) - const Center( - child: Padding( - padding: EdgeInsets.all(40.0), - child: Column( - children: [ - CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Color(0xFFFFD54F)), - ), - SizedBox(height: 16), - Text( - 'Синхронизация с полем оракула...', - style: TextStyle(fontStyle: FontStyle.italic), - ), - ], - ), - ), - ) - else if (_selectedMode == 0 && _singleDraw != null) - WordCardWidget( - key: ValueKey('${_singleDraw!.id}_${_singleDraw!.sessionNumber}'), - word: _singleDraw!, - positionTitle: 'Символ момента', - question: _questionController.text.trim(), - historyEntryId: _singleHistoryId, - onRandom: () => _performSingleDraw(saveHistory: true), - ) - else if (_selectedMode == 1 && _triadDraw.isNotEmpty) - Column( - children: [ - WordCardWidget( - key: ValueKey('triad_0_${_triadDraw[0].id}_${_triadDraw[0].sessionNumber}'), - word: _triadDraw[0], - positionTitle: '1. Истоки / Скрытая причина', - question: _questionController.text.trim(), - historyEntryId: _triadHistoryIds.isNotEmpty ? _triadHistoryIds[0] : null, - ), - const SizedBox(height: 14), - WordCardWidget( - key: ValueKey('triad_1_${_triadDraw[1].id}_${_triadDraw[1].sessionNumber}'), - word: _triadDraw[1], - positionTitle: '2. Настоящее / Вызов', - question: _questionController.text.trim(), - historyEntryId: _triadHistoryIds.length > 1 ? _triadHistoryIds[1] : null, - ), - const SizedBox(height: 14), - WordCardWidget( - key: ValueKey('triad_2_${_triadDraw[2].id}_${_triadDraw[2].sessionNumber}'), - word: _triadDraw[2], - positionTitle: '3. Совет / Исход', - question: _questionController.text.trim(), - historyEntryId: _triadHistoryIds.length > 2 ? _triadHistoryIds[2] : null, - ), - ], - ), - - const SizedBox(height: 24), - ], - ), - ), - ), - ); - } -} diff --git a/lib/screens/session_result_screen.dart b/lib/screens/session_result_screen.dart new file mode 100644 index 0000000..bcd381c --- /dev/null +++ b/lib/screens/session_result_screen.dart @@ -0,0 +1,359 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../models/history_entry_model.dart'; +import '../services/ai_oracle_service.dart'; +import '../services/words_repository.dart'; +import '../widgets/gradient_star_badge.dart'; + +class SessionResultScreen extends StatefulWidget { + final OracleSession session; + + const SessionResultScreen({ + super.key, + required this.session, + }); + + @override + State createState() => _SessionResultScreenState(); +} + +class _SessionResultScreenState extends State { + final _aiService = AiOracleService(); + final _repo = WordsRepository(); + + late OracleSession _session; + bool _isLoadingAi = false; + + @override + void initState() { + super.initState(); + _session = widget.session; + // Persist the session atomically + _repo.saveSession(_session); + } + + Future _fetchAiInterpretation() async { + setState(() => _isLoadingAi = true); + HapticFeedback.mediumImpact(); + + try { + final result = await _aiService.interpretSession( + words: _session.words, + question: _session.question ?? '', + ); + + if (mounted) { + final updated = _session.copyWith(aiInterpretation: result); + setState(() { + _session = updated; + _isLoadingAi = false; + }); + await _repo.saveSession(updated); + } + } catch (e) { + if (mounted) { + setState(() => _isLoadingAi = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка генерации: $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + + void _copySessionToClipboard() { + final buf = StringBuffer(); + buf.writeln('✨ Оракул 999 — Сессия Клеромантии ✨'); + buf.writeln('Дата: ${_session.formattedDate}'); + if (_session.question != null && _session.question!.isNotEmpty) { + buf.writeln('Вопрос: «${_session.question}»'); + } + buf.writeln('Знаков в цепочке: ${_session.words.length}\n'); + + for (int i = 0; i < _session.words.length; i++) { + final w = _session.words[i]; + final pos = w.positionTitle != null ? ' (${w.positionTitle})' : ''; + buf.writeln('🔮 Знак #${w.formattedSessionNumber}: ${w.word.toUpperCase()}$pos'); + buf.writeln(' Аспект: ${w.partOfSpeech} • ${w.category}'); + buf.writeln(' Смысл: ${w.symbolicMeaning}\n'); + } + + if (_session.aiInterpretation != null) { + buf.writeln('🤖 AI Трактовка Оракула (G4F):'); + buf.writeln(_session.aiInterpretation); + } + + Clipboard.setData(ClipboardData(text: buf.toString())); + HapticFeedback.lightImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + GradientStar(size: 18), + SizedBox(width: 10), + Expanded(child: Text('Вся сессия скопирована в буфер обмена')), + ], + ), + 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; + + return Scaffold( + appBar: AppBar( + title: Row( + children: [ + const GradientStar(size: 22), + const SizedBox(width: 8), + Text( + 'Знаки Сессии (${_session.words.length})', + style: const TextStyle(fontWeight: FontWeight.w900), + ), + ], + ), + actions: [ + IconButton( + tooltip: 'Скопировать сессию', + icon: const Icon(Icons.copy_rounded), + onPressed: _copySessionToClipboard, + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Question Banner + if (_session.question != null && _session.question!.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF1E2138), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon(Icons.help_outline_rounded, size: 18, color: Color(0xFFFFD54F)), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Вопрос: «${_session.question}»', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFFFFF9C4), + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + ], + + // AI Interpretation Box if generated or button + if (_session.aiInterpretation != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2A1E4A), Color(0xFF191833)], + ), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: const Color(0xFFBA68C8).withValues(alpha: 0.6), + width: 1.2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Row( + children: [ + Icon(Icons.psychology_rounded, size: 20, color: Color(0xFFE1BEE7)), + SizedBox(width: 8), + Text( + 'AI ТРАКТОВКА ИСТОРИИ (G4F)', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + color: Color(0xFFE1BEE7), + ), + ), + ], + ), + IconButton( + icon: const Icon(Icons.refresh_rounded, size: 18, color: Color(0xFFE1BEE7)), + tooltip: 'Перегенерировать', + onPressed: _fetchAiInterpretation, + ), + ], + ), + const SizedBox(height: 10), + Text( + _session.aiInterpretation!, + style: const TextStyle( + fontSize: 13.5, + height: 1.5, + color: Color(0xFFF3E5F5), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ] else ...[ + FilledButton.icon( + onPressed: _isLoadingAi ? null : _fetchAiInterpretation, + icon: _isLoadingAi + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFFFFD54F)), + ), + ) + : const Icon(Icons.auto_awesome, size: 18, color: Color(0xFFE1BEE7)), + label: Text( + _isLoadingAi ? 'Синтез толкования...' : 'Синтезировать связную AI-трактовку (G4F)', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Color(0xFFFFF9C4), + ), + ), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2145), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: const BorderSide( + color: Color(0xFFBA68C8), + width: 1.2, + ), + ), + ), + ), + const SizedBox(height: 16), + ], + + // Word Cards List + ..._session.words.asMap().entries.map((entry) { + final idx = entry.key; + final w = entry.value; + + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Card( + elevation: 6, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: BorderSide( + color: const Color(0xFFFFD54F).withValues(alpha: 0.25), + ), + ), + color: isDark ? const Color(0xFF1B1D30) : theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const GradientStar(size: 15), + const SizedBox(width: 6), + Text( + '${idx + 1}. ЗНАК #${w.formattedSessionNumber}', + style: const TextStyle( + fontWeight: FontWeight.w900, + fontSize: 13, + color: Color(0xFFFFD54F), + letterSpacing: 0.5, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF262A48) : Colors.black12, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + w.partOfSpeech, + style: TextStyle( + fontSize: 11, + color: isDark ? const Color(0xFFB0B7DE) : Colors.black87, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Center( + child: Text( + w.word.toUpperCase(), + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w900, + letterSpacing: 1.5, + color: Color(0xFFFFF9C4), + ), + ), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF131525) : theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(14), + ), + child: Text( + w.symbolicMeaning, + style: TextStyle( + fontSize: 13, + height: 1.45, + color: isDark ? const Color(0xFFD4D6E6) : Colors.black87, + ), + ), + ), + ], + ), + ), + ), + ); + }), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/ai_oracle_service.dart b/lib/services/ai_oracle_service.dart index 48ab3cc..1f0e482 100644 --- a/lib/services/ai_oracle_service.dart +++ b/lib/services/ai_oracle_service.dart @@ -1,6 +1,7 @@ -import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'package:http/http.dart' as http; +import '../models/history_entry_model.dart'; import '../models/word_model.dart'; class AiOracleService { @@ -8,181 +9,164 @@ class AiOracleService { factory AiOracleService() => _instance; AiOracleService._internal(); - // Configurable local or remote G4F server URL - String g4fServerUrl = 'http://localhost:1337/v1/chat/completions'; + /// Interprets a full multi-word session narrative (1 to 9 words) + Future interpretSession({ + required List words, + required String question, + }) async { + if (words.isEmpty) return 'Нет знаков для трактовки.'; - /// Generates an esoteric, archetypal interpretation weaving the user's question, the drawn word, its session number, and its symbolic meaning. + final wordsNarrative = StringBuffer(); + for (int i = 0; i < words.length; i++) { + final w = words[i]; + final pos = w.positionTitle != null ? ' (${w.positionTitle})' : ''; + wordsNarrative.writeln('${i + 1}. Знак #${w.formattedSessionNumber} «${w.word.toUpperCase()}»$pos — ${w.partOfSpeech}.'); + wordsNarrative.writeln(' Смысл: ${w.symbolicMeaning}'); + } + + final prompt = _buildMultiWordPrompt( + question: question, + wordsNarrative: wordsNarrative.toString(), + wordsCount: words.length, + ); + + // 1. Try Pollinations OpenAI-compatible gateway (GPT-4o-mini / Llama 3.3 free tier) + try { + final res = await _callPollinationsOpenAi(prompt); + if (res != null && res.trim().isNotEmpty) return res.trim(); + } catch (_) {} + + // 2. Try Direct Pollinations Raw Gateway + try { + final res = await _callPollinationsDirect(prompt); + if (res != null && res.trim().isNotEmpty) return res.trim(); + } catch (_) {} + + // 3. Try Local G4F Server if running + try { + final res = await _callLocalG4f(prompt); + if (res != null && res.trim().isNotEmpty) return res.trim(); + } catch (_) {} + + // 4. Offline Multi-Word Synthesizer + return _generateOfflineSessionInterpretation(words, question); + } + + /// Single word legacy interpretation helper Future interpret({ required WordItem word, required String question, - String? spreadType, String? positionTitle, }) async { - final cleanQuestion = question.trim().isEmpty ? 'Общий совет и скрытые тенденции момента' : question.trim(); - - final systemPrompt = - 'Ты — мудрый и глубокий Оракул 999 Сакральных Символов. ' - 'Твоя задача — связать выпавший сакральный знак, его номер и архетипический смысл с конкретным вопросом вопрошающего. ' - 'Дай глубокий, связный, образный и практичный ответ на русском языке. ' - 'Структурируй толкование: ' - '1) Суть послания через призму вопроса. ' - '2) Скрытый вызов / предупреждение. ' - '3) Практический духовный совет / ключ к действию. ' - 'Пиши емко, выразительно, без лишней «воды», на 2-4 абзаца.'; - - final userPrompt = - 'Вопрос вопрошающего: «$cleanQuestion»\n' - 'Выпавший знак: #${word.formattedSessionNumber} — «${word.word.toUpperCase()}» (${word.partOfSpeech})\n' - '${positionTitle != null ? "Позиция в раскладе: $positionTitle\n" : ""}' - 'Базовое символическое значение знака: ${word.description}\n\n' - 'Сотки глубокую трактовку этого знака именно для заданного вопроса.'; - - // Try Provider 1: Local / Configured G4F Server - try { - final g4fResponse = await _callOpenAiCompatible( - url: g4fServerUrl, - systemPrompt: systemPrompt, - userPrompt: userPrompt, - timeout: const Duration(seconds: 4), - ); - if (g4fResponse != null && g4fResponse.isNotEmpty) { - return g4fResponse; - } - } catch (_) {} - - // Try Provider 2: Pollinations AI OpenAI-compatible Gateway (Free GPT-4o-mini tier) - try { - final pollResponse = await _callOpenAiCompatible( - url: 'https://text.pollinations.ai/openai/chat/completions', - systemPrompt: systemPrompt, - userPrompt: userPrompt, - timeout: const Duration(seconds: 10), - ); - if (pollResponse != null && pollResponse.isNotEmpty) { - return pollResponse; - } - } catch (_) {} - - // Try Provider 3: Pollinations AI Raw Text Gateway (GET / POST) - try { - final rawResponse = await _callPollinationsRaw( - systemPrompt: systemPrompt, - userPrompt: userPrompt, - timeout: const Duration(seconds: 8), - ); - if (rawResponse != null && rawResponse.isNotEmpty) { - return rawResponse; - } - } catch (_) {} - - // Provider 4 (Fallback): Offline Sacred Synthesis Engine - return _generateOfflineInterpretation( - word: word, - question: cleanQuestion, - positionTitle: positionTitle, - ); + final sessionWord = SessionWord.fromWordItem(word, positionTitle: positionTitle); + return interpretSession(words: [sessionWord], question: question); } - /// Helper for OpenAI / G4F compatible JSON completions - Future _callOpenAiCompatible({ - required String url, - required String systemPrompt, - required String userPrompt, - required Duration timeout, - }) async { - final response = await http + String _buildMultiWordPrompt({ + required String question, + required String wordsNarrative, + required int wordsCount, + }) { + final userQuestion = question.trim().isNotEmpty + ? 'Вопрос вопрошающего: «$question»' + : 'Вопрос не задан вслух (общий вектор судьбы и текущий момент).'; + + return '''Ты — Священный Оракул Клеромантии 999. Твоя задача — дать глубокое, психологически тонкое, символически связное толкование выпавших знаков жребия. + +$userQuestion + +Выпавшие знаки жребия ($wordsCount слов в единой цепочке): +$wordsNarrative + +Требования к ответу: +1. Свяжи все выпавшие знаки в единую осмысленную историю-путь, прямо отвечающую на вопрос. +2. Раскрой скрытый символический конфликт или вектор развития ситуации. +3. Дай конкретный совет и практический духовный ориентир. +4. Ответ напиши на русском языке, поэтично, ёмко и лаконично (3-5 предложений), без лишней воды.'''; + } + + Future _callPollinationsOpenAi(String prompt) async { + final url = Uri.parse('https://text.pollinations.ai/openai/chat/completions'); + final body = jsonEncode({ + 'model': 'openai', + 'messages': [ + {'role': 'system', 'content': 'Ты — глубокий символический оракул и толкователь священного жребия.'}, + {'role': 'user', 'content': prompt}, + ], + 'temperature': 0.7, + }); + + final resp = await http .post( - Uri.parse(url), + url, headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36', }, - body: jsonEncode({ - 'model': 'gpt-4o-mini', - 'messages': [ - {'role': 'system', 'content': systemPrompt}, - {'role': 'user', 'content': userPrompt}, - ], - 'temperature': 0.7, - 'max_tokens': 700, - }), + body: body, ) - .timeout(timeout); + .timeout(const Duration(seconds: 10)); - if (response.statusCode >= 200 && response.statusCode < 300) { - final data = jsonDecode(utf8.decode(response.bodyBytes)); - if (data is Map && data.containsKey('choices')) { - final choices = data['choices'] as List; - if (choices.isNotEmpty) { - final first = choices.first; - if (first is Map && first.containsKey('message')) { - final content = first['message']['content'] as String?; - if (content != null && content.trim().isNotEmpty) { - return content.trim(); - } - } - } - } + if (resp.statusCode == 200) { + final data = jsonDecode(utf8.decode(resp.bodyBytes)); + return data['choices']?[0]?['message']?['content'] as String?; } return null; } - /// Helper for Pollinations Raw text generation - Future _callPollinationsRaw({ - required String systemPrompt, - required String userPrompt, - required Duration timeout, - }) async { - final combined = '$systemPrompt\n\n$userPrompt'; - final uri = Uri.parse('https://text.pollinations.ai/').replace( - queryParameters: { - 'model': 'openai', - 'json': 'false', - 'seed': DateTime.now().millisecondsSinceEpoch.toString(), + Future _callPollinationsDirect(String prompt) async { + final encodedPrompt = Uri.encodeComponent(prompt); + final seed = Random().nextInt(1000000); + final url = Uri.parse('https://text.pollinations.ai/$encodedPrompt?seed=$seed&model=openai'); + + final resp = await http.get( + url, + headers: { + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)', }, - ); + ).timeout(const Duration(seconds: 10)); - final response = await http - .post( - uri, - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64)', - }, - body: utf8.encode(combined), - ) - .timeout(timeout); - - if (response.statusCode >= 200 && response.statusCode < 300) { - final text = utf8.decode(response.bodyBytes).trim(); - if (text.isNotEmpty && !text.startsWith('Error')) { + if (resp.statusCode == 200) { + final text = utf8.decode(resp.bodyBytes).trim(); + if (text.isNotEmpty && !text.startsWith(' _callLocalG4f(String prompt) async { + final url = Uri.parse('http://localhost:1337/v1/chat/completions'); + final body = jsonEncode({ + 'model': 'gpt-4o-mini', + 'messages': [ + {'role': 'user', 'content': prompt}, + ], + }); - return '🔮 Послание знака #${word.formattedSessionNumber} «${word.word.toUpperCase()}» на ваш запрос «$question»:\n\n' - '1. Смысловой узел: Символ указывает на $focus в текущей ситуации. ${word.description}\n\n' - '2. Внутренний вызов: Обратите внимание на то, как энергия слова «${word.word}» соотносится с вашими скрытыми ожиданиями. ' - 'Не пытайтесь форсировать события вопреки естественному ритму.\n\n' - '3. Ключ к действию: Примите качества этого знака как ориентир для принятия решения в ближайшие дни.'; + final resp = await http + .post( + url, + headers: {'Content-Type': 'application/json'}, + body: body, + ) + .timeout(const Duration(seconds: 5)); + + if (resp.statusCode == 200) { + final data = jsonDecode(utf8.decode(resp.bodyBytes)); + return data['choices']?[0]?['message']?['content'] as String?; + } + return null; + } + + String _generateOfflineSessionInterpretation(List words, String question) { + final wordsListStr = words.map((w) => '«${w.word.toUpperCase()}» (#${w.formattedSessionNumber})').join(' ➔ '); + final questionContext = question.trim().isNotEmpty + ? 'В контексте вашего вопроса «$question» ' + : 'В пространстве текущего момента '; + + return '$questionContext выпавшая связка знаков ($wordsListStr) указывает на непрерывную динамику перехода. ' + 'Первый знак («${words.first.word}») задает скрытый импульс и причину, а завершающий знак («${words.last.word}») указывает на ключ к гармоничному исходу. ' + 'Синтез значений призывает осознать взаимосвязь противоположностей и сделать шаг навстречу созиданию.'; } } diff --git a/lib/services/words_repository.dart b/lib/services/words_repository.dart index dbb79aa..1424db5 100644 --- a/lib/services/words_repository.dart +++ b/lib/services/words_repository.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:math'; import 'package:shared_preferences/shared_preferences.dart'; import '../data/words_data.dart'; @@ -10,111 +9,76 @@ class WordsRepository { factory WordsRepository() => _instance; WordsRepository._internal(); - final List _masterWords = List.from(kRussianTop1000Words); + static const String _kHistoryKey = 'oracle_999_sessions_history_v2'; + List _sessionWords = []; - final List _history = []; - bool _isInitialized = false; + final List _sessions = []; List get allWords => List.unmodifiable(_sessionWords); + List get sessions => List.unmodifiable(_sessions); + List get history => sessions; // Alias for compatibility int get totalWords => _sessionWords.length; - List get history => List.unmodifiable(_history); Future init() async { - if (_isInitialized) return; - - // Always reshuffle session numbers on startup for true synchronicity & randomization - reshuffleSessionNumbers(); + // 1. Initialize random session mapping + _initializeSessionMapping(); - // Load history from persistence - try { - final prefs = await SharedPreferences.getInstance(); - final historyJsonList = prefs.getStringList('oracle_history_entries') ?? []; - _history.clear(); - for (final str in historyJsonList) { - try { - final map = jsonDecode(str) as Map; - _history.add(HistoryEntry.fromMap(map)); - } catch (_) {} - } - } catch (_) {} - - _isInitialized = true; + // 2. Load stored session history + await _loadHistoryFromPrefs(); } - /// Cryptographic/pseudo-random Fisher-Yates shuffle to map 000..999 to words for the current session - void reshuffleSessionNumbers() { + void _initializeSessionMapping() { + final list = List.from(kRussianTop1000Words); final random = Random(); - final indices = List.generate(_masterWords.length, (i) => i); - indices.shuffle(random); - final mappedList = []; - for (int i = 0; i < _masterWords.length; i++) { - final baseWord = _masterWords[indices[i]]; - // i is the dynamic sessionNumber (0..999) - final sessionItem = baseWord.copyWith(sessionNumber: i); - mappedList.add(sessionItem); + // Fisher-Yates shuffle + for (int i = list.length - 1; i > 0; i--) { + int n = random.nextInt(i + 1); + final temp = list[i]; + list[i] = list[n]; + list[n] = temp; } - _sessionWords = mappedList; + _sessionWords = []; + for (int i = 0; i < list.length; i++) { + _sessionWords.add(list[i].copyWith(sessionNumber: i)); + } } - WordItem? getWordBySessionNumber(int sessionNum) { - if (sessionNum < 0 || sessionNum >= _sessionWords.length) return null; - return _sessionWords[sessionNum]; + void reshuffleSessionNumbers() { + _initializeSessionMapping(); } - WordItem? getWordById(int id) { - return getWordBySessionNumber(id); + WordItem? getWordBySessionNumber(int number) { + if (number < 0 || number >= _sessionWords.length) return null; + return _sessionWords[number]; } WordItem getRandomWord() { - if (_sessionWords.isEmpty) { - return _masterWords.first; - } final random = Random(); - final index = random.nextInt(_sessionWords.length); - return _sessionWords[index]; + final idx = random.nextInt(_sessionWords.length); + return _sessionWords[idx]; } - WordItem getNextWord(int currentSessionNum) { - if (_sessionWords.isEmpty) return getRandomWord(); - final nextId = (currentSessionNum + 1) % _sessionWords.length; - return _sessionWords[nextId]; + WordItem getNextWord(int currentSessionNumber) { + int nextNumber = (currentSessionNumber + 1) % _sessionWords.length; + return _sessionWords[nextNumber]; } - WordItem getPreviousWord(int currentSessionNum) { - if (_sessionWords.isEmpty) return getRandomWord(); - final prevId = (currentSessionNum - 1 + _sessionWords.length) % _sessionWords.length; - return _sessionWords[prevId]; + WordItem getPreviousWord(int currentSessionNumber) { + int prevNumber = (currentSessionNumber - 1 + _sessionWords.length) % _sessionWords.length; + return _sessionWords[prevNumber]; } - List search(String rawQuery) { - final query = rawQuery.trim().toLowerCase(); - if (query.isEmpty) return _sessionWords; + List search(String query) { + query = query.trim().toLowerCase(); + if (query.isEmpty) return []; - // Check if query is a number (e.g. 0, 00, 000, 42, 042, 999) final number = int.tryParse(query); if (number != null && number >= 0 && number < _sessionWords.length) { - final exact = _sessionWords[number]; - final matches = _sessionWords.where((w) { - if (w.sessionNumber == number) return false; - final idStr = w.sessionNumber.toString(); - final paddedStr = w.formattedSessionNumber; - return idStr.startsWith(query) || paddedStr.startsWith(query); - }).take(30).toList(); - return [exact, ...matches]; + return [_sessionWords[number]]; } - // Partial numeric match - if (RegExp(r'^\d+$').hasMatch(query)) { - return _sessionWords.where((w) { - final idStr = w.sessionNumber.toString(); - final paddedStr = w.formattedSessionNumber; - return idStr.startsWith(query) || paddedStr.startsWith(query); - }).take(30).toList(); - } - - // Text search in word, description, category, and part of speech final exact = []; final startsWith = []; final contains = []; @@ -156,21 +120,29 @@ class WordsRepository { return set.toList(); } - // --- History Management --- + // --- Session History Management --- - Future addHistoryEntry(HistoryEntry entry) async { - _history.insert(0, entry); - if (_history.length > 500) { - _history.removeRange(500, _history.length); + Future saveSession(OracleSession session) async { + final existingIdx = _sessions.indexWhere((s) => s.id == session.id); + if (existingIdx != -1) { + _sessions[existingIdx] = session; + } else { + _sessions.insert(0, session); + } + if (_sessions.length > 500) { + _sessions.removeRange(500, _sessions.length); } await _saveHistoryToPrefs(); } - Future updateHistoryEntry(String id, {String? aiInterpretation, String? question}) async { - final idx = _history.indexWhere((e) => e.id == id); + // Backward compatibility alias + Future addHistoryEntry(OracleSession entry) => saveSession(entry); + + Future updateSession(String id, {String? aiInterpretation, String? question}) async { + final idx = _sessions.indexWhere((e) => e.id == id); if (idx != -1) { - final old = _history[idx]; - _history[idx] = old.copyWith( + final old = _sessions[idx]; + _sessions[idx] = old.copyWith( aiInterpretation: aiInterpretation ?? old.aiInterpretation, question: question ?? old.question, ); @@ -178,21 +150,41 @@ class WordsRepository { } } - Future deleteHistoryEntry(String id) async { - _history.removeWhere((e) => e.id == id); + Future updateHistoryEntry(String id, {String? aiInterpretation, String? question}) => + updateSession(id, aiInterpretation: aiInterpretation, question: question); + + Future deleteSession(String id) async { + _sessions.removeWhere((e) => e.id == id); await _saveHistoryToPrefs(); } + Future deleteHistoryEntry(String id) => deleteSession(id); + Future clearHistory() async { - _history.clear(); + _sessions.clear(); await _saveHistoryToPrefs(); } + Future _loadHistoryFromPrefs() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonList = prefs.getStringList(_kHistoryKey); + _sessions.clear(); + if (jsonList != null) { + for (final item in jsonList) { + try { + _sessions.add(OracleSession.fromJson(item)); + } catch (_) {} + } + } + } catch (_) {} + } + Future _saveHistoryToPrefs() async { try { final prefs = await SharedPreferences.getInstance(); - final list = _history.map((e) => jsonEncode(e.toMap())).toList(); - await prefs.setStringList('oracle_history_entries', list); + final jsonList = _sessions.map((e) => e.toJson()).toList(); + await prefs.setStringList(_kHistoryKey, jsonList); } catch (_) {} } } diff --git a/lib/widgets/word_card_widget.dart b/lib/widgets/word_card_widget.dart index cab4939..ea566d6 100644 --- a/lib/widgets/word_card_widget.dart +++ b/lib/widgets/word_card_widget.dart @@ -87,19 +87,19 @@ class _WordCardWidgetState extends State { Future _recordToHistory() async { if (_savedToHistory) return; final newId = DateTime.now().millisecondsSinceEpoch.toString(); - final entry = HistoryEntry( + final sessionWord = SessionWord.fromWordItem( + widget.word, + positionTitle: widget.positionTitle, + ); + final session = OracleSession( id: newId, timestamp: DateTime.now(), - word: widget.word.word, - sessionNumber: widget.word.sessionNumber, - partOfSpeech: widget.word.partOfSpeech, - symbolicMeaning: widget.word.description, - readingType: widget.positionTitle ?? 'Жребий номера', - positionTitle: widget.positionTitle, + wordsCount: 1, + words: [sessionWord], question: widget.question, aiInterpretation: _aiInterpretation, ); - await _repo.addHistoryEntry(entry); + await _repo.saveSession(session); if (!mounted) return; setState(() { _savedToHistory = true; diff --git a/test/widget_test.dart b/test/widget_test.dart index 7c672b5..9cf917a 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -11,10 +11,9 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - // Verify app title and destinations + // Verify app title and 3 unified destinations expect(find.text('Оракул 999'), findsOneWidget); - expect(find.text('Поиск 000..999'), findsOneWidget); - expect(find.text('Расклады'), findsOneWidget); + expect(find.text('Оракул'), findsOneWidget); expect(find.text('Каталог 999'), findsOneWidget); expect(find.text('История'), findsOneWidget); }); diff --git a/test/words_repository_test.dart b/test/words_repository_test.dart index e9a3fc0..1da5b74 100644 --- a/test/words_repository_test.dart +++ b/test/words_repository_test.dart @@ -35,7 +35,6 @@ void main() { test('Reshuffling changes the order / mapping of words to session numbers', () { repo.reshuffleSessionNumbers(); - // Verify total count is still 1000 and all numbers 0..999 are present expect(repo.totalWords, equals(1000)); for (int i = 0; i < 1000; i++) { expect(repo.getWordBySessionNumber(i)?.sessionNumber, equals(i)); @@ -63,33 +62,53 @@ void main() { expect(repo.getPreviousWord(0).sessionNumber, equals(999)); }); - test('History entries can be added, updated with AI interpretation, and cleared', () async { + test('Multi-word session history can be saved, updated with AI interpretation, and cleared', () async { await repo.clearHistory(); - expect(repo.history.isEmpty, isTrue); + expect(repo.sessions.isEmpty, isTrue); - final entry = HistoryEntry( - id: 'hist_1', + final session = OracleSession( + id: 'session_1', timestamp: DateTime(2026, 8, 31, 12, 0), - word: 'огонь', - sessionNumber: 777, - partOfSpeech: 'существительное', - symbolicMeaning: 'Очищающее пламя трансформации', - readingType: 'Жребий момента', - question: 'В чем суть текущего вызова?', + question: 'В чем скрытый конфликт?', + wordsCount: 3, + words: [ + const SessionWord( + word: 'огонь', + sessionNumber: 777, + partOfSpeech: 'существительное', + category: 'Образ', + symbolicMeaning: 'Очищающее пламя', + ), + const SessionWord( + word: 'вода', + sessionNumber: 15, + partOfSpeech: 'существительное', + category: 'Образ', + symbolicMeaning: 'Поток интуиции', + ), + const SessionWord( + word: 'мир', + sessionNumber: 999, + partOfSpeech: 'существительное', + category: 'Образ', + symbolicMeaning: 'Гармония согласия', + ), + ], ); - await repo.addHistoryEntry(entry); - expect(repo.history.length, equals(1)); - expect(repo.history.first.word, equals('огонь')); - expect(repo.history.first.question, equals('В чем суть текущего вызова?')); - expect(repo.history.first.formattedSessionNumber, equals('777')); + await repo.saveSession(session); + expect(repo.sessions.length, equals(1)); + expect(repo.sessions.first.words.length, equals(3)); + expect(repo.sessions.first.words[0].word, equals('огонь')); + expect(repo.sessions.first.words[1].word, equals('вода')); + expect(repo.sessions.first.question, equals('В чем скрытый конфликт?')); // Update with AI interpretation - await repo.updateHistoryEntry('hist_1', aiInterpretation: 'Огонь призывает отпустить старые страхи'); - expect(repo.history.first.aiInterpretation, equals('Огонь призывает отпустить старые страхи')); + await repo.updateSession('session_1', aiInterpretation: 'Связка огня и воды рождает баланс мира.'); + expect(repo.sessions.first.aiInterpretation, equals('Связка огня и воды рождает баланс мира.')); await repo.clearHistory(); - expect(repo.history.isEmpty, isTrue); + expect(repo.sessions.isEmpty, isTrue); }); }); }