feat: interactive oracle session chat and dynamic draw additional d10 cards for context refinement
This commit is contained in:
@@ -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<SessionResultScreen> {
|
||||
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<void> _fetchAiInterpretation() async {
|
||||
setState(() => _isLoadingAi = true);
|
||||
HapticFeedback.mediumImpact();
|
||||
@override
|
||||
void dispose() {
|
||||
_chatInputController.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _fetchInitialAi() async {
|
||||
setState(() => _isGeneratingInitialAi = true);
|
||||
|
||||
try {
|
||||
final result = await _aiService.interpretSession(
|
||||
@@ -43,26 +58,138 @@ class _SessionResultScreenState extends State<SessionResultScreen> {
|
||||
);
|
||||
|
||||
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<void> _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<SessionResultScreen> {
|
||||
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<SessionResultScreen> {
|
||||
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<SessionResultScreen> {
|
||||
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<SessionResultScreen> {
|
||||
],
|
||||
),
|
||||
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>(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>(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<WordItem> 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<String> 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user