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 createState() => _CopilotChatPageState(); } class _CopilotChatPageState extends State { 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(); } @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().sendText(text); _scrollToBottom(); } Future _startRecording() async { await _recorderController.checkPermission(); if (!_recorderController.hasPermission) { "Audio permission denied. Please enable from settings".showToast; return; } setState(() => _inputState = _InputState.recording); _recorderController.record(); } Future _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().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: [ 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( 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(context, listen: false).user?.profilePhotoName), ], ); }, ); }, ), ), Consumer( 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( 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" : "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), ), ], ), ), ); } }