Files
App_Projeto/lib/chat_screen.dart

443 lines
15 KiB
Dart

import 'dart:ui';
import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'dart:async';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'database_helper.dart';
class ChatScreen extends StatefulWidget {
const ChatScreen({super.key});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
final List<ChatMessage> _messages = [];
final List<ChatSession> _sessions = [];
ChatSession? _currentSession;
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
bool _isTyping = false;
late AnimationController _typingController;
@override
void initState() {
super.initState();
_typingController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
_loadInitialData();
}
Future<void> _loadInitialData() async {
await _loadSessions();
if (_sessions.isNotEmpty) {
await _selectSession(_sessions.first);
} else {
await _createNewSession();
}
}
Future<void> _loadSessions() async {
final sessions = await DatabaseHelper.instance.getSessions();
if (mounted) {
setState(() {
_sessions.clear();
_sessions.addAll(sessions);
});
}
}
Future<void> _selectSession(ChatSession session) async {
final messages = await DatabaseHelper.instance.getMessages(session.id!);
if (mounted) {
setState(() {
_currentSession = session;
_messages.clear();
_messages.addAll(messages.reversed);
_isTyping = false;
});
}
if (_scaffoldKey.currentState?.isDrawerOpen ?? false) {
Navigator.pop(context);
}
}
Future<void> _createNewSession() async {
final title = "Chat ${DateTime.now().hour}:${DateTime.now().minute}";
final id = await DatabaseHelper.instance.createSession(title);
await _loadSessions();
final newSession = _sessions.firstWhere((s) => s.id == id);
await _selectSession(newSession);
}
Future<void> _deleteSession(int id) async {
await DatabaseHelper.instance.deleteSession(id);
await _loadSessions();
if (_currentSession?.id == id) {
if (_sessions.isNotEmpty) {
await _selectSession(_sessions.first);
} else {
await _createNewSession();
}
} else {
setState(() {});
}
}
@override
void dispose() {
_typingController.dispose();
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _handleSubmitted(String text) async {
if (text.trim().isEmpty || _isTyping) return;
// Se por algum motivo não houver sessão, cria uma antes de enviar
if (_currentSession == null) {
await _createNewSession();
}
final userMsgText = text.trim();
_textController.clear();
final userMsg = ChatMessage(
sessionId: _currentSession!.id!,
text: userMsgText,
isAssistant: false,
timestamp: DateTime.now().toIso8601String(),
);
await DatabaseHelper.instance.insertMessage(userMsg);
setState(() {
_messages.insert(0, userMsg);
_isTyping = true;
});
try {
final url = Uri.parse('http://89.114.196.110:11434/api/chat');
final response = await http.post(
url,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'model': 'qwen3-coder:30b',
'messages': [{'role': 'user', 'content': userMsgText}],
'stream': false,
}),
).timeout(const Duration(seconds: 60));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
final replyText = data['message']?['content'] ?? 'Sem resposta.';
final assistantMsg = ChatMessage(
sessionId: _currentSession!.id!,
text: replyText,
isAssistant: true,
timestamp: DateTime.now().toIso8601String(),
);
await DatabaseHelper.instance.insertMessage(assistantMsg);
if (mounted) {
setState(() {
_isTyping = false;
_messages.insert(0, assistantMsg);
});
}
} else {
throw Exception('Erro HTTP ${response.statusCode}');
}
} catch (e) {
if (mounted) {
setState(() {
_isTyping = false;
_messages.insert(0, ChatMessage(
sessionId: _currentSession!.id!,
text: "Erro de ligação. Verifique se o servidor está online.",
isAssistant: true,
timestamp: DateTime.now().toIso8601String(),
));
});
}
}
}
Widget _buildMessage(ChatMessage message) {
bool isAssistant = message.isAssistant;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
child: Row(
mainAxisAlignment: isAssistant ? MainAxisAlignment.start : MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isAssistant) _buildAvatar(Icons.auto_awesome),
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 10.0),
decoration: BoxDecoration(
gradient: isAssistant
? const LinearGradient(colors: [Color(0xFFF1F5F9), Color(0xFFE2E8F0)])
: const LinearGradient(colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)]),
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(isAssistant ? 4 : 20),
bottomRight: Radius.circular(isAssistant ? 20 : 4),
),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 5, offset: const Offset(0, 2)),
],
),
child: isAssistant
? MarkdownBody(
data: message.text,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(color: Colors.black87, fontSize: 15, height: 1.4),
strong: const TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
),
)
: Text(message.text, style: const TextStyle(color: Colors.white, fontSize: 15)),
),
),
if (!isAssistant) _buildAvatar(Icons.person),
],
),
);
}
Widget _buildAvatar(IconData icon) {
return Container(
margin: EdgeInsets.only(left: icon == Icons.person ? 12 : 0, right: icon == Icons.auto_awesome ? 12 : 0),
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)]),
),
child: CircleAvatar(backgroundColor: Colors.transparent, radius: 16, child: Icon(icon, color: Colors.white, size: 18)),
);
}
Widget _buildTypingIndicator() {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
_buildAvatar(Icons.auto_awesome),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: const BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(20)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [_buildAnimatedDot(0), const SizedBox(width: 4), _buildAnimatedDot(1), const SizedBox(width: 4), _buildAnimatedDot(2)],
),
),
],
),
);
}
Widget _buildAnimatedDot(int index) {
return AnimatedBuilder(
animation: _typingController,
builder: (context, child) {
double value = (_typingController.value + (index * 0.15)) % 1.0;
double intensity = 1.0 - (value - 0.5).abs() * 2;
return Opacity(
opacity: 0.3 + (0.7 * intensity),
child: Transform.translate(offset: Offset(0, -3 * intensity), child: Container(width: 6, height: 6, decoration: const BoxDecoration(color: Colors.black54, shape: BoxShape.circle))),
);
},
);
}
Widget _buildTextComposer() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.black.withOpacity(0.05))),
),
child: SafeArea(
child: Row(
children: [
Expanded(
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(30.0),
),
child: TextField(
controller: _textController,
enabled: !_isTyping,
onSubmitted: _handleSubmitted,
decoration: const InputDecoration(
hintText: "Mensagem EPVChat...",
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 24.0, vertical: 14.0),
),
),
),
),
const SizedBox(width: 12),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _isTyping ? [Colors.grey, Colors.grey] : [const Color(0xFF8ad5c9), const Color(0xFF57a7ed)],
),
shape: BoxShape.circle,
),
child: IconButton(
icon: const Icon(Icons.send_rounded, color: Colors.white),
onPressed: _isTyping ? null : () => _handleSubmitted(_textController.text),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
return Scaffold(
key: _scaffoldKey,
drawer: _buildSidebar(),
backgroundColor: Colors.white,
body: Stack(
children: [
// Sombreado Mint
Positioned(
top: -screenWidth * 0.45,
left: -screenWidth * 0.2,
right: -screenWidth * 0.2,
child: Container(
height: screenWidth * 1.1,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: Alignment.center,
radius: 0.5,
colors: [const Color(0xFF8ad5c9).withOpacity(0.6), const Color(0xFF8ad5c9).withOpacity(0.0)],
stops: const [0.2, 1.0],
),
),
),
),
Column(
children: [
// Área do Topo (Menu e Logo)
SafeArea(
child: Container(
height: 100,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.menu_rounded, color: Color(0xFF57a7ed), size: 32),
onPressed: () => _scaffoldKey.currentState?.openDrawer(),
),
Image.asset('assets/logo.png', height: 100, errorBuilder: (c,e,s) => const SizedBox(width: 100)),
const SizedBox(width: 48), // Equilíbrio para o ícone do menu
],
),
),
),
Expanded(
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(vertical: 20),
reverse: true,
itemCount: _messages.length + (_isTyping ? 1 : 0),
itemBuilder: (context, index) {
if (_isTyping && index == 0) return _buildTypingIndicator();
int msgIndex = _isTyping ? index - 1 : index;
return _buildMessage(_messages[msgIndex]);
},
),
),
_buildTextComposer(),
],
),
],
),
);
}
Widget _buildSidebar() {
return Drawer(
width: MediaQuery.of(context).size.width * 0.75,
child: Column(
children: [
DrawerHeader(
decoration: const BoxDecoration(
gradient: LinearGradient(colors: [Color(0xFF8ad5c9), Color(0xFF57a7ed)]),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.auto_awesome, color: Colors.white, size: 40),
const SizedBox(height: 10),
const Text('Histórico de Chats', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
],
),
),
),
ListTile(
leading: const Icon(Icons.add_comment_rounded, color: Color(0xFF57a7ed)),
title: const Text('Nova Conversa', style: TextStyle(fontWeight: FontWeight.bold)),
onTap: _createNewSession,
),
const Divider(),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _sessions.length,
itemBuilder: (context, index) {
final session = _sessions[index];
final isSelected = _currentSession?.id == session.id;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF8ad5c9).withOpacity(0.1) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
title: Text(session.title, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: IconButton(
icon: const Icon(Icons.delete_outline_rounded, color: Colors.redAccent),
onPressed: () => _deleteSession(session.id!),
),
onTap: () => _selectSession(session),
),
);
},
),
),
],
),
);
}
}