288 lines
7.5 KiB
Dart
288 lines
7.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../../core/theme/theme.dart';
|
|
|
|
/// Loading Dots Animation - Used on splash screen
|
|
class LoadingDots extends StatefulWidget {
|
|
final Color? color;
|
|
final double size;
|
|
|
|
const LoadingDots({super.key, this.color, this.size = 8});
|
|
|
|
@override
|
|
State<LoadingDots> createState() => _LoadingDotsState();
|
|
}
|
|
|
|
class _LoadingDotsState extends State<LoadingDots>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1200),
|
|
)..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final dotColor = widget.color ?? Colors.white;
|
|
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: List.generate(3, (index) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
final delay = index * 0.2;
|
|
final value = (_controller.value - delay) % 1.0;
|
|
final scale = value < 0.5
|
|
? 1.0 + (value * 0.5)
|
|
: 1.5 - ((value - 0.5) * 0.5);
|
|
final opacity = value < 0.5
|
|
? 0.3 + (value * 1.4)
|
|
: 1.0 - ((value - 0.5) * 1.4);
|
|
|
|
return Container(
|
|
margin: EdgeInsets.symmetric(horizontal: widget.size * 0.4),
|
|
child: Transform.scale(
|
|
scale: scale.clamp(0.8, 1.2),
|
|
child: Opacity(
|
|
opacity: opacity.clamp(0.3, 1.0),
|
|
child: Container(
|
|
width: widget.size,
|
|
height: widget.size,
|
|
decoration: BoxDecoration(
|
|
color: dotColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Loading Spinner - Circular progress indicator
|
|
class LoadingSpinner extends StatelessWidget {
|
|
final Color? color;
|
|
final double size;
|
|
final double strokeWidth;
|
|
|
|
const LoadingSpinner({
|
|
super.key,
|
|
this.color,
|
|
this.size = 24,
|
|
this.strokeWidth = 2.5,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: strokeWidth,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
color ?? AppColors.primary600,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Full Screen Loading Overlay
|
|
class LoadingOverlay extends StatelessWidget {
|
|
final String? message;
|
|
|
|
const LoadingOverlay({super.key, this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.3),
|
|
child: Center(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius: AppLayout.borderRadiusLg,
|
|
boxShadow: AppLayout.shadowLg,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const LoadingSpinner(size: 32),
|
|
if (message != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
message!,
|
|
style: AppTypography.body.copyWith(
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shimmer Loading Effect
|
|
class ShimmerLoading extends StatefulWidget {
|
|
final double width;
|
|
final double height;
|
|
final BorderRadius? borderRadius;
|
|
|
|
const ShimmerLoading({
|
|
super.key,
|
|
required this.width,
|
|
required this.height,
|
|
this.borderRadius,
|
|
});
|
|
|
|
@override
|
|
State<ShimmerLoading> createState() => _ShimmerLoadingState();
|
|
}
|
|
|
|
class _ShimmerLoadingState extends State<ShimmerLoading>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1500),
|
|
)..repeat();
|
|
_animation = Tween<double>(
|
|
begin: -1,
|
|
end: 2,
|
|
).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 Container(
|
|
width: widget.width,
|
|
height: widget.height,
|
|
decoration: BoxDecoration(
|
|
borderRadius: widget.borderRadius ?? AppLayout.borderRadiusSm,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.centerLeft,
|
|
end: Alignment.centerRight,
|
|
colors: const [
|
|
AppColors.gray100,
|
|
AppColors.gray50,
|
|
AppColors.gray100,
|
|
],
|
|
stops: [
|
|
_animation.value - 0.3,
|
|
_animation.value,
|
|
_animation.value + 0.3,
|
|
].map((e) => e.clamp(0.0, 1.0)).toList(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Empty State Widget
|
|
class EmptyState extends StatelessWidget {
|
|
final IconData icon;
|
|
final String title;
|
|
final String? description;
|
|
final String? actionLabel;
|
|
final VoidCallback? onAction;
|
|
|
|
/// A second, quieter action under the first, such as Import beside Add.
|
|
final String? secondaryActionLabel;
|
|
final VoidCallback? onSecondaryAction;
|
|
|
|
const EmptyState({
|
|
super.key,
|
|
required this.icon,
|
|
required this.title,
|
|
this.description,
|
|
this.actionLabel,
|
|
this.onAction,
|
|
this.secondaryActionLabel,
|
|
this.onSecondaryAction,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: SingleChildScrollView(
|
|
padding: AppSpacing.page,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.gray100,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(icon, size: 36, color: AppColors.gray400),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
title,
|
|
style: AppTypography.h3.copyWith(color: AppColors.gray700),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
if (description != null) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
description!,
|
|
style: AppTypography.body.copyWith(
|
|
color: AppColors.textSecondary,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
if (actionLabel != null && onAction != null) ...[
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(onPressed: onAction, child: Text(actionLabel!)),
|
|
],
|
|
if (secondaryActionLabel != null && onSecondaryAction != null) ...[
|
|
const SizedBox(height: 8),
|
|
OutlinedButton(
|
|
onPressed: onSecondaryAction,
|
|
child: Text(secondaryActionLabel!),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|