Add tender card shimmer loading
continuous-integration/drone/push Build is passing

This commit is contained in:
AmirReza Jamali
2026-06-29 16:12:18 +03:30
parent 7b5b6b85f8
commit 727dadecf8
2 changed files with 182 additions and 13 deletions
@@ -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<RecommendedTendersView> {
return Consumer<AiRecommendationsViewModel>(
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),
);
}
}
@@ -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);
}
}