From 82c0eaf5fe494d05e1b031b7f3fa1cad8c45c9bd Mon Sep 17 00:00:00 2001 From: efrilm Date: Sun, 17 Aug 2025 13:14:03 +0700 Subject: [PATCH] feat: empty and search widget --- .../widgets/empty_search_widget.dart | 27 +++ .../components/widgets/empty_widget.dart | 179 ++++++++++++++++++ .../components/widgets/error_widget.dart | 176 +++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 lib/presentation/components/widgets/empty_search_widget.dart create mode 100644 lib/presentation/components/widgets/empty_widget.dart create mode 100644 lib/presentation/components/widgets/error_widget.dart diff --git a/lib/presentation/components/widgets/empty_search_widget.dart b/lib/presentation/components/widgets/empty_search_widget.dart new file mode 100644 index 0000000..298cc7b --- /dev/null +++ b/lib/presentation/components/widgets/empty_search_widget.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../../../common/theme/theme.dart'; +import 'empty_widget.dart'; + +class EmptySearchWidget extends StatelessWidget { + final String? searchQuery; + final VoidCallback? onClear; + + const EmptySearchWidget({Key? key, this.searchQuery, this.onClear}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return EmptyWidget( + title: 'Pencarian Tidak Ditemukan', + message: searchQuery != null + ? 'Tidak ada hasil untuk "$searchQuery"\nCoba kata kunci lain' + : 'Coba gunakan kata kunci yang berbeda', + emptyIcon: Icons.search_off_rounded, + iconColor: AppColor.warning, + buttonText: 'Hapus Filter', + onRefresh: onClear, + showButton: onClear != null, + ); + } +} diff --git a/lib/presentation/components/widgets/empty_widget.dart b/lib/presentation/components/widgets/empty_widget.dart new file mode 100644 index 0000000..6d9a997 --- /dev/null +++ b/lib/presentation/components/widgets/empty_widget.dart @@ -0,0 +1,179 @@ +// ==================== EMPTY WIDGET ==================== +import 'package:flutter/material.dart'; + +import '../../../common/theme/theme.dart'; + +class EmptyWidget extends StatefulWidget { + final String? title; + final String? message; + final VoidCallback? onRefresh; + final IconData? emptyIcon; + final String? buttonText; + final double? width; + final double? height; + final bool showButton; + final EdgeInsets? padding; + final Color? iconColor; + + const EmptyWidget({ + super.key, + this.title, + this.message, + this.onRefresh, + this.emptyIcon, + this.buttonText, + this.width, + this.height, + this.showButton = true, + this.padding, + this.iconColor, + }); + + @override + State createState() => _EmptyWidgetState(); +} + +class _EmptyWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + + _slideAnimation = + Tween(begin: const Offset(0.0, 0.3), end: Offset.zero).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ), + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: widget.width, + height: widget.height, + padding: widget.padding ?? const EdgeInsets.all(24), + child: FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Empty Illustration + Stack( + alignment: Alignment.center, + children: [ + // Background Circle + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + AppColor.primary.withOpacity(0.05), + AppColor.secondary.withOpacity(0.05), + ], + ), + ), + ), + + // Icon Container + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColor.surface, + border: Border.all(color: AppColor.border, width: 2), + boxShadow: [ + BoxShadow( + color: AppColor.primary.withOpacity(0.08), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Icon( + widget.emptyIcon ?? Icons.inbox_outlined, + size: 40, + color: widget.iconColor ?? AppColor.textLight, + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Title + Text( + widget.title ?? 'Tidak Ada Data', + style: AppStyle.xl.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.textPrimary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 8), + + // Message + Text( + widget.message ?? 'Belum ada data untuk ditampilkan', + style: AppStyle.md.copyWith( + color: AppColor.textSecondary, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + + // Action Button + if (widget.showButton && widget.onRefresh != null) ...[ + const SizedBox(height: 24), + OutlinedButton.icon( + onPressed: widget.onRefresh, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text(widget.buttonText ?? 'Muat Ulang'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColor.primary, + side: BorderSide(color: AppColor.primary, width: 1.5), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/components/widgets/error_widget.dart b/lib/presentation/components/widgets/error_widget.dart new file mode 100644 index 0000000..8a2ee20 --- /dev/null +++ b/lib/presentation/components/widgets/error_widget.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; + +import '../../../common/theme/theme.dart'; + +class ErrorWidget extends StatefulWidget { + final String? title; + final String? message; + final VoidCallback? onRetry; + final String? errorCode; + final IconData? errorIcon; + final double? width; + final double? height; + final bool showRetryButton; + final EdgeInsets? padding; + + const ErrorWidget({ + super.key, + this.title, + this.message, + this.onRetry, + this.errorCode, + this.errorIcon, + this.width, + this.height, + this.showRetryButton = true, + this.padding, + }); + + @override + State createState() => _ErrorWidgetState(); +} + +class _ErrorWidgetState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 600), + vsync: this, + ); + + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + + _scaleAnimation = Tween(begin: 0.8, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.elasticOut), + ); + + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: widget.width, + height: widget.height, + padding: widget.padding ?? const EdgeInsets.all(24), + child: FadeTransition( + opacity: _fadeAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Error Icon + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: [ + AppColor.error.withOpacity(0.1), + AppColor.warning.withOpacity(0.1), + ], + ), + border: Border.all( + color: AppColor.error.withOpacity(0.2), + width: 2, + ), + ), + child: Icon( + widget.errorIcon ?? Icons.error_outline_rounded, + size: 40, + color: AppColor.error, + ), + ), + + const SizedBox(height: 16), + + // Title + if (widget.title != null) + Text( + widget.title!, + style: AppStyle.xl.copyWith( + fontWeight: FontWeight.bold, + color: AppColor.textPrimary, + ), + textAlign: TextAlign.center, + ), + + const SizedBox(height: 8), + + // Message + Text( + widget.message ?? 'Terjadi kesalahan tidak terduga', + style: AppStyle.md.copyWith( + color: AppColor.textSecondary, + height: 1.4, + ), + textAlign: TextAlign.center, + ), + + // Error Code + if (widget.errorCode != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColor.error.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColor.error.withOpacity(0.2)), + ), + child: Text( + 'Error: ${widget.errorCode}', + style: AppStyle.xs.copyWith( + color: AppColor.error, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + + // Retry Button + if (widget.showRetryButton && widget.onRetry != null) ...[ + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: widget.onRetry, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.primary, + foregroundColor: AppColor.textWhite, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +}