import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_sound/flutter_sound.dart'; import 'package:flutter_sound/public/flutter_sound_recorder.dart'; import 'package:flutter_svg/svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:lba/gen/assets.gen.dart'; import 'package:lba/res/colors.dart'; import 'package:lba/widgets/chat_text_input_field.dart'; import 'package:lba/widgets/recorded_audio_preview.dart'; import 'package:lba/widgets/recording_indicator.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:vibration/vibration.dart'; class Planner extends StatefulWidget { const Planner({super.key}); @override State createState() => _PlannerState(); } class _PlannerState extends State with TickerProviderStateMixin { final ImagePicker _picker = ImagePicker(); final GlobalKey _attachIconKey = GlobalKey(); final TextEditingController _textController = TextEditingController(); bool _isLoading = false; String _sentText = ""; XFile? _sentImage; String? _sentAudioPath; Duration _sentAudioDuration = Duration.zero; XFile? _attachedImage; FlutterSoundRecorder? _audioRecorder; bool _isRecording = false; String? _recordedAudioPath; Duration _recordDuration = Duration.zero; Timer? _recordingTimer; late AnimationController _micPulseController; late Animation _micPulseAnimation; late AnimationController _pageEnterController; late Animation _staggeredAnimation; late Animation _textInputAnimation; final List suggestions = [ 'running shoes', 'hotel', 'power tools', 'food', 'cafe', 'fashion', 'gift', 'indoor plants', ]; @override void initState() { super.initState(); _initRecorder(); _micPulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1000), )..repeat(reverse: true); _micPulseAnimation = Tween( begin: 1.0, end: 1.4, ).animate(_micPulseController); _pageEnterController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), ); _staggeredAnimation = CurvedAnimation( parent: _pageEnterController, curve: Curves.easeOutCubic, ); _textInputAnimation = Tween( begin: const Offset(0, 1), end: Offset.zero, ).animate( CurvedAnimation( parent: _pageEnterController, curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), ), ); _pageEnterController.forward(); } Future _initRecorder() async { try { _audioRecorder = FlutterSoundRecorder(); await _audioRecorder!.openRecorder(); } catch (e) { debugPrint('Error initializing recorder: $e'); } } @override void dispose() { _textController.dispose(); _audioRecorder?.closeRecorder(); _recordingTimer?.cancel(); _micPulseController.dispose(); _pageEnterController.dispose(); super.dispose(); } void _showAttachmentOptions() { final RenderBox renderBox = _attachIconKey.currentContext!.findRenderObject() as RenderBox; final position = renderBox.localToGlobal(Offset.zero); const double menuWidth = 220.0; const double menuHeight = 120.0; showGeneralDialog( context: context, barrierDismissible: true, barrierLabel: 'Attachment Options', barrierColor: Colors.black.withOpacity(0.1), transitionDuration: const Duration(milliseconds: 200), pageBuilder: (context, anim1, anim2) { return GestureDetector( onTap: () => Navigator.of(context).pop(), child: Material( color: Colors.transparent, child: Stack( children: [ Positioned( left: position.dx + 30, top: position.dy - menuHeight + 5, child: GestureDetector( onTap: () {}, child: PopupMenuWithoutTail( child: SizedBox( width: menuWidth, height: menuHeight, child: _buildAttachmentMenuContent(), ), ), ), ), ], ), ), ); }, transitionBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), child: child, ); }, ); } Widget _buildAttachmentMenuContent() { return Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ ListTile( leading: SvgPicture.asset(Assets.icons.camera2.path, height: 20), title: const Text('Snap a photo', style: TextStyle(fontSize: 16)), onTap: () { Navigator.of(context).pop(); _pickImage(ImageSource.camera); }, dense: true, ), const Divider(thickness: 0.5, height: 1, indent: 55, endIndent: 55), ListTile( leading: SvgPicture.asset(Assets.icons.galleryAdd.path, height: 19), title: const Text('Upload a photo', style: TextStyle(fontSize: 16)), onTap: () { Navigator.of(context).pop(); _pickImage(ImageSource.gallery); }, dense: true, ), ], ); } Future _pickImage(ImageSource source) async { try { final XFile? pickedFile = await _picker.pickImage(source: source); if (pickedFile != null) { setState(() { _attachedImage = pickedFile; }); } } catch (e) { debugPrint("Error picking image: $e"); } } void _removeAttachedImage() { setState(() { _attachedImage = null; }); } void _sendMessage() { final text = _textController.text.trim(); final imageFile = _attachedImage; final audioPath = _recordedAudioPath; if (text.isNotEmpty || imageFile != null || audioPath != null) { setState(() { _isLoading = true; _sentText = text; _sentImage = imageFile; _sentAudioPath = audioPath; _sentAudioDuration = _recordDuration; _textController.clear(); _attachedImage = null; _recordedAudioPath = null; _recordDuration = Duration.zero; }); Future.delayed(const Duration(seconds: 3), () { if (mounted) { setState(() { _isLoading = false; }); } }); } } Future _startRecording() async { final permission = await Permission.microphone.request(); if (permission != PermissionStatus.granted) return; try { if (await Vibration.hasVibrator() ?? false) { Vibration.vibrate(duration: 100); } final Directory tempDir = await getTemporaryDirectory(); final String filePath = '${tempDir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.aac'; await _audioRecorder!.startRecorder( toFile: filePath, codec: Codec.aacADTS, ); setState(() { _isRecording = true; _recordDuration = Duration.zero; }); _startRecordTimer(); } catch (e) { debugPrint('Error starting recording: $e'); } } Future _stopRecording() async { if (!_isRecording) return; try { _recordingTimer?.cancel(); final path = await _audioRecorder!.stopRecorder(); setState(() { _isRecording = false; _recordedAudioPath = path; }); } catch (e) { debugPrint('Error stopping recording: $e'); } } void _startRecordTimer() { _recordingTimer = Timer.periodic(const Duration(seconds: 1), (timer) { setState(() => _recordDuration += const Duration(seconds: 1)); }); } void _deleteRecording() => setState(() { _recordedAudioPath = null; _recordDuration = Duration.zero; }); String _formatDuration(Duration duration) { String twoDigits(int n) => n.toString().padLeft(2, "0"); String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); return "$twoDigitMinutes:$twoDigitSeconds"; } Widget _buildAnimatedSection( Widget child, { required double begin, required double end, }) { return FadeTransition( opacity: CurvedAnimation( parent: _staggeredAnimation, curve: Interval(begin, end), ), child: SlideTransition( position: Tween( begin: const Offset(0.0, 0.5), end: Offset.zero, ).animate( CurvedAnimation( parent: _staggeredAnimation, curve: Interval(begin, end), ), ), child: child, ), ); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColors.scaffoldBackground, appBar: AppBar( backgroundColor: AppColors.scaffoldBackground, elevation: 1, shadowColor: AppColors.divider.withOpacity(0.2), surfaceTintColor: Colors.transparent, title: Text( 'Smart Planner', style: TextStyle( color: AppColors.textPrimary, fontSize: 18, fontWeight: FontWeight.normal, ), ), actions: [ IconButton( icon: SvgPicture.asset( Assets.icons.mDSPublicTWButton1.path, color: AppColors.isDarkMode ? Colors.white : null, ), onPressed: () {}, ), ], ), body: Column( children: [ Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 500), transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: ScaleTransition( scale: Tween( begin: 0.9, end: 1.0, ).animate(animation), child: child, ), ); }, child: _isLoading ? _buildLoadingView() : _buildDefaultView(), ), ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return SizeTransition( sizeFactor: animation, child: FadeTransition(opacity: animation, child: child), ); }, child: _attachedImage != null ? _buildAttachedImagePreview() : const SizedBox.shrink(), ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return SizeTransition( sizeFactor: animation, child: FadeTransition(opacity: animation, child: child), ); }, child: _recordedAudioPath != null ? RecordedAudioPreview( audioPath: _recordedAudioPath!, totalDuration: _recordDuration, onDelete: _deleteRecording, formatDuration: _formatDuration, ) : const SizedBox.shrink(), ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return SizeTransition( sizeFactor: animation, child: FadeTransition(opacity: animation, child: child), ); }, child: _isRecording ? RecordingIndicator( formattedDuration: _formatDuration(_recordDuration), ) : const SizedBox.shrink(), ), SlideTransition( position: _textInputAnimation, child: ChatTextInputField( textController: _textController, onSendMessage: _sendMessage, onShowAttachmentOptions: _showAttachmentOptions, onStartRecording: _startRecording, onStopRecording: _stopRecording, isRecording: _isRecording, micPulseAnimation: _micPulseAnimation, attachIconKey: _attachIconKey, ), ), ], ), ); } Widget _buildDefaultView() { return ListView( key: const ValueKey('default'), padding: const EdgeInsets.all(16.0), children: [ _buildAnimatedSection( _buildChatBubble( icon: Assets.icons.flatColorIconsPlanner.path, text: "\"Hi there! Tell me what you need – I'm here to plan it for you!\" ", backgroundColor: AppColors.nearbyPopup, iconBackgroundColor: Colors.transparent, ), begin: 0.0, end: 0.5, ), const SizedBox(height: 16), _buildAnimatedSection( _buildSuggestionArea(suggestions), begin: 0.2, end: 1.0, ), ], ); } Widget _buildLoadingView() { return Padding( key: const ValueKey('loading'), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), child: Column( children: [ _buildUserRequestBubble(), const Padding( padding: EdgeInsets.symmetric(vertical: 15.0), child: Divider(color: Color.fromARGB(255, 224, 224, 224)), ), Text( "\"Scanning the best deals just for you...\"", textAlign: TextAlign.center, style: TextStyle( fontSize: 15, color: AppColors.hintTitle, height: 1.4, ), ), const SizedBox(height: 12), const SlantedLinesAnimation(isLoading: true), ], ), ); } Widget _buildUserRequestBubble() { bool hasImage = _sentImage != null; bool hasText = _sentText.isNotEmpty; bool hasAudio = _sentAudioPath != null; if (hasImage || hasAudio) { return Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (hasImage) Center( child: Padding( padding: const EdgeInsets.only(top: 0.0, right: 12.0), child: ClipRRect( borderRadius: BorderRadius.circular(12.0), child: Image.file( File(_sentImage!.path), width: 75, height: 75, fit: BoxFit.cover, ), ), ), ), if (hasAudio && !hasImage) Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.nearbyPopup, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.primary.withOpacity(0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 32, height: 32, decoration: BoxDecoration( color: AppColors.primary, shape: BoxShape.circle, ), child: const Icon( Icons.volume_up, color: Colors.white, size: 16, ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( 'Voice Message', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), ), Text( _formatDuration(_sentAudioDuration), style: TextStyle( fontSize: 10, color: AppColors.textSecondary, ), ), ], ), ], ), ), if (hasText) Flexible( child: Container( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 16, ), decoration: BoxDecoration( color: AppColors.nearbyPopup, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.divider), ), child: Text( "\"$_sentText\"", style: TextStyle( fontSize: 15, color: AppColors.hintTitle, height: 1.4, ), ), ), ), ], ); } else { return Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Flexible( child: IntrinsicHeight( child: Container( decoration: BoxDecoration( color: AppColors.nearbyPopup, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.divider), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Padding( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 16, ), child: Text( "\"$_sentText\"", style: TextStyle( fontSize: 15, color: AppColors.hintTitle, height: 1.4, ), ), ), ), Container( width: 10, decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.only( topRight: Radius.circular(16), bottomRight: Radius.circular(16), ), ), ), ], ), ), ), ), ], ); } } Widget _buildAttachedImagePreview() { return Padding( key: const ValueKey('image_preview'), padding: const EdgeInsets.fromLTRB(35, 8, 16, 0), child: Align( alignment: Alignment.centerLeft, child: SizedBox( height: 120, width: 120, child: Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(16.0), child: Image.file( File(_attachedImage!.path), height: 120, width: 120, fit: BoxFit.cover, ), ), Positioned( top: 6, left: 6, child: GestureDetector( onTap: _removeAttachedImage, child: Container( decoration: BoxDecoration( color: AppColors.surface.withOpacity(0.6), shape: BoxShape.circle, ), padding: const EdgeInsets.all(4), child: Icon( Icons.close, color: AppColors.textPrimary, size: 18, ), ), ), ), ], ), ), ), ); } Widget _buildChatBubble({ required String icon, required String text, required Color backgroundColor, required Color iconBackgroundColor, }) { return Container( padding: const EdgeInsets.all(12.0), decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(16), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: iconBackgroundColor, borderRadius: BorderRadius.circular(10), ), child: SvgPicture.asset(icon, height: 24), ), const SizedBox(width: 12), Expanded( child: Padding( padding: const EdgeInsets.only(top: 2.0), child: Text( text, style: TextStyle( fontSize: 15, color: AppColors.hintTitle, height: 1.4, ), ), ), ), ], ), ); } Widget _buildSuggestionArea(List suggestions) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), decoration: BoxDecoration( color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 2.0), child: SvgPicture.asset(Assets.icons.flatColorIconsIdea.path), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( "\"Need help? Here are some ideas you can ask me about!\"", style: TextStyle( fontSize: 15, color: AppColors.hintTitle, height: 1.4, ), ), const SizedBox(height: 16), Wrap( spacing: 10.0, runSpacing: 10.0, alignment: WrapAlignment.start, children: suggestions.asMap().entries.map((entry) { final index = entry.key; final suggestion = entry.value; return FadeTransition( opacity: CurvedAnimation( parent: _staggeredAnimation, curve: Interval(0.4 + (index * 0.05), 1.0), ), child: SlideTransition( position: Tween( begin: const Offset(0.2, 0.0), end: Offset.zero, ).animate( CurvedAnimation( parent: _staggeredAnimation, curve: Interval(0.4 + (index * 0.05), 1.0), ), ), child: _buildSuggestionChip(suggestion), ), ); }).toList(), ), ], ), ), ], ), ); } Widget _buildSuggestionChip(String text) { return GestureDetector( onTap: () { _textController.text = text; _textController.selection = TextSelection.fromPosition( TextPosition(offset: _textController.text.length), ); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.primary, width: 1), ), child: Text( text, style: TextStyle( color: AppColors.textSecondary, fontWeight: FontWeight.w500, fontSize: 14, ), ), ), ); } } class SlantedLinesAnimation extends StatefulWidget { final bool isLoading; const SlantedLinesAnimation({super.key, this.isLoading = false}); @override _SlantedLinesAnimationState createState() => _SlantedLinesAnimationState(); } class _SlantedLinesAnimationState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(seconds: 2), )..repeat(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _controller, builder: (context, child) { return ClipRRect( borderRadius: BorderRadius.circular(4), child: CustomPaint( painter: _SlantedLinesPainter(_controller.value), child: const SizedBox(height: 8, width: 180), ), ); }, ); } } class _SlantedLinesPainter extends CustomPainter { final double progress; _SlantedLinesPainter(this.progress); @override void paint(Canvas canvas, Size size) { const double stripeWidth = 5.0; const double angle = -0.5; final Paint paint = Paint() ..shader = LinearGradient( colors: [AppColors.primary, AppColors.loadingBorder], stops: const [0.5, 0.5], tileMode: TileMode.repeated, transform: GradientRotation(angle), ).createShader( Rect.fromLTWH( -progress * stripeWidth * 4, 0, stripeWidth * 4, size.height, ), ); canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint); } @override bool shouldRepaint(covariant _SlantedLinesPainter oldDelegate) { return oldDelegate.progress != progress; } } class PopupMenuWithoutTail extends StatelessWidget { final Widget child; const PopupMenuWithoutTail({super.key, required this.child}); @override Widget build(BuildContext context) { return CustomPaint( painter: _MenuPainter( color: AppColors.surface, shadowColor: Colors.black.withOpacity(0.15), ), child: child, ); } } class _MenuPainter extends CustomPainter { final Color color; final Color shadowColor; final double radius = 20.0; _MenuPainter({required this.color, required this.shadowColor}); @override void paint(Canvas canvas, Size size) { final paint = Paint() ..color = color ..style = PaintingStyle.fill; final path = Path() ..moveTo(radius, 0) ..lineTo(size.width - radius, 0) ..arcToPoint( Offset(size.width, radius), radius: Radius.circular(radius), ) ..lineTo(size.width, size.height - radius) ..arcToPoint( Offset(size.width - radius, size.height), radius: Radius.circular(radius), ) ..lineTo(0, size.height) ..lineTo(0, radius) ..arcToPoint(Offset(radius, 0), radius: Radius.circular(radius)) ..close(); canvas.drawShadow(path, shadowColor, 10.0, true); canvas.drawPath(path, paint); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; }