proxybuy-flutter/lib/screens/mains/planner/planner.dart

950 lines
28 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Planner> createState() => _PlannerState();
}
class _PlannerState extends State<Planner> 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<double> _micPulseAnimation;
late AnimationController _pageEnterController;
late Animation<double> _staggeredAnimation;
late Animation<Offset> _textInputAnimation;
final List<String> 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<double>(
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<Offset>(
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<void> _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: <Widget>[
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<void> _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<void> _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<void> _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<Offset>(
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<double>(
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<String> 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<Offset>(
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<SlantedLinesAnimation>
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;
}