You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
mohemm-flutter-app/lib/ui/chatbot/chatbot_screen.dart

451 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:mohem_flutter_app/api/dashboard_api_client.dart';
import 'package:mohem_flutter_app/classes/colors.dart';
import 'package:mohem_flutter_app/classes/utils.dart';
import 'package:mohem_flutter_app/extensions/widget_extensions.dart';
import 'package:mohem_flutter_app/models/generic_response_model.dart';
import 'package:speech_to_text/speech_recognition_result.dart';
import 'package:speech_to_text/speech_to_text.dart';
enum ChatMode { home, chatActive }
class ChatMessage {
final String text;
final bool isUser;
final DateTime timestamp;
ChatMessage({required this.text, required this.isUser, required this.timestamp});
}
class ChatBotScreen extends StatefulWidget {
const ChatBotScreen({Key? key}) : super(key: key);
@override
State<ChatBotScreen> createState() => _ChatBotScreenState();
}
class _ChatBotScreenState extends State<ChatBotScreen> {
ChatMode _currentMode = ChatMode.home;
final List<ChatMessage> _messages = <ChatMessage>[];
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
AtlasInitiateChatResponse? atlasChatTokenResponse;
bool _isThinking = false;
// Speech to text
final SpeechToText _speechToText = SpeechToText();
bool _speechEnabled = false;
@override
void initState() {
// TODO: implement initState
super.initState();
getChatSession();
_initSpeech();
}
/// Initialize speech to text
void _initSpeech() async {
_speechEnabled = await _speechToText.initialize();
setState(() {});
}
/// Start listening for speech
void _startListening() async {
await _speechToText.listen(onResult: _onSpeechResult);
setState(() {});
}
/// Stop listening for speech
void _stopListening() async {
await _speechToText.stop();
setState(() {});
}
/// Callback when speech is recognized
void _onSpeechResult(SpeechRecognitionResult result) {
setState(() {
_messageController.text = result.recognizedWords;
// Move cursor to end of text
_messageController.selection = TextSelection.fromPosition(TextPosition(offset: _messageController.text.length));
});
}
void getChatSession() async {
try {
GenericResponseModel? res = await DashboardApiClient().getChatBotSession();
if (res != null) {
setState(() {
atlasChatTokenResponse = res.atlasInitiateChatResponse;
});
}
} catch (ex) {
Utils.handleException(ex, context, null);
}
}
@override
void dispose() {
atlasChatTokenResponse = null;
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
void _sendMessage({String? suggestionText}) async {
String text = suggestionText ?? _messageController.text.trim();
if (text.isEmpty || atlasChatTokenResponse == null) return;
try {
setState(() {
_messages.add(ChatMessage(text: text, isUser: true, timestamp: DateTime.now()));
_currentMode = ChatMode.chatActive;
_messageController.clear();
_isThinking = true;
});
_scrollToBottom();
GenericResponseModel? res = await DashboardApiClient().sendChatBotMessage(atlasChatTokenResponse: atlasChatTokenResponse!, atlasChatText: text);
if (res != null) {
res.atlasContinueChatResponseDetails;
if (mounted) {
setState(() {
_isThinking = false;
_messages.add(
ChatMessage(
text: res.atlasContinueChatResponseDetails!.aiResponse!.structuredData!.answer ?? "",
isUser: false,
timestamp: res.atlasContinueChatResponseDetails!.aiResponse!.createdAt ?? DateTime.now(),
),
);
});
}
_scrollToBottom();
}
} catch (ex) {
setState(() {
_isThinking = false;
});
Utils.handleException(ex, context, null);
}
_scrollToBottom();
}
void _scrollToBottom() {
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(_scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
});
}
void _sendSuggestion(String text) {
if (atlasChatTokenResponse == null) {
Utils.showToast("Please wait, initializing chat...");
return;
}
_sendMessage(suggestionText: text);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: MyColors.backgroundColor,
appBar: AppBar(
title: const Text("ChatBot", style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF2B353E))),
backgroundColor: MyColors.backgroundColor,
centerTitle: false,
),
body: Column(children: <Widget>[Expanded(child: _currentMode == ChatMode.home ? _buildHomeMode() : _buildChatMode()), _buildBottomInputBar()]),
);
}
Widget _buildHomeMode() {
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Text("Hello, ask me", textAlign: TextAlign.center, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF2E303A))),
const Text("anything...", textAlign: TextAlign.center, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF2E303A))),
const SizedBox(height: 50),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 12,
children: <Widget>[
_buildSuggestionChip("Nostalgia Perfume", () => _sendSuggestion("Nostalgia Perfume")),
_buildSuggestionChip("Al Nafoura", () => _sendSuggestion("Al Nafoura")),
_buildSuggestionChip("Al Nafoura", () => _sendSuggestion("Al Nafoura")),
_buildSuggestionChip("Al Jadi", () => _sendSuggestion("Al Jadi")),
],
),
],
),
),
);
}
Widget _buildChatMode() {
return ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
itemCount: _messages.length + (_isThinking ? 1 : 0),
itemBuilder: (BuildContext context, int index) {
if (index == _messages.length && _isThinking) {
return _buildThinkingIndicator();
}
ChatMessage message = _messages[index];
return _buildMessageBubble(message);
},
separatorBuilder: (BuildContext context, int index) {
return const SizedBox(height: 15);
},
);
}
Widget _buildThinkingIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: <Color>[Color(0xFF32D892), Color(0xFF259CB8)], begin: Alignment.centerLeft, end: Alignment.centerRight),
borderRadius: BorderRadius.circular(15),
boxShadow: <BoxShadow>[BoxShadow(color: const Color(0xFF000000).withValues(alpha: 0.0784), offset: const Offset(0, 0), blurRadius: 13, spreadRadius: 0)],
),
child: const ThinkingDotsAnimation(),
),
const Spacer(flex: 2),
],
);
}
Widget _buildMessageBubble(ChatMessage message) {
bool isUser = message.isUser;
Widget bubbleContent = Row(
mainAxisAlignment: isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (isUser) const Spacer(flex: 2),
Flexible(
flex: 7,
child: Column(
crossAxisAlignment: isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isUser ? Colors.white : null,
gradient: isUser ? null : const LinearGradient(colors: <Color>[Color(0xFF32D892), Color(0xFF259CB8)], begin: Alignment.centerLeft, end: Alignment.centerRight),
borderRadius: BorderRadius.circular(15),
boxShadow: isUser ? null : <BoxShadow>[BoxShadow(color: const Color(0xFF000000).withValues(alpha: 0.0784), offset: const Offset(0, 0), blurRadius: 13, spreadRadius: 0)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(message.text, softWrap: true, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: isUser ? const Color(0xFF2B353E) : MyColors.white)),
Align(
alignment: Alignment.centerRight,
child: Text(
_formatTimestamp(message.timestamp),
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: -0.4, color: isUser ? const Color(0xFF2B353E) : MyColors.whiteColor),
),
),
],
),
),
],
),
),
if (!isUser) const Spacer(flex: 2),
],
);
if (!isUser) {
return AnimatedMessageBubble(child: bubbleContent);
}
return bubbleContent;
}
String _formatTimestamp(DateTime timestamp) {
String hour = timestamp.hour.toString().padLeft(2, '0');
String minute = timestamp.minute.toString().padLeft(2, '0');
String month = timestamp.month.toString().padLeft(2, '0');
String day = timestamp.day.toString().padLeft(2, '0');
int year = timestamp.year;
return "$month/$day/$year $hour:$minute AM";
}
Widget _buildSuggestionChip(String label, VoidCallback onTap) {
return Material(
color: Colors.white,
elevation: 3,
shadowColor: const Color(0xFF000015).withValues(alpha: 0.0784),
borderRadius: BorderRadius.circular(10),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: MyColors.darkTextColor, letterSpacing: 0.2)),
),
),
);
}
Widget _buildBottomInputBar() {
return Container(
decoration: const BoxDecoration(color: MyColors.backgroundColor),
child: SafeArea(
top: false,
child: Row(
children: <Widget>[
// Plus button
// IconButton(padding: EdgeInsets.zero, icon: const Icon(Icons.add, color: MyColors.darkTextColor, size: 34), onPressed: () {}),
// const SizedBox(width: 12),
// Text field
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
height: 48,
decoration: BoxDecoration(color: Colors.transparent, borderRadius: BorderRadius.circular(10), border: BoxBorder.all(width: 1, color: const Color(0xFFE5E5E5))),
child: Row(
children: <Widget>[
Expanded(
child: TextField(
controller: _messageController,
onSubmitted: _isThinking ? null : (_) => _sendMessage(),
enabled: !_isThinking,
maxLines: 1,
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: _isThinking ? 'Waiting for response...' : 'Type a message..',
hintStyle: const TextStyle(color: MyColors.hintTextColor, fontSize: 14, fontWeight: FontWeight.normal),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
),
),
GestureDetector(
onTap: _isThinking ? null : (_speechEnabled ? (_speechToText.isNotListening ? _startListening : _stopListening) : null),
child: SvgPicture.asset(
"assets/icons/microphone.svg",
colorFilter: ColorFilter.mode(_isThinking ? MyColors.hintTextColor : (_speechToText.isListening ? Colors.red : MyColors.darkTextColor), BlendMode.srcIn),
),
),
],
),
),
),
const SizedBox(width: 6),
// Send button
GestureDetector(
onTap: _isThinking ? null : _sendMessage,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(color: _isThinking ? MyColors.hintTextColor.withValues(alpha: 0.3) : MyColors.white, shape: BoxShape.circle),
child: Center(child: SvgPicture.asset("assets/icons/send.svg", colorFilter: _isThinking ? const ColorFilter.mode(MyColors.hintTextColor, BlendMode.srcIn) : null)),
),
),
],
),
),
).paddingOnly(left: 16, right: 16, bottom: 16);
}
}
class ThinkingDotsAnimation extends StatefulWidget {
const ThinkingDotsAnimation({Key? key}) : super(key: key);
@override
State<ThinkingDotsAnimation> createState() => _ThinkingDotsAnimationState();
}
class _ThinkingDotsAnimationState extends State<ThinkingDotsAnimation> with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
const Text("Thinking", style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white)),
const SizedBox(width: 2),
AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? child) {
int dotCount = (_controller.value * 4).floor() % 4;
String dots = '.' * dotCount;
return SizedBox(width: 18, child: Text(dots, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Colors.white)));
},
),
],
);
}
}
class AnimatedMessageBubble extends StatefulWidget {
final Widget child;
const AnimatedMessageBubble({Key? key, required this.child}) : super(key: key);
@override
State<AnimatedMessageBubble> createState() => _AnimatedMessageBubbleState();
}
class _AnimatedMessageBubbleState extends State<AnimatedMessageBubble> with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 400));
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
_slideAnimation = Tween<Offset>(begin: const Offset(-0.3, 0.0), end: Offset.zero).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
_scaleAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack));
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: _slideAnimation,
child: FadeTransition(opacity: _fadeAnimation, child: ScaleTransition(scale: _scaleAnimation, alignment: Alignment.centerLeft, child: widget.child)),
);
}
}