diff --git a/lib/views/tenders/widgets/recommended_tenders_view.dart b/lib/views/tenders/widgets/recommended_tenders_view.dart index aa18ca4..e1be43b 100644 --- a/lib/views/tenders/widgets/recommended_tenders_view.dart +++ b/lib/views/tenders/widgets/recommended_tenders_view.dart @@ -6,6 +6,7 @@ import 'package:tm_app/view_models/ai_recommendations_view_model.dart'; import '../strings/tenders_strings.dart'; import 'main_tenders_slider.dart'; +import 'tender_card_shimmer.dart'; /// The "Recommended" tab body. Handles loading, empty/"still learning", /// error+retry and the recommended tender list for [AiRecommendationsViewModel]. @@ -34,7 +35,7 @@ class _RecommendedTendersViewState extends State { return Consumer( builder: (context, vm, child) { if (vm.isLoading) { - return const _LoadingSkeleton(); + return _LoadingSkeleton(isDesktop: widget.isDesktop); } if (vm.errorMessage != null) { return _MessageState( @@ -152,23 +153,18 @@ class _MessageState extends StatelessWidget { /// Simple placeholder list shown while recommendations load. class _LoadingSkeleton extends StatelessWidget { - const _LoadingSkeleton(); + const _LoadingSkeleton({required this.isDesktop}); + + final bool isDesktop; @override Widget build(BuildContext context) { return ListView.separated( padding: EdgeInsets.all(16.0.h()), - itemCount: 4, - separatorBuilder: (_, __) => SizedBox(height: 12.0.h()), - itemBuilder: - (context, index) => Container( - height: 120.0.h(), - decoration: BoxDecoration( - color: AppColors.grey0, - borderRadius: BorderRadius.circular(12.0.w()), - border: Border.all(color: AppColors.grey30), - ), - ), + itemCount: isDesktop ? 3 : 2, + separatorBuilder: + (_, __) => SizedBox(height: isDesktop ? 20.0.h() : 52.0.h()), + itemBuilder: (context, index) => TenderCardShimmer(isDesktop: isDesktop), ); } } diff --git a/lib/views/tenders/widgets/tender_card_shimmer.dart b/lib/views/tenders/widgets/tender_card_shimmer.dart new file mode 100644 index 0000000..c26611b --- /dev/null +++ b/lib/views/tenders/widgets/tender_card_shimmer.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:tm_app/core/theme/colors.dart'; +import 'package:tm_app/core/utils/size_config.dart'; + +class TenderCardShimmer extends StatelessWidget { + const TenderCardShimmer({super.key, this.isDesktop = false}); + + final bool isDesktop; + + @override + Widget build(BuildContext context) { + return _Shimmer( + child: Container( + width: double.infinity, + margin: EdgeInsets.symmetric(horizontal: 16.0.w()), + height: isDesktop ? 350.0.h() : 587.0.h(), + padding: EdgeInsets.all(16.0.w()), + decoration: BoxDecoration( + color: AppColors.grey0, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.grey30), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _bar(width: isDesktop ? 120.0.w() : 140.0.w(), height: 14.0.h()), + SizedBox(height: 16.0.h()), + _block(height: isDesktop ? 58.0.h() : 70.0.h()), + SizedBox(height: isDesktop ? 12.0.h() : 20.0.h()), + _bar(width: double.infinity, height: 18.0.h()), + SizedBox(height: 8.0.h()), + _fractionBar(widthFactor: 0.72, height: 18.0.h()), + SizedBox(height: 16.0.h()), + _bar(width: double.infinity, height: 14.0.h()), + SizedBox(height: 8.0.h()), + _bar(width: double.infinity, height: 14.0.h()), + SizedBox(height: 8.0.h()), + _fractionBar(widthFactor: 0.55, height: 14.0.h()), + const Spacer(), + Row( + children: [ + _circle(size: 24.0.h()), + SizedBox(width: 8.0.w()), + _bar(width: 60.0.w(), height: 14.0.h()), + const Spacer(), + _pill(width: 108.0.w(), height: 40.0.h()), + ], + ), + ], + ), + ), + ); + } + + Widget _bar({required double width, required double height}) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.grey20, + borderRadius: BorderRadius.circular(6), + ), + ); + } + + Widget _fractionBar({required double widthFactor, required double height}) { + return FractionallySizedBox( + widthFactor: widthFactor, + alignment: AlignmentDirectional.centerStart, + child: _bar(width: double.infinity, height: height), + ); + } + + Widget _block({required double height}) { + return Container( + width: double.infinity, + height: height, + decoration: BoxDecoration( + color: AppColors.grey20, + borderRadius: BorderRadius.circular(4), + ), + ); + } + + Widget _pill({required double width, required double height}) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.grey20, + borderRadius: BorderRadius.circular(100), + ), + ); + } + + Widget _circle({required double size}) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: AppColors.grey20, + shape: BoxShape.circle, + ), + ); + } +} + +class _Shimmer extends StatefulWidget { + const _Shimmer({required this.child}); + + final Widget child; + + @override + State<_Shimmer> createState() => _ShimmerState(); +} + +class _ShimmerState extends State<_Shimmer> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) { + return ShaderMask( + blendMode: BlendMode.srcATop, + shaderCallback: (bounds) { + final shimmerWidth = bounds.width * 0.8; + final offset = (bounds.width + shimmerWidth) * _controller.value; + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [AppColors.grey20, AppColors.grey10, AppColors.grey20], + stops: const [0.25, 0.5, 0.75], + transform: _SlidingGradientTransform( + slidePercent: (offset - shimmerWidth) / bounds.width, + ), + ).createShader(bounds); + }, + child: child, + ); + }, + ); + } +} + +class _SlidingGradientTransform extends GradientTransform { + const _SlidingGradientTransform({required this.slidePercent}); + + final double slidePercent; + + @override + Matrix4 transform(Rect bounds, {TextDirection? textDirection}) { + return Matrix4.translationValues(bounds.width * slidePercent, 0, 0); + } +}