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.
cloudsolutions-atoms/lib/modules/copilot/screens/copilot_chat_page.dart

368 lines
15 KiB
Dart

import 'dart:io';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/extensions/context_extension.dart';
import 'package:test_sa/extensions/int_extensions.dart';
import 'package:test_sa/extensions/string_extensions.dart';
import 'package:test_sa/extensions/text_extensions.dart';
import 'package:test_sa/extensions/widget_extensions.dart';
import 'package:test_sa/modules/copilot/screens/copilot_history_page.dart';
import 'package:test_sa/new_views/app_style/app_color.dart';
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
import '../controller/copilot_provider.dart';
import '../widgets/copilot_message_bubble.dart';
enum _InputState { idle, recording, recorded }
class CopilotChatPage extends StatefulWidget {
final bool isNewConversation;
const CopilotChatPage({Key? key, this.isNewConversation = true}) : super(key: key);
@override
State<CopilotChatPage> createState() => _CopilotChatPageState();
}
class _CopilotChatPageState extends State<CopilotChatPage> {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
final RecorderController _recorderController = RecorderController();
final PlayerController _recordedPlayerController = PlayerController();
_InputState _inputState = _InputState.idle;
String? _recordedFilePath;
CopilotProvider? _copilotProvider;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_copilotProvider = context.read<CopilotProvider>();
}
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
_recorderController.dispose();
_recordedPlayerController.dispose();
_copilotProvider?.cancelStreaming();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
);
}
});
}
void _sendText() {
final text = _textController.text.trim();
if (text.isEmpty) return;
_textController.clear();
context.read<CopilotProvider>().sendText(text);
_scrollToBottom();
}
Future<void> _startRecording() async {
await _recorderController.checkPermission();
if (!_recorderController.hasPermission) {
"Audio permission denied. Please enable from settings".showToast;
return;
}
setState(() => _inputState = _InputState.recording);
_recorderController.record();
}
Future<void> _stopRecording() async {
await _recorderController.pause();
_recordedFilePath = await _recorderController.stop();
setState(() => _inputState = _InputState.recorded);
}
void _cancelRecording() async {
await _recordedPlayerController.stopPlayer();
_recorderController.reset();
_recordedFilePath = null;
setState(() => _inputState = _InputState.idle);
}
void _sendRecording() async {
if (_recordedFilePath == null) return;
final file = File(_recordedFilePath!);
context.read<CopilotProvider>().sendAudio(file);
await _recordedPlayerController.stopPlayer();
_recorderController.reset();
_recordedFilePath = null;
setState(() => _inputState = _InputState.idle);
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: DefaultAppBar(title: "Copilot Assistant", actions: [
'history_icon'.toSvgAsset(width: 24, height: 24).onPress((){
Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotHistoryPage())).then((_) => context.read<CopilotProvider>().loadHistory());
}).paddingOnly(end: 12),
],),
body: Column(
children: [
Expanded(
child: Consumer<CopilotProvider>(
builder: (context, provider, _) {
if (provider.conversationLoading) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
_ShimmerBubble(isSender: false),
_ShimmerBubble(isSender: false),
_ShimmerBubble(isSender: true),
_ShimmerBubble(isSender: true),
],
);
}
if (provider.messages.isEmpty) {
return Text(
"Send a message to start conversation",
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: AppTextStyles.heading6.copyWith(color: AppColor.lightTextColor(context), fontWeight: FontWeight.w500),
).center;
}
_scrollToBottom();
final messages = provider.messages;
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: messages.length,
itemBuilder: (context, index) {
final currentMessage = messages[index];
final bool showHeader = index == 0 || currentMessage.role != messages[index - 1].role;
bool showDateHeader = index == 0;
if (!showDateHeader) {
final prevDate = DateUtils.dateOnly(messages[index - 1].timestamp ?? DateTime.now());
final currDate = DateUtils.dateOnly(currentMessage.timestamp ?? DateTime.now());
showDateHeader = !prevDate.isAtSameMomentAs(currDate);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (showDateHeader) _dateCard(currentMessage.timestamp?.toIso8601String().chatMsgDateWithYear ?? ""),
CopilotMessageBubble(message: currentMessage, showHeader: showHeader),
],
);
},
);
},
),
),
Consumer<CopilotProvider>(
builder: (context, provider, _) {
if (provider.errorMessage == null) return const SizedBox();
return Container(
width: double.infinity,
color: Colors.red.withValues(alpha: 0.1),
padding: const EdgeInsets.all(8),
child: Text(provider.errorMessage!, style: const TextStyle(color: Colors.red)),
);
},
),
const Divider(height: 1).defaultStyle(context),
SafeArea(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
child: Row(
children: [
if (_inputState == _InputState.idle) ...[
TextFormField(
controller: _textController,
cursorColor: AppColor.loadingColor(context),
style: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 14, color: AppColor.textStyleColor(context)),
minLines: 1,
maxLines: 3,
textInputAction: TextInputAction.none,
keyboardType: TextInputType.multiline,
decoration: InputDecoration(
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
border: InputBorder.none,
errorBorder: InputBorder.none,
contentPadding: const EdgeInsets.only(left: 16, top: 8, bottom: 8),
alignLabelWithHint: true,
constraints: const BoxConstraints(),
suffixIconConstraints: const BoxConstraints(),
hintText: "Ask Copilot...",
hintStyle: TextStyle(fontWeight: FontWeight.w500, fontSize: context.isTablet() ? 16 : 12, color: AppColor.lightTextColor(context)),
),
).expanded,
IconButton(
onPressed: _startRecording,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: "chat_mic".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
),
] else if (_inputState == _InputState.recording) ...[
AudioWaveforms(
size: Size(MediaQuery.of(context).size.width, 56.0),
waveStyle: const WaveStyle(waveColor: AppColor.neutral50, extendWaveform: true, showMiddleLine: false),
padding: const EdgeInsets.only(left: 16),
recorderController: _recorderController,
).expanded,
IconButton(
onPressed: _stopRecording,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: const Icon(Icons.stop_circle_rounded),
constraints: const BoxConstraints(),
),
] else ...[
if (_recordedPlayerController.playerState == PlayerState.playing)
IconButton(
onPressed: () async {
await _recordedPlayerController.pausePlayer();
await _recordedPlayerController.stopPlayer();
setState(() {});
},
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: const Icon(Icons.stop_circle_outlined, size: 20),
constraints: const BoxConstraints(),
)
else
IconButton(
onPressed: () async {
await _recordedPlayerController.preparePlayer(path: _recordedFilePath!);
await _recordedPlayerController.startPlayer();
setState(() {});
},
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: const Icon(Icons.play_circle_fill_rounded, size: 20),
constraints: const BoxConstraints(),
),
AudioFileWaveforms(
playerController: _recordedPlayerController,
waveformData: _recorderController.waveData,
enableSeekGesture: false,
continuousWaveform: false,
waveformType: WaveformType.long,
playerWaveStyle: const PlayerWaveStyle(
fixedWaveColor: AppColor.neutral50,
liveWaveColor: AppColor.primary10,
showSeekLine: true,
),
size: Size(MediaQuery.of(context).size.width, 56.0),
).expanded,
IconButton(
onPressed: _cancelRecording,
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: "delete_icon".toSvgAsset(width: 24, height: 24),
constraints: const BoxConstraints(),
),
],
if (_inputState != _InputState.recording)
IconButton(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
onPressed: () {
if (_inputState == _InputState.recorded) {
_sendRecording();
} else {
_sendText();
}
},
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: Consumer<CopilotProvider>(
builder: (_, provider, __) => provider.isStreaming
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(color: AppColor.primary10, strokeWidth: 2),
)
: "chat_msg_send".toSvgAsset(width: 24, height: 24),
),
constraints: const BoxConstraints(),
),
8.width,
],
),
),
),
],
),
);
}
Widget _dateCard(String date) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
margin: const EdgeInsets.only(top: 16, bottom: 8),
decoration: BoxDecoration(
color: AppColor.neutral50,
borderRadius: BorderRadius.circular(6),
),
child: Text(date, style: AppTextStyles.bodyText2.copyWith(color: AppColor.white10)))
.center;
}
}
class _ShimmerBubble extends StatelessWidget {
final bool isSender;
const _ShimmerBubble({required this.isSender});
@override
Widget build(BuildContext context) {
final avatar = Container(
height: 26,
width: 26,
decoration: const BoxDecoration(shape: BoxShape.circle, color: Colors.grey),
).toShimmer(context: context, isShow: true);
final name = Text(
isSender ? "Me" : "Copilot",//check the api if they send the user name
style: AppTextStyles.bodyText.copyWith(color: AppColor.neutral50, fontWeight: FontWeight.w600),
).toShimmer(context: context, isShow: true);
final header = Row(
mainAxisSize: MainAxisSize.min,
children: isSender ? [name, 8.width, avatar] : [avatar, 8.width, name],
);
return Align(
alignment: isSender ? Alignment.centerRight : Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
header,
4.height,
Container(
padding: const EdgeInsets.all(8),
margin: EdgeInsets.only(left: isSender ? 26 + 8 : 0, right: isSender ? 0 : 26 + 8),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.6),
child: Text(
"Please let me know what is the issue?",
style: AppTextStyles.bodyText2.copyWith(color: AppColor.neutral120),
).toShimmer(context: context, isShow: true),
),
],
),
),
);
}
}