From 10c3fee08328fc9aa7d26d6a56f2caa7c85152e9 Mon Sep 17 00:00:00 2001 From: Alma Alexyz Date: Mon, 31 Aug 2026 15:31:51 +0500 Subject: [PATCH] feat: interactive oracle session chat and dynamic draw additional d10 cards for context refinement --- lib/models/history_entry_model.dart | 44 +- lib/screens/session_result_screen.dart | 859 ++++++++++++++++++------- lib/services/ai_oracle_service.dart | 55 ++ 3 files changed, 734 insertions(+), 224 deletions(-) diff --git a/lib/models/history_entry_model.dart b/lib/models/history_entry_model.dart index 887c3f0..69603c0 100644 --- a/lib/models/history_entry_model.dart +++ b/lib/models/history_entry_model.dart @@ -48,6 +48,40 @@ class SessionWord { ); } +class SessionChatMessage { + final String id; + final String sender; // 'user' | 'oracle' | 'system' + final String text; + final DateTime timestamp; + final SessionWord? attachedWord; + + const SessionChatMessage({ + required this.id, + required this.sender, + required this.text, + required this.timestamp, + this.attachedWord, + }); + + Map toMap() => { + 'id': id, + 'sender': sender, + 'text': text, + 'timestamp': timestamp.toIso8601String(), + if (attachedWord != null) 'attachedWord': attachedWord!.toMap(), + }; + + factory SessionChatMessage.fromMap(Map map) => SessionChatMessage( + id: map['id'] as String? ?? '', + sender: map['sender'] as String? ?? 'oracle', + text: map['text'] as String? ?? '', + timestamp: DateTime.tryParse(map['timestamp'] as String? ?? '') ?? DateTime.now(), + attachedWord: map['attachedWord'] != null + ? SessionWord.fromMap(map['attachedWord'] as Map) + : null, + ); +} + class OracleSession { final String id; final DateTime timestamp; @@ -55,6 +89,7 @@ class OracleSession { final int wordsCount; final List words; final String? aiInterpretation; + final List messages; const OracleSession({ required this.id, @@ -63,6 +98,7 @@ class OracleSession { required this.wordsCount, required this.words, this.aiInterpretation, + this.messages = const [], }); OracleSession copyWith({ @@ -72,6 +108,7 @@ class OracleSession { int? wordsCount, List? words, String? aiInterpretation, + List? messages, }) { return OracleSession( id: id ?? this.id, @@ -80,6 +117,7 @@ class OracleSession { wordsCount: wordsCount ?? this.wordsCount, words: words ?? this.words, aiInterpretation: aiInterpretation ?? this.aiInterpretation, + messages: messages ?? this.messages, ); } @@ -100,6 +138,7 @@ class OracleSession { 'wordsCount': wordsCount, 'words': words.map((w) => w.toMap()).toList(), if (aiInterpretation != null) 'aiInterpretation': aiInterpretation, + 'messages': messages.map((m) => m.toMap()).toList(), }; factory OracleSession.fromMap(Map map) { @@ -113,6 +152,10 @@ class OracleSession { .toList() ?? [], aiInterpretation: map['aiInterpretation'] as String?, + messages: (map['messages'] as List?) + ?.map((item) => SessionChatMessage.fromMap(item as Map)) + .toList() ?? + [], ); } @@ -120,5 +163,4 @@ class OracleSession { factory OracleSession.fromJson(String source) => OracleSession.fromMap(jsonDecode(source)); } -// Backward compatibility alias typedef HistoryEntry = OracleSession; diff --git a/lib/screens/session_result_screen.dart b/lib/screens/session_result_screen.dart index bcd381c..86fe4c0 100644 --- a/lib/screens/session_result_screen.dart +++ b/lib/screens/session_result_screen.dart @@ -1,8 +1,10 @@ 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 '../widgets/d10_dice_roller_widget.dart'; import '../widgets/gradient_star_badge.dart'; class SessionResultScreen extends StatefulWidget { @@ -20,21 +22,34 @@ class SessionResultScreen extends StatefulWidget { class _SessionResultScreenState extends State { final _aiService = AiOracleService(); final _repo = WordsRepository(); + final _chatInputController = TextEditingController(); + final _scrollController = ScrollController(); late OracleSession _session; - bool _isLoadingAi = false; + bool _isGeneratingInitialAi = false; + bool _isChatLoading = false; @override void initState() { super.initState(); _session = widget.session; - // Persist the session atomically _repo.saveSession(_session); + + // If initial interpretation is not yet done, trigger it + if (_session.aiInterpretation == null) { + _fetchInitialAi(); + } } - Future _fetchAiInterpretation() async { - setState(() => _isLoadingAi = true); - HapticFeedback.mediumImpact(); + @override + void dispose() { + _chatInputController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _fetchInitialAi() async { + setState(() => _isGeneratingInitialAi = true); try { final result = await _aiService.interpretSession( @@ -43,26 +58,138 @@ class _SessionResultScreenState extends State { ); if (mounted) { - final updated = _session.copyWith(aiInterpretation: result); + final initialMessage = SessionChatMessage( + id: 'msg_0', + sender: 'oracle', + text: result, + timestamp: DateTime.now(), + ); + + final updated = _session.copyWith( + aiInterpretation: result, + messages: _session.messages.isEmpty ? [initialMessage] : _session.messages, + ); + setState(() { _session = updated; - _isLoadingAi = false; + _isGeneratingInitialAi = false; }); await _repo.saveSession(updated); + _scrollToBottom(); + } + } catch (_) { + if (mounted) { + setState(() => _isGeneratingInitialAi = false); + } + } + } + + Future _sendMessage({SessionWord? newlyAddedWord}) async { + final text = _chatInputController.text.trim(); + if (text.isEmpty && newlyAddedWord == null) return; + + final userMessageText = text.isNotEmpty + ? text + : 'Раскрой значение добавленного знака #${newlyAddedWord?.formattedSessionNumber} «${newlyAddedWord?.word.toUpperCase()}».'; + + _chatInputController.clear(); + + final userMsg = SessionChatMessage( + id: 'msg_${DateTime.now().millisecondsSinceEpoch}', + sender: 'user', + text: userMessageText, + timestamp: DateTime.now(), + attachedWord: newlyAddedWord, + ); + + final updatedWithUser = _session.copyWith( + messages: [..._session.messages, userMsg], + ); + + setState(() { + _session = updatedWithUser; + _isChatLoading = true; + }); + _scrollToBottom(); + + try { + final reply = await _aiService.chatWithOracle( + session: updatedWithUser, + userMessage: userMessageText, + newlyAddedWord: newlyAddedWord, + ); + + if (mounted) { + final oracleMsg = SessionChatMessage( + id: 'msg_${DateTime.now().millisecondsSinceEpoch + 1}', + sender: 'oracle', + text: reply, + timestamp: DateTime.now(), + ); + + final finalSession = updatedWithUser.copyWith( + messages: [...updatedWithUser.messages, oracleMsg], + ); + + setState(() { + _session = finalSession; + _isChatLoading = false; + }); + await _repo.saveSession(finalSession); + _scrollToBottom(); } } catch (e) { if (mounted) { - setState(() => _isLoadingAi = false); + setState(() => _isChatLoading = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Ошибка генерации: $e'), - behavior: SnackBarBehavior.floating, - ), + SnackBar(content: Text('Ошибка отправки: $e')), ); } } } + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent + 100, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + void _showAddWordModal() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => _AddWordBottomSheet( + onWordChosen: (WordItem word) async { + Navigator.pop(ctx); + final sessionWord = SessionWord.fromWordItem( + word, + positionTitle: 'Уточняющий знак #${_session.words.length + 1}', + ); + + final updated = _session.copyWith( + words: [..._session.words, sessionWord], + wordsCount: _session.words.length + 1, + ); + + setState(() { + _session = updated; + }); + await _repo.saveSession(updated); + + // Trigger chat refinement with this new word + await _sendMessage(newlyAddedWord: sessionWord); + }, + ), + ); + } + void _copySessionToClipboard() { final buf = StringBuffer(); buf.writeln('✨ Оракул 999 — Сессия Клеромантии ✨'); @@ -76,12 +203,11 @@ class _SessionResultScreenState extends State { 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'); + buf.writeln(' ${w.symbolicMeaning}\n'); } if (_session.aiInterpretation != null) { - buf.writeln('🤖 AI Трактовка Оракула (G4F):'); + buf.writeln('🤖 Толкование Оракула (G4F):'); buf.writeln(_session.aiInterpretation); } @@ -93,7 +219,7 @@ class _SessionResultScreenState extends State { children: [ GradientStar(size: 18), SizedBox(width: 10), - Expanded(child: Text('Вся сессия скопирована в буфер обмена')), + Expanded(child: Text('Сессия скопирована в буфер обмена')), ], ), duration: const Duration(seconds: 2), @@ -115,12 +241,17 @@ class _SessionResultScreenState extends State { const GradientStar(size: 22), const SizedBox(width: 8), Text( - 'Знаки Сессии (${_session.words.length})', + 'Сессия (${_session.words.length} ${_session.words.length == 1 ? "знак" : (_session.words.length < 5 ? "знака" : "знаков")})', style: const TextStyle(fontWeight: FontWeight.w900), ), ], ), actions: [ + IconButton( + tooltip: 'Докинуть уточняющий знак', + icon: const Icon(Icons.add_circle_outline_rounded, color: Color(0xFFFFD54F)), + onPressed: _showAddWordModal, + ), IconButton( tooltip: 'Скопировать сессию', icon: const Icon(Icons.copy_rounded), @@ -129,231 +260,513 @@ class _SessionResultScreenState extends State { ], ), 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, - ), - ), - ), - ], - ), + child: Column( + children: [ + // Top Collapsible / Horizontal Words Strip + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF141728) : theme.colorScheme.surfaceContainerLow, + border: const Border( + bottom: BorderSide(color: Colors.white12, width: 1), ), - 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, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Question line if present + if (_session.question != null && _session.question!.isNotEmpty) ...[ + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( 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), - ), + const Icon(Icons.help_outline_rounded, size: 14, color: Color(0xFFFFD54F)), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Вопрос: «${_session.question}»', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFFFFF9C4), + fontStyle: FontStyle.italic, ), - ], - ), - 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), + ), + ], + + // Words Horizontal Badges List + Add Button + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + ..._session.words.asMap().entries.map((e) { + final idx = e.key; + final w = e.value; + return Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: () => _showWordDetails(w), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFF232742), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFFFD54F).withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Text( + '${idx + 1}. ', + style: TextStyle( + fontSize: 11, + color: theme.colorScheme.outline, + fontWeight: FontWeight.bold, + ), + ), + Text( + '#${w.formattedSessionNumber} ${w.word.toUpperCase()}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w900, + color: Color(0xFFFFD54F), + ), + ), + ], + ), + ), + ), + ); + }), + // Button: Add word + OutlinedButton.icon( + onPressed: _showAddWordModal, + icon: const Icon(Icons.casino_rounded, size: 14, color: Color(0xFFFFD54F)), + label: const Text( + '+ Докинуть знак', + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.bold, + color: Color(0xFFFFD54F), + ), + ), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + side: const BorderSide(color: Color(0xFFFFD54F)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), ), - ), - ], + ], + ), ), - ), - const SizedBox(height: 16), - ] else ...[ - FilledButton.icon( - onPressed: _isLoadingAi ? null : _fetchAiInterpretation, - icon: _isLoadingAi - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, + ], + ), + ), + + // Live Chat Area + Expanded( + child: _session.messages.isEmpty && _isGeneratingInitialAi + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( 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, - ), - ), + SizedBox(height: 16), + Text( + 'Оракул синтезирует символы сессии...', + style: TextStyle(fontStyle: FontStyle.italic), ), ], ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + itemCount: _session.messages.length + (_isChatLoading ? 1 : 0), + itemBuilder: (context, idx) { + if (idx == _session.messages.length) { + return _buildTypingIndicator(); + } + + final msg = _session.messages[idx]; + final isUser = msg.sender == 'user'; + + return _buildChatBubble(msg, isUser, isDark, theme); + }, + ), + ), + + // Bottom Chat Input Bar + Container( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 10), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF141728) : theme.colorScheme.surfaceContainerHigh, + border: const Border( + top: BorderSide(color: Colors.white12, width: 1), + ), + ), + child: Row( + children: [ + IconButton( + tooltip: 'Докинуть уточняющий знак d10', + icon: const Icon(Icons.casino_rounded, color: Color(0xFFFFD54F), size: 24), + onPressed: _showAddWordModal, + ), + const SizedBox(width: 4), + Expanded( + child: TextField( + controller: _chatInputController, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _sendMessage(), + decoration: InputDecoration( + hintText: 'Задайте уточняющий вопрос оракулу...', + hintStyle: const TextStyle(fontSize: 13), + filled: true, + fillColor: isDark ? const Color(0xFF1F223B) : theme.colorScheme.surface, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(20), + borderSide: BorderSide.none, + ), + ), ), ), - ); - }), + const SizedBox(width: 6), + IconButton.filled( + onPressed: _isChatLoading ? null : () => _sendMessage(), + icon: const Icon(Icons.arrow_upward_rounded, size: 20), + style: IconButton.styleFrom( + backgroundColor: const Color(0xFF2A2D4A), + foregroundColor: const Color(0xFFFFD54F), + side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildChatBubble( + SessionChatMessage msg, bool isUser, bool isDark, ThemeData theme) { + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.85, + ), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: isUser + ? const LinearGradient( + colors: [Color(0xFF2A2D4A), Color(0xFF1E2138)], + ) + : const LinearGradient( + colors: [Color(0xFF2B1F4A), Color(0xFF1A1B35)], + ), + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isUser ? 18 : 4), + bottomRight: Radius.circular(isUser ? 4 : 18), + ), + border: Border.all( + color: isUser + ? const Color(0xFFFFD54F).withValues(alpha: 0.3) + : const Color(0xFFBA68C8).withValues(alpha: 0.45), + width: 1.2, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: Sender icon & name + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isUser ? Icons.person_outline_rounded : Icons.psychology_rounded, + size: 14, + color: isUser ? const Color(0xFFFFD54F) : const Color(0xFFE1BEE7), + ), + const SizedBox(width: 5), + Text( + isUser ? 'Вы' : 'Оракул 999', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w900, + color: isUser ? const Color(0xFFFFD54F) : const Color(0xFFE1BEE7), + ), + ), + ], + ), + + // Attached new word pill if present + if (msg.attachedWord != null) ...[ + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFF3B2B5E), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.4)), + ), + child: Text( + '🎲 Добавлен знак: #${msg.attachedWord!.formattedSessionNumber} «${msg.attachedWord!.word.toUpperCase()}»', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: Color(0xFFFFF9C4), + ), + ), + ), + ], + + const SizedBox(height: 6), + + // Message text + Text( + msg.text, + style: TextStyle( + fontSize: 13.5, + height: 1.45, + color: isUser ? Colors.white : const Color(0xFFF3E5F5), + ), + ), + ], + ), + ), + ); + } + + Widget _buildTypingIndicator() { + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFF241B3B), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFBA68C8).withValues(alpha: 0.3)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 1.8, + valueColor: AlwaysStoppedAnimation(Color(0xFFE1BEE7)), + ), + ), + SizedBox(width: 8), + Text( + 'Оракул внимает и толкует знаки...', + style: TextStyle(fontSize: 12, color: Color(0xFFE1BEE7), fontStyle: FontStyle.italic), + ), + ], + ), + ), + ); + } + + void _showWordDetails(SessionWord word) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Row( + children: [ + const GradientStar(size: 20), + const SizedBox(width: 8), + Text( + '#${word.formattedSessionNumber} ${word.word.toUpperCase()}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${word.partOfSpeech} • ${word.category}', + style: TextStyle(color: Theme.of(ctx).colorScheme.outline), + ), + const SizedBox(height: 12), + Text( + word.symbolicMeaning, + style: const TextStyle(fontSize: 14, height: 1.45), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Закрыть'), + ), + ], + ), + ); + } +} + +/// Modal Bottom Sheet for rolling or typing an additional d10 word +class _AddWordBottomSheet extends StatefulWidget { + final ValueChanged onWordChosen; + + const _AddWordBottomSheet({required this.onWordChosen}); + + @override + State<_AddWordBottomSheet> createState() => _AddWordBottomSheetState(); +} + +class _AddWordBottomSheetState extends State<_AddWordBottomSheet> { + final _repo = WordsRepository(); + String _digits = ''; + + void _onRollCompleted(int finalNumber) { + final word = _repo.getWordBySessionNumber(finalNumber); + if (word != null) { + widget.onWordChosen(word); + } + } + + void _onKeypadTap(String val) { + HapticFeedback.lightImpact(); + + if (val == 'C') { + setState(() => _digits = ''); + return; + } + if (val == '⌫') { + if (_digits.isNotEmpty) { + setState(() => _digits = _digits.substring(0, _digits.length - 1)); + } + return; + } + + String newDigits = _digits.length >= 3 ? val : _digits + val; + setState(() => _digits = newDigits); + + if (newDigits.length == 3) { + final num = int.tryParse(newDigits); + if (num != null && num <= 999) { + final word = _repo.getWordBySessionNumber(num); + if (word != null) { + widget.onWordChosen(word); + } + } + } + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF141728) : Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + border: Border.all(color: const Color(0xFFFFD54F).withValues(alpha: 0.35)), + ), + child: SafeArea( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: const Color(0xFFFFD54F).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + const Row( + children: [ + GradientStar(size: 20), + SizedBox(width: 8), + Text( + 'Докинуть уточняющий знак (3× d10)', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w900), + ), + ], + ), + const SizedBox(height: 12), + D10DiceRollerWidget( + digits: _digits, + isRolling: false, + onRollPressed: () {}, + onRollCompleted: _onRollCompleted, + ), + const SizedBox(height: 12), + // Keypad for 3 digits + _buildKeypadRow(['1', '2', '3']), + const SizedBox(height: 6), + _buildKeypadRow(['4', '5', '6']), + const SizedBox(height: 6), + _buildKeypadRow(['7', '8', '9']), + const SizedBox(height: 6), + _buildKeypadRow(['C', '0', '⌫']), ], ), ), ), ); } + + Widget _buildKeypadRow(List keys) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Row( + children: keys.map((key) { + final isSpecial = key == 'C' || key == '⌫'; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Material( + color: isSpecial ? const Color(0xFF2A2D4A) : (isDark ? const Color(0xFF20233A) : Colors.black12), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () => _onKeypadTap(key), + borderRadius: BorderRadius.circular(12), + child: Container( + height: 42, + alignment: Alignment.center, + child: Text( + key, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: isSpecial ? const Color(0xFFFFF9C4) : Colors.white, + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ); + } } diff --git a/lib/services/ai_oracle_service.dart b/lib/services/ai_oracle_service.dart index 1f0e482..ef8d0e9 100644 --- a/lib/services/ai_oracle_service.dart +++ b/lib/services/ai_oracle_service.dart @@ -52,6 +52,61 @@ class AiOracleService { return _generateOfflineSessionInterpretation(words, question); } + /// Multi-turn chat with the Oracle inside a session, supporting context refinement and additional words + Future chatWithOracle({ + required OracleSession session, + required String userMessage, + SessionWord? newlyAddedWord, + }) async { + final wordsNarrative = StringBuffer(); + for (int i = 0; i < session.words.length; i++) { + final w = session.words[i]; + wordsNarrative.writeln('${i + 1}. Знак #${w.formattedSessionNumber} «${w.word.toUpperCase()}» (${w.partOfSpeech}): ${w.symbolicMeaning}'); + } + + final chatHistoryBuf = StringBuffer(); + for (final msg in session.messages.take(8)) { + if (msg.sender == 'user') { + chatHistoryBuf.writeln('Вопрошающий: ${msg.text}'); + } else if (msg.sender == 'oracle') { + chatHistoryBuf.writeln('Оракул: ${msg.text}'); + } + } + + final addedWordContext = newlyAddedWord != null + ? '\n[ВАЖНО: Вопрошающий только что вытянул ДОПОЛНИТЕЛЬНЫЙ ЗНАК для уточнения деталей: #${newlyAddedWord.formattedSessionNumber} «${newlyAddedWord.word.toUpperCase()}» (${newlyAddedWord.symbolicMeaning}). Обязательно вплети этот новый знак в ответ!]\n' + : ''; + + final prompt = '''Ты — Священный Оракул Клеромантии 999. Ты ведешь глубокий диалог с вопрошающим в рамках текущей сессии гадания. + +Контекст сессии: +Вопрос: «${session.question ?? 'Общий расклад'}» +Выпавшие знаки жребия: +$wordsNarrative +$addedWordContext +Предыдущий диалог: +$chatHistoryBuf + +Новая реплика/вопрос вопрошающего: +«$userMessage» + +Инструкция: +Ответь на реплику вопрошающего прямо, мудро и ёмко (2-4 предложения). +Опирайся на символизм выпавших знаков (особенно дополнительных, если они появились). Раскрой скрытые детали и дай практичный духовный совет.'''; + + try { + final res = await _callPollinationsOpenAi(prompt); + if (res != null && res.trim().isNotEmpty) return res.trim(); + } catch (_) {} + + try { + final res = await _callPollinationsDirect(prompt); + if (res != null && res.trim().isNotEmpty) return res.trim(); + } catch (_) {} + + return 'Оракул внимает вашим словам. ${newlyAddedWord != null ? "Дополнительный знак «${newlyAddedWord.word}» уточняет скрытые детали: ${newlyAddedWord.symbolicMeaning}. " : ""}Слушайте голос интуиции и действуйте в согласии с открывшейся истиной.'; + } + /// Single word legacy interpretation helper Future interpret({ required WordItem word,