diff --git a/assets/icons/send.svg b/assets/icons/send.svg new file mode 100644 index 0000000..6c076f1 --- /dev/null +++ b/assets/icons/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/lib/gen/assets.gen.dart b/lib/gen/assets.gen.dart index 4b1d5bf..c1f7bfb 100644 --- a/lib/gen/assets.gen.dart +++ b/lib/gen/assets.gen.dart @@ -420,6 +420,9 @@ class $AssetsIconsGen { SvgGenImage get selectedList => const SvgGenImage('assets/icons/selected list.svg'); + /// File path: assets/icons/send.svg + SvgGenImage get send => const SvgGenImage('assets/icons/send.svg'); + /// File path: assets/icons/shield.svg SvgGenImage get shield => const SvgGenImage('assets/icons/shield.svg'); @@ -601,6 +604,7 @@ class $AssetsIconsGen { routing, searchNormal, selectedList, + send, shield, shoppingCart, slide2, diff --git a/lib/screens/product/item.dart b/lib/screens/product/item.dart index 9c58757..21c5d44 100644 --- a/lib/screens/product/item.dart +++ b/lib/screens/product/item.dart @@ -5,6 +5,8 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:lba/gen/assets.gen.dart'; import 'package:lba/res/colors.dart'; import 'package:lba/widgets/buildWarpedInfo.dart'; +import 'package:lba/widgets/compact_review_input_widget.dart'; +import 'package:lba/widgets/leave_review_widget.dart'; import 'package:lba/widgets/orderType.dart'; import 'package:lba/widgets/price_reserve_widget.dart'; import 'package:lba/widgets/rate.dart'; @@ -156,8 +158,10 @@ class _ItemState extends State with TickerProviderStateMixin { alignment: Alignment.centerLeft, child: Text( widget.title, - style: - const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), ), ), 0, @@ -213,13 +217,21 @@ class _ItemState extends State with TickerProviderStateMixin { ), const SizedBox(height: 15), _buildAnimatedWidget( - buildWrappedInfo("Dimensions:", widget.dimensions), 4), + buildWrappedInfo("Dimensions:", widget.dimensions), + 4, + ), _buildAnimatedWidget(buildWrappedInfo("Colour:", widget.colour), 5), _buildAnimatedWidget( - buildWrappedInfo("Material:", widget.material), 6), + buildWrappedInfo("Material:", widget.material), + 6, + ), _buildAnimatedWidget( - buildWrappedInfo("Description:", widget.description), 7), - const SizedBox(height: 30), + buildWrappedInfo("Description:", widget.description), + 7, + ), + const SizedBox(height: 12), + const ReviewComposerWidget(), + const SizedBox(height: 15), const Center( child: Text( "Top reviews from the United Arab Emirates", @@ -238,8 +250,9 @@ class _ItemState extends State with TickerProviderStateMixin { position: Tween( begin: const Offset(-1, 0), end: Offset.zero, - ).animate(CurvedAnimation( - parent: animation, curve: Curves.easeInOut)), + ).animate( + CurvedAnimation(parent: animation, curve: Curves.easeInOut), + ), child: SizeTransition( sizeFactor: animation, axisAlignment: -1, @@ -258,17 +271,20 @@ class _ItemState extends State with TickerProviderStateMixin { review['userLiked'] = null; } else { if (review['userLiked'] == false) { - review['noCount'] = (review['noCount'] as int) - 1; + review['noCount'] = + (review['noCount'] as int) - 1; } review['userLiked'] = true; - review['yesCount'] = (review['yesCount'] as int) + 1; + review['yesCount'] = + (review['yesCount'] as int) + 1; } } else { if (review['userLiked'] == false) { review['userLiked'] = null; } else { if (review['userLiked'] == true) { - review['yesCount'] = (review['yesCount'] as int) - 1; + review['yesCount'] = + (review['yesCount'] as int) - 1; } review['userLiked'] = false; review['noCount'] = (review['noCount'] as int) + 1; @@ -309,10 +325,10 @@ class _ItemState extends State with TickerProviderStateMixin { ), ], ), - const SizedBox(height: 7), - const PriceReserveWidget(), + const SizedBox(height: 7), + const PriceReserveWidget(), ], ), ); } -} \ No newline at end of file +} diff --git a/lib/widgets/compact_review_input_widget.dart b/lib/widgets/compact_review_input_widget.dart new file mode 100644 index 0000000..e29f911 --- /dev/null +++ b/lib/widgets/compact_review_input_widget.dart @@ -0,0 +1,604 @@ +import 'dart:io'; +import 'package:flutter/material.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'; + +class ReviewComposerWidget extends StatefulWidget { + const ReviewComposerWidget({super.key}); + + @override + State createState() => _ReviewComposerWidgetState(); +} + +class _ReviewComposerWidgetState extends State + with TickerProviderStateMixin { + double _rating = 0; + final TextEditingController _reviewController = TextEditingController(); + XFile? _image; + final FocusNode _focusNode = FocusNode(); + bool _isFocused = false; + + late AnimationController _focusController; + late AnimationController _buttonController; + late AnimationController _starController; + late AnimationController _gradientController; + late Animation _gradientAnimation; + + late List _starControllers; + late List> _starAnimations; + + @override + void initState() { + super.initState(); + _focusController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _buttonController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + _starController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _gradientController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _gradientAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: _gradientController, curve: Curves.easeInOut), + ); + + _starControllers = List.generate( + 5, + (index) => AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ), + ); + + _starAnimations = + _starControllers + .map( + (controller) => Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: controller, curve: Curves.elasticOut), + ), + ) + .toList(); + + _focusNode.addListener(() { + setState(() { + _isFocused = _focusNode.hasFocus; + if (_isFocused) { + _focusController.forward(); + } else { + _focusController.reverse(); + } + }); + }); + + _reviewController.addListener(() { + if (_reviewController.text.isNotEmpty) { + _triggerGradientAnimation(); + } + + if (_reviewController.text.isNotEmpty || _image != null) { + _buttonController.forward(); + } else { + _buttonController.reverse(); + } + }); + } + + void _triggerGradientAnimation() { + if (!_gradientController.isAnimating) { + _gradientController.forward().then((_) { + _gradientController.reverse(); + }); + } + } + + Future _animateStarsSequentially(int targetRating) async { + for (var controller in _starControllers) { + controller.reset(); + } + + for (int i = 0; i < targetRating; i++) { + if (i > 0) { + await Future.delayed(Duration(milliseconds: 120)); + } + + _starControllers[i].forward().then((_) { + Future.delayed(Duration(milliseconds: 100), () { + if (mounted) { + _starControllers[i].reverse().then((_) { + _starControllers[i].forward(); + }); + } + }); + }); + } + } + + @override + void dispose() { + _reviewController.dispose(); + _focusNode.dispose(); + _focusController.dispose(); + _buttonController.dispose(); + _starController.dispose(); + _gradientController.dispose(); + + for (var controller in _starControllers) { + controller.dispose(); + } + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _gradientAnimation, + builder: (context, child) { + return Transform.scale( + scale: 1.0 + (_gradientAnimation.value * 0.005), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + margin: const EdgeInsets.symmetric(vertical: 12.0), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.primary.withOpacity( + 0.05 + (_gradientAnimation.value * 0.03), + ), + AppColors.borderPrimary.withOpacity( + 0.03 + (_gradientAnimation.value * 0.02), + ), + AppColors.buttonPrimary.withOpacity( + 0.04 + (_gradientAnimation.value * 0.02), + ), + AppColors.surface, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + stops: [ + 0.0, + 0.3 + (_gradientAnimation.value * 0.2), + 0.7 + (_gradientAnimation.value * 0.1), + 1.0, + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + _isFocused + ? AppColors.primary.withOpacity( + 0.6 + (_gradientAnimation.value * 0.2), + ) + : AppColors.divider.withOpacity( + 0.3 + (_gradientAnimation.value * 0.1), + ), + width: _isFocused ? 1.5 : 1, + ), + boxShadow: [ + if (_isFocused) + BoxShadow( + color: AppColors.primary.withOpacity( + 0.15 + (_gradientAnimation.value * 0.05), + ), + blurRadius: 12 + (_gradientAnimation.value * 4), + spreadRadius: 1 + (_gradientAnimation.value * 0.5), + offset: const Offset(0, 4), + ) + else + BoxShadow( + color: Colors.black.withOpacity( + 0.03 + (_gradientAnimation.value * 0.02), + ), + blurRadius: 4 + (_gradientAnimation.value * 2), + spreadRadius: 0, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 10), + if (_image != null) _buildImagePreview(), + _buildInputSection(), + const SizedBox(height: 8), + _buildActionBar(), + ], + ), + ), + ); + }, + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _rating > 0 ? 'Thanks for rating!' : 'Rate Your Experience', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + color: + _rating > 0 ? AppColors.primary : AppColors.textPrimary, + ), + ), + if (_rating > 0) + Text( + _getRatingText(_rating), + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Expanded(flex: 2, child: _buildAnimatedStarRating()), + ], + ); + } + + Widget _buildInputSection() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: + _isFocused + ? AppColors.surface.withOpacity(0.7) + : AppColors.surface.withOpacity(0.4), + borderRadius: BorderRadius.circular(12), + border: + _isFocused + ? Border.all(color: AppColors.primary.withOpacity(0.25)) + : null, + ), + child: TextField( + controller: _reviewController, + focusNode: _focusNode, + maxLines: null, + minLines: 2, + keyboardType: TextInputType.multiline, + style: TextStyle( + fontSize: 14, + height: 1.4, + color: AppColors.textPrimary, + ), + decoration: InputDecoration( + hintText: _getHintText(), + hintStyle: TextStyle( + color: AppColors.textSecondary.withOpacity(0.6), + fontSize: 14, + height: 1.4, + ), + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + ), + ); + } + + Widget _buildActionBar() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [_buildImageButton(), _buildSubmitButton()], + ); + } + + Widget _buildImageButton() { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: InkWell( + onTap: _pickImage, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.primary.withOpacity(0.15)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + Assets.icons.galleryAdd.path, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + AppColors.primary, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 6), + Text( + 'Photo', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ); + } + + Future _pickImage() async { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage(source: ImageSource.gallery); + if (image != null) { + setState(() => _image = image); + _buttonController.forward(); + } + } + + void _submit() { + if (_reviewController.text.isEmpty && _image == null) return; + print( + 'Rating: $_rating, Review: ${_reviewController.text}, Image: ${_image?.path}', + ); + setState(() { + _rating = 0; + _reviewController.clear(); + _image = null; + }); + FocusScope.of(context).unfocus(); + } + + Widget _buildSubmitButton() { + return ScaleTransition( + scale: CurvedAnimation( + parent: _buttonController, + curve: Curves.elasticOut, + ), + child: FadeTransition( + opacity: _buttonController, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.primary, AppColors.primary.withOpacity(0.8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.25), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _submit, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + Assets.icons.send.path, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + AppColors.surface, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 6), + Text( + 'Post', + style: TextStyle( + color: AppColors.surface, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + String _getRatingText(double rating) { + if (rating >= 4.5) return 'Excellent! 🎉'; + if (rating >= 3.5) return 'Great! 👍'; + if (rating >= 2.5) return 'Good 👌'; + if (rating >= 1.5) return 'Fair 😐'; + return 'Could be better 😔'; + } + + String _getHintText() { + if (_rating >= 4.5) { + return 'Tell others what made this experience amazing...'; + } else if (_rating >= 3.5) { + return 'Share what you liked about this experience...'; + } else if (_rating >= 2.5) { + return 'Share your thoughts about this experience...'; + } else if (_rating >= 1.5) { + return 'Help us understand what could be improved...'; + } else { + return 'Share your honest feedback to help others...'; + } + } + + Widget _buildAnimatedStarRating() { + return SizedBox( + width: 120, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: List.generate(5, (index) { + return AnimatedBuilder( + animation: _starAnimations[index], + builder: (context, child) { + final isActive = index < _rating; + final animationValue = _starAnimations[index].value; + final scaleValue = + isActive ? (1.0 + (animationValue * 0.1)) : 1.0; + + return Transform.scale( + scale: scaleValue, + child: InkWell( + onTap: () { + setState(() => _rating = index + 1.0); + _animateStarsSequentially(index + 1); + _starController.forward().then( + (_) => _starController.reverse(), + ); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + width: 20, + height: 20, + child: Stack( + alignment: Alignment.center, + children: [ + if (isActive && animationValue > 0.5) + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.green.withOpacity(0.3), + blurRadius: 3, + spreadRadius: 1, + ), + ], + ), + ), + + SvgPicture.asset( + isActive + ? Assets.icons.starFill.path + : Assets.icons.star.path, + width: 13, + height: 13, + // colorFilter: ColorFilter.mode( + // isActive + // ? AppColors.primary + // : AppColors.textSecondary.withOpacity(0.4), + // BlendMode.srcIn, + // ), + ), + ], + ), + ), + ), + ); + }, + ); + }), + ), + ); + } + + Widget _buildImagePreview() { + return TweenAnimationBuilder( + duration: const Duration(milliseconds: 400), + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.elasticOut, + builder: (context, value, child) { + return Transform.scale( + scale: value, + child: Container( + margin: const EdgeInsets.only(bottom: 10.0), + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primary.withOpacity(0.15), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withOpacity(0.08), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file( + File(_image!.path), + width: 60, + height: 60, + fit: BoxFit.cover, + ), + ), + Positioned( + top: -1, + right: -1, + child: GestureDetector( + onTap: () => setState(() => _image = null), + child: Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.85), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.2), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: 12, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/leave_review_widget.dart b/lib/widgets/leave_review_widget.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/widgets/price_reserve_widget.dart b/lib/widgets/price_reserve_widget.dart index 0887f92..0ec2c63 100644 --- a/lib/widgets/price_reserve_widget.dart +++ b/lib/widgets/price_reserve_widget.dart @@ -23,7 +23,10 @@ class PriceReserveWidget extends StatelessWidget { Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 3, + ), decoration: BoxDecoration( color: AppColors.offerTimer, borderRadius: BorderRadius.circular(4), @@ -80,7 +83,7 @@ class PriceReserveWidget extends StatelessWidget { const SizedBox(width: 16), SizedBox( - width: 200, + width: 150, height: 50, child: ElevatedButton( onPressed: () { @@ -96,7 +99,10 @@ class PriceReserveWidget extends StatelessWidget { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), ), child: Text( 'Reserve', @@ -112,4 +118,4 @@ class PriceReserveWidget extends StatelessWidget { ), ); } -} \ No newline at end of file +}