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

399 lines
17 KiB
Dart

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:io';
import 'package:audio_waveforms/audio_waveforms.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:test_sa/border_beam/src/beam_style.dart';
import 'package:test_sa/border_beam/src/border_beam.dart';
import 'package:test_sa/controllers/providers/api/user_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 'package:test_sa/thinking_orbs/src/thinking_orb.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: "ATOMS – AI Assistant",
actions: [
IconButton(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CopilotHistoryPage())),
style: const ButtonStyle(tapTargetSize: MaterialTapTargetSize.shrinkWrap),
icon: "copilot_history".toSvgAsset(width: 24, height: 24, color: AppColor.icon2Color(context)),
constraints: const BoxConstraints(),
tooltip: "History",
),
],
),
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 Column(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 24,
mainAxisSize: MainAxisSize.min,
children: [
"atoms_logo".toSvgAsset(width: 54),
Text(
"How Can I help you Today?",
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, userAvatar: Provider.of<UserProvider>(context, listen: false).user?.profilePhotoName),
],
);
},
);
},
),
),
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)),
);
},
),
SafeArea(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 56),
child: Container(
height: 50,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.background(context),
border: Border.all(width: 1, color: Colors.transparent),
borderRadius: BorderRadius.circular(35),
),
child: BorderBeam(
size: BeamSize.pulseOutside,
reserveGlowSpace: false,
borderRadius: 35,
strength: 0.7,
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 anything...",
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, 24.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, 24.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),
)
: "copilot_send_msg".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" : "Assistant", //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),
),
],
),
),
);
}
}