import 'dart:async'; import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:lba/gen/assets.gen.dart'; import 'package:lba/res/colors.dart'; import 'package:flutter_sound/flutter_sound.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.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; XFile? _attachedImage; FlutterSoundRecorder? _audioRecorder; final AudioPlayer _audioPlayer = AudioPlayer(); bool _isRecording = false; bool _isPlaying = false; String? _recordedAudioPath; Duration _recordDuration = Duration.zero; Duration _playDuration = Duration.zero; Duration _totalDuration = 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(); _audioPlayer.dispose(); _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; _textController.clear(); _attachedImage = null; _recordedAudioPath = null; _recordDuration = Duration.zero; }); Future.delayed(const Duration(seconds: 3), () { if (mounted) { setState(() { _isLoading = false; }); } }); } } Future _startRecording() async { try { HapticFeedback.mediumImpact(); final permission = await Permission.microphone.request(); if (permission != PermissionStatus.granted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Microphone permission is required to record audio.')), ); return; } if (_audioRecorder == null) { await _initRecorder(); } final Directory appDocumentsDir = await getApplicationDocumentsDirectory(); final String filePath = '${appDocumentsDir.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 { try { HapticFeedback.lightImpact(); _recordingTimer?.cancel(); final path = await _audioRecorder!.stopRecorder(); setState(() { _isRecording = false; _recordedAudioPath = path; }); } catch (e) { debugPrint('Error stopping recording: $e'); } } void _startRecordTimer() { _recordingTimer?.cancel(); _recordingTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (_isRecording && mounted) { setState(() { _recordDuration += const Duration(seconds: 1); }); } else { timer.cancel(); } }); } Future _playRecording() async { if (_recordedAudioPath == null) return; try { if (_isPlaying) { await _audioPlayer.pause(); setState(() { _isPlaying = false; }); } else { await _audioPlayer.play(DeviceFileSource(_recordedAudioPath!)); setState(() { _isPlaying = true; }); _audioPlayer.onDurationChanged.listen((duration) { if (mounted) { setState(() { _totalDuration = duration; }); } }); _audioPlayer.onPositionChanged.listen((position) { if (mounted) { setState(() { _playDuration = position; }); } }); _audioPlayer.onPlayerComplete.listen((_) { if (mounted) { setState(() { _isPlaying = false; _playDuration = Duration.zero; }); } }); } } catch (e) { debugPrint('Error playing recording: $e'); } } void _deleteRecording() { setState(() { _recordedAudioPath = null; _recordDuration = Duration.zero; _playDuration = Duration.zero; _totalDuration = Duration.zero; _isPlaying = false; }); } 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: Colors.white, appBar: AppBar( backgroundColor: Colors.white, elevation: 1, shadowColor: Colors.grey.withOpacity(0.2), surfaceTintColor: Colors.transparent, leading: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.black54), onPressed: () { if (_isLoading) { setState(() { _isLoading = false; }); } else { Navigator.pop(context); } }, ), title: const Text( 'Smart Planner', style: TextStyle( color: Colors.black, fontSize: 18, fontWeight: FontWeight.normal), ), actions: [ IconButton( icon: SvgPicture.asset(Assets.icons.mDSPublicTWButton1.path), 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 ? _buildRecordedAudioPreview() : const SizedBox.shrink(), ), AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { return SizeTransition( sizeFactor: animation, child: FadeTransition(opacity: animation, child: child), ); }, child: _isRecording ? _buildRecordingIndicator() : const SizedBox.shrink(), ), SlideTransition( position: _textInputAnimation, child: _buildTextInputArea(), ), ], ), ); } 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: const Color(0xFFEFF7FE), 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)), ), const Text( "\"Scanning the best deals just for you...\"", textAlign: TextAlign.center, style: TextStyle( fontSize: 15, color: Color(0xFF37474F), 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: const Color(0xFFEFF7FE), borderRadius: BorderRadius.circular(16), border: Border.all( color: LightAppColors.primary.withOpacity(0.3)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container( width: 32, height: 32, decoration: const BoxDecoration( color: LightAppColors.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: Colors.grey[700], ), ), Text( _formatDuration(_recordDuration), style: TextStyle( fontSize: 10, color: Colors.grey[600], ), ), ], ), ], ), ), if (hasText) Flexible( child: Container( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), decoration: BoxDecoration( color: const Color(0xFFEFF7FE), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade300), ), child: Text( "\"$_sentText\"", style: const TextStyle( fontSize: 15, color: Color(0xFF37474F), height: 1.4, ), ), ), ), ], ); } else { return Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Flexible( child: IntrinsicHeight( child: Container( decoration: BoxDecoration( color: const Color(0xFFEFF7FE), borderRadius: BorderRadius.circular(16), border: Border.all(color: Colors.grey.shade300), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Padding( padding: const EdgeInsets.symmetric( vertical: 6, horizontal: 16), child: Text( "\"$_sentText\"", style: const TextStyle( fontSize: 15, color: Color(0xFF37474F), height: 1.4, ), ), ), ), Container( width: 10, decoration: const BoxDecoration( color: Color(0xFF189CFF), 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: Colors.white.withOpacity(0.6), shape: BoxShape.circle, ), padding: const EdgeInsets.all(4), child: const Icon(Icons.close, color: Colors.black, size: 18), ), ), ), ], ), ), ), ); } Widget _buildRecordedAudioPreview() { return Padding( key: const ValueKey('audio_preview'), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF8F9FA), borderRadius: BorderRadius.circular(16), border: Border.all(color: LightAppColors.primary.withOpacity(0.3)), ), child: Row( children: [ GestureDetector( onTap: _playRecording, child: Container( width: 40, height: 40, decoration: const BoxDecoration( color: LightAppColors.primary, shape: BoxShape.circle, ), child: Icon( _isPlaying ? Icons.pause : Icons.play_arrow, color: Colors.white, size: 20, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ const Icon(Icons.mic, size: 16, color: LightAppColors.primary), const SizedBox(width: 4), Text( 'Voice Message', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: Colors.grey[700], ), ), ], ), const SizedBox(height: 4), Row( children: [ Expanded( child: LinearProgressIndicator( value: _totalDuration.inMilliseconds > 0 ? _playDuration.inMilliseconds / _totalDuration.inMilliseconds : 0.0, backgroundColor: Colors.grey[300], valueColor: const AlwaysStoppedAnimation( LightAppColors.primary), minHeight: 2, ), ), const SizedBox(width: 8), Text( _formatDuration(_playDuration), style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), Text( ' / ${_formatDuration(_recordDuration)}', style: TextStyle( fontSize: 12, color: Colors.grey[600], ), ), ], ), ], ), ), const SizedBox(width: 8), GestureDetector( onTap: _deleteRecording, child: Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( color: Colors.red.withOpacity(0.1), shape: BoxShape.circle, ), child: const Icon( Icons.delete_outline, color: Colors.red, size: 18, ), ), ), ], ), ), ); } Widget _buildRecordingIndicator() { return Padding( key: const ValueKey('recording_indicator'), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), child: Container( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), decoration: BoxDecoration( color: Colors.red.withOpacity(0.1), borderRadius: BorderRadius.circular(20), border: Border.all(color: Colors.red.withOpacity(0.3)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedContainer( duration: const Duration(milliseconds: 500), width: 12, height: 12, decoration: BoxDecoration( color: Colors.red, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.red.withOpacity(0.5), blurRadius: 8, spreadRadius: 2, ), ], ), ), const SizedBox(width: 12), const Icon( Icons.mic, color: Colors.red, size: 20, ), const SizedBox(width: 8), Text( 'Recording... ${_formatDuration(_recordDuration)}', style: const TextStyle( color: Colors.red, fontWeight: FontWeight.w600, fontSize: 16, ), ), const SizedBox(width: 12), const RecordingPulseAnimation(), ], ), ), ); } 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: const TextStyle( fontSize: 15, color: Color(0xFF37474F), height: 1.4, ), ), ), ), ], ), ); } Widget _buildSuggestionArea(List suggestions) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), decoration: BoxDecoration( color: const Color(0xFFF7F7F7), 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: [ const Text( "\"Need help? Here are some ideas you can ask me about!\"", style: TextStyle( fontSize: 15, color: Color(0xFF37474F), 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: LightAppColors.primary, width: 1), ), child: Text( text, style: const TextStyle( color: Color(0xFF546E7A), fontWeight: FontWeight.w500, fontSize: 14), ), ), ); } Widget _buildTextInputArea() { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: Container( padding: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: LightAppColors.nearbyPopup, borderRadius: BorderRadius.circular(35), border: Border.all(color: Colors.grey.shade300), ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ IconButton( key: _attachIconKey, onPressed: _showAttachmentOptions, icon: SvgPicture.asset(Assets.icons.link2.path), ), Expanded( child: TextField( controller: _textController, decoration: const InputDecoration( hintText: "Type your request here and let's plan it!", hintStyle: TextStyle(color: Colors.grey, fontSize: 14), border: InputBorder.none, focusedBorder: InputBorder.none, enabledBorder: InputBorder.none, contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 3), ), onSubmitted: (_) => _sendMessage(), ), ), GestureDetector( onLongPressStart: (_) => _startRecording(), onLongPressEnd: (_) => _stopRecording(), child: AnimatedBuilder( animation: _micPulseAnimation, builder: (context, child) { return Transform.scale( scale: _isRecording ? _micPulseAnimation.value : 1.0, child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _isRecording ? Colors.red : Colors.transparent, shape: BoxShape.circle, boxShadow: _isRecording ? [ BoxShadow( color: Colors.red.withOpacity(0.3), blurRadius: 8, spreadRadius: 2, ), ] : null, ), child: SvgPicture.asset( Assets.icons.microphone2.path, colorFilter: _isRecording ? const ColorFilter.mode( Colors.white, BlendMode.srcIn) : null, ), ), ); }), ), IconButton( icon: SvgPicture.asset(Assets.icons.arrowUp.path), onPressed: _sendMessage, ), ], ), ), ), ); } } 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: [const Color(0xFF189CFF), LightAppColors.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: Colors.white, 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; } class RecordingPulseAnimation extends StatefulWidget { const RecordingPulseAnimation({super.key}); @override _RecordingPulseAnimationState createState() => _RecordingPulseAnimationState(); } class _RecordingPulseAnimationState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(seconds: 1), vsync: this, )..repeat(reverse: true); _animation = Tween( begin: 0.5, end: 1.0, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeInOut, )); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: _animation, builder: (context, child) { return Row( children: List.generate(3, (index) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 1), child: AnimatedContainer( duration: Duration(milliseconds: 200 + (index * 100)), height: 16 * _animation.value, width: 3, decoration: BoxDecoration( color: Colors.red.withOpacity(_animation.value), borderRadius: BorderRadius.circular(2), ), ), ); }), ); }, ); } }