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.
451 lines
16 KiB
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)),
|
|
);
|
|
}
|
|
}
|