diff --git a/lib/models/history_entry_model.dart b/lib/models/history_entry_model.dart index 74530d9..2d81cd1 100644 --- a/lib/models/history_entry_model.dart +++ b/lib/models/history_entry_model.dart @@ -7,6 +7,8 @@ class HistoryEntry { final String symbolicMeaning; final String readingType; final String? positionTitle; + final String? question; + final String? aiInterpretation; const HistoryEntry({ required this.id, @@ -17,8 +19,36 @@ class HistoryEntry { required this.symbolicMeaning, this.readingType = 'Жребий', this.positionTitle, + this.question, + this.aiInterpretation, }); + HistoryEntry copyWith({ + String? id, + DateTime? timestamp, + String? word, + int? sessionNumber, + String? partOfSpeech, + String? symbolicMeaning, + String? readingType, + String? positionTitle, + String? question, + String? aiInterpretation, + }) { + return HistoryEntry( + 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, + aiInterpretation: aiInterpretation ?? this.aiInterpretation, + ); + } + String get formattedSessionNumber => sessionNumber.toString().padLeft(3, '0'); String get formattedDate { @@ -41,6 +71,8 @@ class HistoryEntry { 'symbolicMeaning': symbolicMeaning, 'readingType': readingType, 'positionTitle': positionTitle, + 'question': question, + 'aiInterpretation': aiInterpretation, }; } @@ -54,6 +86,8 @@ class HistoryEntry { symbolicMeaning: map['symbolicMeaning'] as String? ?? '', readingType: map['readingType'] as String? ?? 'Жребий', positionTitle: map['positionTitle'] as String?, + question: map['question'] as String?, + aiInterpretation: map['aiInterpretation'] as String?, ); } } diff --git a/lib/screens/history_screen.dart b/lib/screens/history_screen.dart index f70ba55..a264306 100644 --- a/lib/screens/history_screen.dart +++ b/lib/screens/history_screen.dart @@ -26,7 +26,7 @@ class _HistoryScreenState extends State { ], ), content: const Text( - 'Все сохранённые записи гаданий и жребиев с датами будут удалены.', + 'Все сохранённые записи гаданий, вопросов и AI-трактовок будут удалены.', ), actions: [ TextButton( @@ -49,14 +49,19 @@ class _HistoryScreenState extends State { } void _copyEntry(HistoryEntry entry) { - final text = + 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}'; + if (entry.aiInterpretation != null && entry.aiInterpretation!.isNotEmpty) { + text += '\n\n🤖 AI Трактовка (G4F):\n${entry.aiInterpretation}'; + } + Clipboard.setData(ClipboardData(text: text)); HapticFeedback.lightImpact(); ScaffoldMessenger.of(context).showSnackBar( @@ -119,7 +124,7 @@ class _HistoryScreenState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - 'Совершите жребий или сохраните выпавший символ, и он появится здесь с точной датой и временем.', + 'Задайте вопрос и бросьте жребий, чтобы получить ответ оракула и сохранить его здесь.', textAlign: TextAlign.center, style: TextStyle(color: theme.colorScheme.outline), ), @@ -188,6 +193,34 @@ class _HistoryScreenState extends State { ], ), + // 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}»', + style: const TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: Color(0xFFFFF9C4), + ), + ), + ), + ], + ), + ), + ], + if (item.positionTitle != null) ...[ const SizedBox(height: 6), Text( @@ -238,7 +271,7 @@ class _HistoryScreenState extends State { const SizedBox(height: 8), - // Meaning snippet + // Base Meaning snippet Container( width: double.infinity, padding: const EdgeInsets.all(10), @@ -257,6 +290,50 @@ class _HistoryScreenState extends State { ), ), ), + + // 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 e8303bb..0a5bc2e 100644 --- a/lib/screens/home_search_screen.dart +++ b/lib/screens/home_search_screen.dart @@ -1,5 +1,6 @@ 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'; @@ -15,16 +16,18 @@ class HomeSearchScreen extends StatefulWidget { class _HomeSearchScreenState extends State { final _repo = WordsRepository(); final _searchController = TextEditingController(); + final _questionController = TextEditingController(); final _focusNode = FocusNode(); WordItem? _currentWord; + String? _activeHistoryId; List _searchResults = []; bool _showKeypad = true; @override void initState() { super.initState(); - _currentWord = _repo.getWordBySessionNumber(0) ?? _repo.allWords.first; + // Do NOT show word by default until user interacts or starts a session _searchController.addListener(_onSearchChanged); } @@ -32,6 +35,7 @@ class _HomeSearchScreenState extends State { void dispose() { _searchController.removeListener(_onSearchChanged); _searchController.dispose(); + _questionController.dispose(); _focusNode.dispose(); super.dispose(); } @@ -49,14 +53,33 @@ class _HomeSearchScreenState extends State { setState(() { _searchResults = results; if (results.isNotEmpty) { - _currentWord = results.first; + _setWord(results.first, recordHistory: true); } }); } - void _setWord(WordItem word, {bool updateText = false}) { + Future _setWord(WordItem word, {bool updateText = false, 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: 'Жребий номера', + question: question.isNotEmpty ? question : null, + ); + await _repo.addHistoryEntry(entry); + } + setState(() { _currentWord = word; + _activeHistoryId = newHistId ?? _activeHistoryId; if (updateText) { _searchController.text = word.sessionNumber.toString(); } @@ -101,28 +124,35 @@ class _HomeSearchScreenState extends State { ); } + void _startNewSessionDraw() { + final rnd = _repo.getRandomWord(); + _setWord(rnd, updateText: true, recordHistory: true); + } + + void _resetToWelcome() { + setState(() { + _currentWord = null; + _searchController.clear(); + _searchResults = []; + }); + } + void _goToNext() { if (_currentWord == null) return; final next = _repo.getNextWord(_currentWord!.sessionNumber); - _setWord(next, updateText: true); + _setWord(next, updateText: true, recordHistory: false); } void _goToPrevious() { if (_currentWord == null) return; final prev = _repo.getPreviousWord(_currentWord!.sessionNumber); - _setWord(prev, updateText: true); - } - - void _goToRandom() { - final rnd = _repo.getRandomWord(); - _setWord(rnd, updateText: true); + _setWord(prev, updateText: true, recordHistory: false); } void _reshuffleSession() { _repo.reshuffleSessionNumbers(); - final first = _repo.getWordBySessionNumber(0) ?? _repo.allWords.first; setState(() { - _currentWord = first; + _currentWord = null; _searchController.clear(); _searchResults = []; }); @@ -155,11 +185,11 @@ class _HomeSearchScreenState extends State { return Scaffold( appBar: AppBar( - title: Row( + title: const Row( children: [ - const GradientStar(size: 24), - const SizedBox(width: 10), - const Text( + GradientStar(size: 24), + SizedBox(width: 10), + Text( 'Оракул 999', style: TextStyle( fontSize: 20, @@ -170,23 +200,24 @@ class _HomeSearchScreenState extends State { ], ), actions: [ + if (_currentWord != null) + IconButton( + tooltip: 'Новый вопрос / Сброс', + icon: const Icon(Icons.refresh_rounded), + onPressed: _resetToWelcome, + ), IconButton( - tooltip: 'Перетасовать числа сессии (#000..#999)', - icon: const Icon(Icons.refresh_rounded), + tooltip: 'Перетасовать числа (#000..#999)', + icon: const Icon(Icons.shuffle_rounded), onPressed: _reshuffleSession, ), IconButton( - tooltip: _showKeypad ? 'Скрыть цифровую панель' : 'Показать цифровую панель', + tooltip: _showKeypad ? 'Скрыть панель' : 'Показать панель', icon: Icon(_showKeypad ? Icons.dialpad_outlined : Icons.dialpad), onPressed: () { setState(() => _showKeypad = !_showKeypad); }, ), - IconButton( - tooltip: 'Случайный жребий', - icon: const Icon(Icons.shuffle_rounded), - onPressed: _goToRandom, - ), ], ), body: SafeArea( @@ -195,6 +226,45 @@ 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), + ), + ), + 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: 10), + // Search & Number Input Bar TextField( controller: _searchController, @@ -242,7 +312,7 @@ class _HomeSearchScreenState extends State { ), ), - // Search matches dropdown list if query produced text matches + // Search matches dropdown list if (_searchResults.length > 1 && _searchController.text.isNotEmpty) ...[ const SizedBox(height: 8), Container( @@ -287,7 +357,7 @@ class _HomeSearchScreenState extends State { ), ), onTap: () { - _setWord(item, updateText: false); + _setWord(item, updateText: false, recordHistory: true); }, ); }, @@ -297,8 +367,85 @@ class _HomeSearchScreenState extends State { const SizedBox(height: 14), - // Main Sacred Word Card Display - if (_currentWord != null) + // Welcome Idle State OR Active Word Card + if (_currentWord == null) ...[ + // Welcome Initiation Card + Container( + padding: const EdgeInsets.all(22), + 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, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFFFD54F).withValues(alpha: 0.1), + blurRadius: 16, + ), + ], + ), + child: Column( + children: [ + const GradientStar(size: 44), + const SizedBox(height: 14), + const Text( + 'СВЯЩЕННЫЙ ЖРЕБИЙ ОРАКУЛА', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + letterSpacing: 1.0, + color: Color(0xFFFFF9C4), + ), + ), + const SizedBox(height: 10), + Text( + 'Сформулируйте в мыслях или запишите сверху ваш вопрос, затем нажмите кнопку или наберите номер от 000 до 999 для раскрытия символического ключа.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + height: 1.45, + color: theme.colorScheme.onSurface.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _startNewSessionDraw, + icon: const GradientStar(size: 20), + label: const Text( + 'НАЧАТЬ СЕССИЮ / БРОСИТЬ ЖРЕБИЙ', + style: TextStyle( + fontWeight: FontWeight.w900, + letterSpacing: 0.5, + ), + ), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF2A2D4A), + foregroundColor: const Color(0xFFFFF9C4), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: const BorderSide( + color: Color(0xFFFFD54F), + width: 1.2, + ), + ), + ), + ), + ], + ), + ), + ] else ...[ + // Main Sacred Word Card Display AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { @@ -316,26 +463,18 @@ class _HomeSearchScreenState extends State { child: WordCardWidget( key: ValueKey('${_currentWord!.id}_${_currentWord!.sessionNumber}'), word: _currentWord!, + question: _questionController.text.trim(), + historyEntryId: _activeHistoryId, onNext: _goToNext, onPrevious: _goToPrevious, - onRandom: _goToRandom, - ), - ) - else - Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Text( - 'Символ с таким номером не найден.', - textAlign: TextAlign.center, - style: TextStyle(color: theme.colorScheme.error), - ), + onRandom: _startNewSessionDraw, ), ), + ], const SizedBox(height: 14), - // Interactive Numeric Keypad (1..9, C, 0, ⌫) + // Interactive Numeric Keypad if (_showKeypad) ...[ Container( padding: const EdgeInsets.all(12), diff --git a/lib/screens/oracle_spread_screen.dart b/lib/screens/oracle_spread_screen.dart index 6a7ae79..f9bb264 100644 --- a/lib/screens/oracle_spread_screen.dart +++ b/lib/screens/oracle_spread_screen.dart @@ -15,10 +15,13 @@ class OracleSpreadScreen extends StatefulWidget { 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 @@ -27,35 +30,47 @@ class _OracleSpreadScreenState extends State { _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: 250)); + await Future.delayed(const Duration(milliseconds: 200)); final word = _repo.getRandomWord(); - setState(() { - _singleDraw = word; - _isGenerating = false; - }); + String? histId; if (saveHistory) { + histId = DateTime.now().millisecondsSinceEpoch.toString(); + final question = _questionController.text.trim(); final entry = HistoryEntry( - id: DateTime.now().millisecondsSinceEpoch.toString(), + 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: 300)); + await Future.delayed(const Duration(milliseconds: 250)); final words = []; final titles = [ @@ -71,14 +86,14 @@ class _OracleSpreadScreenState extends State { } } - setState(() { - _triadDraw = words; - _isGenerating = false; - }); + 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: '${DateTime.now().millisecondsSinceEpoch}_$i', + id: histId, timestamp: DateTime.now(), word: words[i].word, sessionNumber: words[i].sessionNumber, @@ -86,9 +101,16 @@ class _OracleSpreadScreenState extends State { 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 @@ -115,6 +137,45 @@ class _OracleSpreadScreenState extends State { 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), @@ -264,26 +325,38 @@ class _OracleSpreadScreenState extends State { ) 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, ), ], ), diff --git a/lib/services/ai_oracle_service.dart b/lib/services/ai_oracle_service.dart new file mode 100644 index 0000000..48ab3cc --- /dev/null +++ b/lib/services/ai_oracle_service.dart @@ -0,0 +1,188 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/word_model.dart'; + +class AiOracleService { + static final AiOracleService _instance = AiOracleService._internal(); + factory AiOracleService() => _instance; + AiOracleService._internal(); + + // Configurable local or remote G4F server URL + String g4fServerUrl = 'http://localhost:1337/v1/chat/completions'; + + /// Generates an esoteric, archetypal interpretation weaving the user's question, the drawn word, its session number, and its symbolic meaning. + 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, + ); + } + + /// 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 + .post( + Uri.parse(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', + }, + body: jsonEncode({ + 'model': 'gpt-4o-mini', + 'messages': [ + {'role': 'system', 'content': systemPrompt}, + {'role': 'user', 'content': userPrompt}, + ], + 'temperature': 0.7, + 'max_tokens': 700, + }), + ) + .timeout(timeout); + + 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(); + } + } + } + } + } + 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(), + }, + ); + + 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')) { + return text; + } + } + return null; + } + + /// Offline fallback generator in case of network unavailability + String _generateOfflineInterpretation({ + required WordItem word, + required String question, + String? positionTitle, + }) { + final pos = word.partOfSpeech.toLowerCase(); + String focus = 'ключевую сферу кристаллизации'; + if (pos.contains('глагол')) { + focus = 'необходимый вектор действия и трансформации'; + } else if (pos.contains('прилаг')) { + focus = 'вибрационное качество и внутреннее состояние'; + } else if (pos.contains('нареч')) { + focus = 'модус и темп развертывания судьбы'; + } + + return '🔮 Послание знака #${word.formattedSessionNumber} «${word.word.toUpperCase()}» на ваш запрос «$question»:\n\n' + '1. Смысловой узел: Символ указывает на $focus в текущей ситуации. ${word.description}\n\n' + '2. Внутренний вызов: Обратите внимание на то, как энергия слова «${word.word}» соотносится с вашими скрытыми ожиданиями. ' + 'Не пытайтесь форсировать события вопреки естественному ритму.\n\n' + '3. Ключ к действию: Примите качества этого знака как ориентир для принятия решения в ближайшие дни.'; + } +} diff --git a/lib/services/words_repository.dart b/lib/services/words_repository.dart index bdb7cf3..dbb79aa 100644 --- a/lib/services/words_repository.dart +++ b/lib/services/words_repository.dart @@ -166,6 +166,18 @@ class WordsRepository { await _saveHistoryToPrefs(); } + Future updateHistoryEntry(String id, {String? aiInterpretation, String? question}) async { + final idx = _history.indexWhere((e) => e.id == id); + if (idx != -1) { + final old = _history[idx]; + _history[idx] = old.copyWith( + aiInterpretation: aiInterpretation ?? old.aiInterpretation, + question: question ?? old.question, + ); + await _saveHistoryToPrefs(); + } + } + Future deleteHistoryEntry(String id) async { _history.removeWhere((e) => e.id == id); await _saveHistoryToPrefs(); diff --git a/lib/widgets/word_card_widget.dart b/lib/widgets/word_card_widget.dart index 5fc4efc..ddf1183 100644 --- a/lib/widgets/word_card_widget.dart +++ b/lib/widgets/word_card_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../models/history_entry_model.dart'; import '../models/word_model.dart'; +import '../services/ai_oracle_service.dart'; import '../services/words_repository.dart'; import 'gradient_star_badge.dart'; @@ -10,7 +11,10 @@ class WordCardWidget extends StatefulWidget { final VoidCallback? onNext; final VoidCallback? onPrevious; final VoidCallback? onRandom; - final String? positionTitle; // Optional title for spread positions + final String? positionTitle; + final String? question; + final String? initialAiInterpretation; + final String? historyEntryId; const WordCardWidget({ super.key, @@ -19,6 +23,9 @@ class WordCardWidget extends StatefulWidget { this.onPrevious, this.onRandom, this.positionTitle, + this.question, + this.initialAiInterpretation, + this.historyEntryId, }); @override @@ -27,35 +34,51 @@ class WordCardWidget extends StatefulWidget { class _WordCardWidgetState extends State { final _repo = WordsRepository(); + final _aiService = AiOracleService(); + bool _savedToHistory = false; + String? _currentHistoryId; + String? _aiInterpretation; + bool _isLoadingAi = false; + + @override + void initState() { + super.initState(); + _currentHistoryId = widget.historyEntryId; + _aiInterpretation = widget.initialAiInterpretation; + _savedToHistory = widget.historyEntryId != null; + } @override void didUpdateWidget(covariant WordCardWidget oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.word.id != widget.word.id || oldWidget.word.sessionNumber != widget.word.sessionNumber) { - _savedToHistory = false; + _savedToHistory = widget.historyEntryId != null; + _currentHistoryId = widget.historyEntryId; + _aiInterpretation = widget.initialAiInterpretation; + _isLoadingAi = false; } } Color _getPosColor(String pos) { switch (pos.toLowerCase()) { case 'существительное': - return const Color(0xFF64B5F6); // Celestial Blue + return const Color(0xFF64B5F6); case 'глагол': - return const Color(0xFF81C784); // Vital Green + return const Color(0xFF81C784); case 'прилагательное': - return const Color(0xFFFFD54F); // Golden + return const Color(0xFFFFD54F); case 'наречие': case 'наречие-местоимение': - return const Color(0xFFBA68C8); // Mystic Violet + return const Color(0xFFBA68C8); case 'местоимение': case 'местоименное прил.': - return const Color(0xFF4DD0E1); // Cyan + return const Color(0xFF4DD0E1); case 'предлог': case 'союз': case 'частица': - return const Color(0xFFFF8A65); // Coral Ember + return const Color(0xFFFF8A65); default: return const Color(0xFF90A4AE); } @@ -63,8 +86,9 @@ class _WordCardWidgetState extends State { Future _recordToHistory() async { if (_savedToHistory) return; + final newId = DateTime.now().millisecondsSinceEpoch.toString(); final entry = HistoryEntry( - id: DateTime.now().millisecondsSinceEpoch.toString(), + id: newId, timestamp: DateTime.now(), word: widget.word.word, sessionNumber: widget.word.sessionNumber, @@ -72,10 +96,15 @@ class _WordCardWidgetState extends State { symbolicMeaning: widget.word.description, readingType: widget.positionTitle ?? 'Жребий номера', positionTitle: widget.positionTitle, + question: widget.question, + aiInterpretation: _aiInterpretation, ); await _repo.addHistoryEntry(entry); if (!mounted) return; - setState(() => _savedToHistory = true); + setState(() { + _savedToHistory = true; + _currentHistoryId = newId; + }); HapticFeedback.mediumImpact(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -95,6 +124,47 @@ class _WordCardWidgetState extends State { ); } + Future _fetchAiInterpretation() async { + setState(() => _isLoadingAi = true); + HapticFeedback.mediumImpact(); + + try { + final result = await _aiService.interpret( + word: widget.word, + question: widget.question ?? '', + positionTitle: widget.positionTitle, + ); + + if (mounted) { + setState(() { + _aiInterpretation = result; + _isLoadingAi = false; + }); + + // Update history if already saved, or save now + if (_currentHistoryId != null) { + await _repo.updateHistoryEntry( + _currentHistoryId!, + aiInterpretation: result, + question: widget.question, + ); + } else { + await _recordToHistory(); + } + } + } catch (e) { + if (mounted) { + setState(() => _isLoadingAi = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка генерации: $e'), + behavior: SnackBarBehavior.floating, + ), + ); + } + } + } + void _copyToClipboard(BuildContext context) { final now = DateTime.now(); final day = now.day.toString().padLeft(2, '0'); @@ -103,13 +173,18 @@ class _WordCardWidgetState extends State { final hour = now.hour.toString().padLeft(2, '0'); final min = now.minute.toString().padLeft(2, '0'); - final text = + var text = '✨ Оракул 999 — Толкование символа ✨\n' 'Дата и время: $day.$month.$year $hour:$min\n' + '${widget.question != null && widget.question!.isNotEmpty ? "Вопрос: «${widget.question}»\n" : ""}' 'Знак №${widget.word.formattedSessionNumber}: ${widget.word.word.toUpperCase()}\n' 'Аспект: ${widget.word.partOfSpeech} • ${widget.word.category}\n\n' '🔮 Символическое значение:\n${widget.word.description}'; + if (_aiInterpretation != null) { + text += '\n\n🤖 AI Трактовка:\n$_aiInterpretation'; + } + Clipboard.setData(ClipboardData(text: text)); HapticFeedback.lightImpact(); ScaffoldMessenger.of(context).showSnackBar( @@ -161,15 +236,51 @@ class _WordCardWidgetState extends State { ], ), ), - padding: const EdgeInsets.all(22), + padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + // Question context banner if present + if (widget.question != null && widget.question!.trim().isNotEmpty) ...[ + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF2A2D4A).withValues(alpha: 0.7), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.35), + ), + ), + child: Row( + children: [ + const Icon( + Icons.help_outline_rounded, + size: 16, + color: Color(0xFFFFD54F), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Вопрос: «${widget.question}»', + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w600, + color: Color(0xFFFFF9C4), + fontStyle: FontStyle.italic, + ), + ), + ), + ], + ), + ), + ], + // Optional spread position banner (e.g. "Исток / Прошлое") if (widget.positionTitle != null) ...[ Container( - margin: const EdgeInsets.only(bottom: 12), + margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: const Color(0xFFFFD54F).withValues(alpha: 0.15), @@ -226,12 +337,12 @@ class _WordCardWidgetState extends State { ], ), - const SizedBox(height: 14), + const SizedBox(height: 12), // Big Word Title Center( child: Padding( - padding: const EdgeInsets.symmetric(vertical: 6.0), + padding: const EdgeInsets.symmetric(vertical: 4.0), child: Text( widget.word.word.toUpperCase(), textAlign: TextAlign.center, @@ -284,17 +395,17 @@ class _WordCardWidgetState extends State { ), ), - const SizedBox(height: 16), + const SizedBox(height: 14), Divider( height: 1, color: isDark ? Colors.white12 : Colors.black12, ), - const SizedBox(height: 14), + const SizedBox(height: 12), - // Symbolic Divination Meaning Box + // Base Symbolic Divination Meaning Box Container( width: double.infinity, - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isDark ? const Color(0xFF141729) @@ -303,38 +414,31 @@ class _WordCardWidgetState extends State { border: Border.all( color: const Color(0xFFFFD54F).withValues(alpha: 0.2), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: isDark ? 0.2 : 0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + const Row( children: [ - const GradientStar(size: 16), - const SizedBox(width: 8), + GradientStar(size: 15), + SizedBox(width: 8), Text( - 'СИМВОЛИЧЕСКОЕ ЗНАЧЕНИЕ', + 'СИМВОЛИЧЕСКИЙ СМЫСЛ', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 1.0, - color: const Color(0xFFFFD54F), + color: Color(0xFFFFD54F), ), ), ], ), - const SizedBox(height: 10), + const SizedBox(height: 8), Text( widget.word.description, - style: theme.textTheme.bodyLarge?.copyWith( - height: 1.5, - fontSize: 14.5, + style: theme.textTheme.bodyMedium?.copyWith( + height: 1.45, + fontSize: 13.5, color: isDark ? const Color(0xFFE2E4F0) : const Color(0xFF2C3042), ), ), @@ -342,7 +446,108 @@ class _WordCardWidgetState extends State { ), ), - const SizedBox(height: 18), + const SizedBox(height: 12), + + // AI Interpretation Block / Button + if (_aiInterpretation != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFF2A2145), + const Color(0xFF1A1B35), + ], + ), + borderRadius: BorderRadius.circular(16), + 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: 18, color: Color(0xFFE1BEE7)), + SizedBox(width: 8), + Text( + 'AI ТРАКТОВКА ОРАКУЛА (G4F)', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w900, + letterSpacing: 0.8, + color: Color(0xFFE1BEE7), + ), + ), + ], + ), + InkWell( + onTap: _fetchAiInterpretation, + child: const Padding( + padding: EdgeInsets.all(4.0), + child: Icon(Icons.refresh_rounded, size: 16, color: Color(0xFFE1BEE7)), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + _aiInterpretation!, + style: const TextStyle( + fontSize: 13.5, + height: 1.5, + color: Color(0xFFF3E5F5), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + ] else ...[ + // Button to generate AI interpretation + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _isLoadingAi ? null : _fetchAiInterpretation, + icon: _isLoadingAi + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Color(0xFFBA68C8)), + ), + ) + : const Icon(Icons.auto_awesome, size: 16, color: Color(0xFFBA68C8)), + label: Text( + _isLoadingAi ? 'Синтез толкования...' : 'Спросить AI Оракула (G4F-трактовка)', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12.5, + color: Color(0xFFE1BEE7), + ), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 10), + side: BorderSide( + color: const Color(0xFFBA68C8).withValues(alpha: 0.5), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + ), + ), + const SizedBox(height: 12), + ], // Navigation Controls Row Row( @@ -353,7 +558,7 @@ class _WordCardWidgetState extends State { icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 14), label: const Text('Пред.'), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 11), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), @@ -366,7 +571,7 @@ class _WordCardWidgetState extends State { onPressed: widget.onRandom, icon: const GradientStar(size: 20), style: IconButton.styleFrom( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.all(11), backgroundColor: const Color(0xFF2A2D4A), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), @@ -383,7 +588,7 @@ class _WordCardWidgetState extends State { icon: const Text('След.'), label: const Icon(Icons.arrow_forward_ios_rounded, size: 14), style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12), + padding: const EdgeInsets.symmetric(vertical: 11), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), diff --git a/pubspec.lock b/pubspec.lock index 1a4ca43..717622b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -96,6 +96,22 @@ packages: description: flutter source: sdk version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" leak_tracker: dependency: transitive description: @@ -309,6 +325,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 868d4cf..ecad597 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 shared_preferences: ^2.5.5 + http: ^1.6.0 dev_dependencies: flutter_test: diff --git a/scripts/app_icon_master.png b/scripts/app_icon_master.png new file mode 100644 index 0000000..0934707 Binary files /dev/null and b/scripts/app_icon_master.png differ diff --git a/test/words_repository_test.dart b/test/words_repository_test.dart index fa68dc0..e9a3fc0 100644 --- a/test/words_repository_test.dart +++ b/test/words_repository_test.dart @@ -63,25 +63,30 @@ void main() { expect(repo.getPreviousWord(0).sessionNumber, equals(999)); }); - test('History entries can be added, retrieved, and cleared', () async { + test('History entries can be added, updated with AI interpretation, and cleared', () async { await repo.clearHistory(); expect(repo.history.isEmpty, isTrue); final entry = HistoryEntry( - id: '1', + id: 'hist_1', timestamp: DateTime(2026, 8, 31, 12, 0), word: 'огонь', sessionNumber: 777, partOfSpeech: 'существительное', symbolicMeaning: 'Очищающее пламя трансформации', readingType: 'Жребий момента', + question: 'В чем суть текущего вызова?', ); 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')); - expect(repo.history.first.formattedDate, contains('31.08.2026 12:00:00')); + + // Update with AI interpretation + await repo.updateHistoryEntry('hist_1', aiInterpretation: 'Огонь призывает отпустить старые страхи'); + expect(repo.history.first.aiInterpretation, equals('Огонь призывает отпустить старые страхи')); await repo.clearHistory(); expect(repo.history.isEmpty, isTrue);