1350 lines
41 KiB
Dart
1350 lines
41 KiB
Dart
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';
|
||
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;
|
||
|
||
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<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();
|
||
_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: <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;
|
||
_textController.clear();
|
||
_attachedImage = null;
|
||
_recordedAudioPath = null;
|
||
_recordDuration = Duration.zero;
|
||
});
|
||
|
||
Future.delayed(const Duration(seconds: 3), () {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _startRecording() async {
|
||
try {
|
||
debugPrint('🎤 Starting recording...');
|
||
|
||
try {
|
||
debugPrint('📳 Attempting vibration...');
|
||
|
||
bool? hasVibrator = await Vibration.hasVibrator();
|
||
debugPrint('🔍 Device has vibrator: $hasVibrator');
|
||
|
||
if (hasVibrator == true) {
|
||
await Vibration.vibrate(duration: 100);
|
||
debugPrint('✅ Vibration pattern done');
|
||
}
|
||
|
||
await HapticFeedback.heavyImpact();
|
||
debugPrint('✅ Heavy impact done');
|
||
|
||
} catch (e) {
|
||
debugPrint('❌ Vibration error: $e');
|
||
}
|
||
|
||
final permission = await Permission.microphone.request();
|
||
debugPrint('🎙️ Microphone permission status: $permission');
|
||
|
||
if (permission != PermissionStatus.granted) {
|
||
debugPrint('❌ Microphone permission denied');
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
'Microphone permission is required to record audio.')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (_audioRecorder == null) {
|
||
debugPrint('🔧 Initializing recorder...');
|
||
await _initRecorder();
|
||
}
|
||
|
||
final Directory appDocumentsDir =
|
||
await getApplicationDocumentsDirectory();
|
||
final String filePath =
|
||
'${appDocumentsDir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.aac';
|
||
debugPrint('📁 Recording to: $filePath');
|
||
|
||
await _audioRecorder!.startRecorder(
|
||
toFile: filePath,
|
||
codec: Codec.aacADTS,
|
||
);
|
||
|
||
debugPrint('✅ Recording started successfully');
|
||
setState(() {
|
||
_isRecording = true;
|
||
_recordDuration = Duration.zero;
|
||
});
|
||
|
||
_startRecordTimer();
|
||
} catch (e) {
|
||
debugPrint('❌ Error starting recording: $e');
|
||
}
|
||
}
|
||
|
||
Future<void> _stopRecording() async {
|
||
try {
|
||
debugPrint('🛑 Stopping recording...');
|
||
|
||
try {
|
||
debugPrint('📳 Attempting stop vibration...');
|
||
|
||
bool? hasVibrator = await Vibration.hasVibrator();
|
||
debugPrint('🔍 Device has vibrator: $hasVibrator');
|
||
|
||
if (hasVibrator == true) {
|
||
await Vibration.vibrate(duration: 50);
|
||
await Future.delayed(Duration(milliseconds: 100));
|
||
await Vibration.vibrate(duration: 50);
|
||
debugPrint('✅ Double vibration done');
|
||
}
|
||
|
||
await HapticFeedback.lightImpact();
|
||
debugPrint('✅ Light impact done');
|
||
|
||
} catch (e) {
|
||
debugPrint('❌ Stop vibration error: $e');
|
||
}
|
||
|
||
_recordingTimer?.cancel();
|
||
final path = await _audioRecorder!.stopRecorder();
|
||
debugPrint('✅ Recording stopped. Path: $path');
|
||
|
||
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<void> _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<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: 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<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
|
||
? _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<Color>(
|
||
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<String> 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<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: 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: (_) {
|
||
debugPrint('🔴 Long press started');
|
||
_startRecording();
|
||
},
|
||
onLongPressEnd: (_) {
|
||
debugPrint('🔴 Long press ended');
|
||
_stopRecording();
|
||
},
|
||
onTap: () async {
|
||
debugPrint('👆 Mic tapped for test');
|
||
try {
|
||
bool? hasVibrator = await Vibration.hasVibrator();
|
||
debugPrint('🔍 Device vibration support: $hasVibrator');
|
||
if (hasVibrator == true) {
|
||
await Vibration.vibrate(duration: 200);
|
||
debugPrint('✅ Test vibration completed');
|
||
}
|
||
await HapticFeedback.heavyImpact();
|
||
debugPrint('✅ Test haptic completed');
|
||
} catch (e) {
|
||
debugPrint('❌ Test vibration error: $e');
|
||
}
|
||
},
|
||
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<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: [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<RecordingPulseAnimation>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _controller;
|
||
late Animation<double> _animation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_controller = AnimationController(
|
||
duration: const Duration(seconds: 1),
|
||
vsync: this,
|
||
)..repeat(reverse: true);
|
||
|
||
_animation = Tween<double>(
|
||
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),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
} |