330 lines
11 KiB
Dart
330 lines
11 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';
|
|
|
|
class ChatScreen extends StatefulWidget {
|
|
const ChatScreen({super.key});
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class ChatMessage {
|
|
final String text;
|
|
final bool isAssistant;
|
|
|
|
ChatMessage({required this.text, this.isAssistant = false});
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> with TickerProviderStateMixin {
|
|
final List<ChatMessage> _messages = [];
|
|
final TextEditingController _textController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
bool _isTyping = false;
|
|
late AnimationController _typingController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_typingController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1200),
|
|
)..repeat();
|
|
|
|
Timer(const Duration(seconds: 2), () => _checkAvailableModels());
|
|
}
|
|
|
|
Future<void> _checkAvailableModels() async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('http://89.114.196.110:11434/api/tags'),
|
|
).timeout(const Duration(seconds: 15));
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
print("--- MODELOS DISPONÍVEIS ---");
|
|
if (data['models'] != null) {
|
|
for (var m in data['models']) {
|
|
print("- ${m['name']}");
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
print("Erro ao listar modelos: $e");
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_typingController.dispose();
|
|
_textController.dispose();
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _clearChat() {
|
|
setState(() {
|
|
_messages.clear();
|
|
_isTyping = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _handleSubmitted(String text) async {
|
|
if (text.trim().isEmpty || _isTyping) return;
|
|
|
|
_textController.clear();
|
|
|
|
setState(() {
|
|
_messages.insert(0, ChatMessage(text: text));
|
|
_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:4b',
|
|
'messages': [{'role': 'user', 'content': text}],
|
|
'stream': false,
|
|
}),
|
|
)
|
|
.timeout(const Duration(seconds: 60));
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
final reply = data['message']?['content'] ?? 'Sem resposta.';
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_isTyping = false;
|
|
_messages.insert(0, ChatMessage(text: reply, isAssistant: true));
|
|
});
|
|
}
|
|
} else {
|
|
throw Exception('Erro HTTP ${response.statusCode}: ${response.body}');
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isTyping = false;
|
|
_messages.insert(
|
|
0,
|
|
ChatMessage(text: "Erro: $e", isAssistant: true),
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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),
|
|
),
|
|
),
|
|
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),
|
|
listBullet: const TextStyle(color: Colors.black87),
|
|
),
|
|
)
|
|
: Text(
|
|
message.text,
|
|
style: const TextStyle(color: Colors.white, fontSize: 15, height: 1.4),
|
|
),
|
|
),
|
|
),
|
|
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 ClipRRect(
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.8),
|
|
border: Border(top: BorderSide(color: Colors.black.withOpacity(0.05), width: 0.5)),
|
|
),
|
|
child: SafeArea(
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_sweep_rounded, color: Colors.redAccent),
|
|
onPressed: _isTyping ? null : _clearChat,
|
|
),
|
|
Expanded(
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF1F5F9),
|
|
borderRadius: BorderRadius.circular(30.0),
|
|
border: Border.all(color: Colors.black.withOpacity(0.05)),
|
|
),
|
|
child: TextField(
|
|
controller: _textController,
|
|
enabled: !_isTyping,
|
|
onSubmitted: _handleSubmitted,
|
|
decoration: InputDecoration(
|
|
hintText: _isTyping ? "Aguarde a resposta..." : "Mensagem EPVChat...",
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 14.0),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: _isTyping
|
|
? [Colors.grey, Colors.grey]
|
|
: [const Color(0xFF8ad5c9), const Color(0xFF57a7ed)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
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(
|
|
backgroundColor: Colors.white,
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ListView(
|
|
controller: _scrollController,
|
|
reverse: true,
|
|
children: [
|
|
...(_isTyping ? [_buildTypingIndicator()] : []),
|
|
..._messages.map((msg) => _buildMessage(msg)),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 50, top: 20),
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: [
|
|
Container(
|
|
height: screenWidth * 0.8,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
const Color(0xFF8ad5c9).withOpacity(0.4),
|
|
const Color(0xFF8ad5c9).withOpacity(0.0),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Image.asset(
|
|
'assets/logo.png',
|
|
height: 170,
|
|
fit: BoxFit.contain,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildTextComposer(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|