392 lines
11 KiB
Dart
392 lines
11 KiB
Dart
import 'dart:math';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:qpay/models/responsive_policy.dart';
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
|
|
class StepLoadingWidget extends StatefulWidget {
|
|
final int currentStep; // 1 or 2
|
|
final String title;
|
|
final String subtitle;
|
|
final String status; // '', 'SUCCESS', 'FAILED'
|
|
final bool isLoading;
|
|
final String? errorMessage;
|
|
final VoidCallback? onRetry;
|
|
|
|
const StepLoadingWidget({
|
|
super.key,
|
|
required this.currentStep,
|
|
required this.title,
|
|
required this.subtitle,
|
|
required this.status,
|
|
required this.isLoading,
|
|
this.errorMessage,
|
|
this.onRetry,
|
|
});
|
|
|
|
@override
|
|
State<StepLoadingWidget> createState() => _StepLoadingWidgetState();
|
|
}
|
|
|
|
class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
|
with TickerProviderStateMixin {
|
|
late AnimationController _pulseController;
|
|
late Animation<double> _pulseAnimation;
|
|
late AnimationController _spinController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Pulse animation for the outer ring
|
|
_pulseController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1500),
|
|
)..repeat(reverse: true);
|
|
|
|
_pulseAnimation = Tween<double>(begin: 0.85, end: 1.15).animate(
|
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
|
);
|
|
|
|
// Spin animation for the arc
|
|
_spinController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1200),
|
|
)..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pulseController.dispose();
|
|
_spinController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final primaryColor = theme.colorScheme.primary;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(50),
|
|
child: Center(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
|
? ResponsivePolicy.md.toDouble()
|
|
: constraints.maxWidth;
|
|
|
|
return SizedBox(
|
|
width: maxWidth,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
const SizedBox(height: 60),
|
|
|
|
// Step indicator
|
|
_StepIndicator(
|
|
currentStep: widget.currentStep,
|
|
status: widget.status,
|
|
primaryColor: primaryColor,
|
|
),
|
|
|
|
const SizedBox(height: 60),
|
|
|
|
// Animated loader
|
|
if (widget.status != 'FAILED' && widget.status != 'SUCCESS')
|
|
AnimatedBuilder(
|
|
animation: Listenable.merge([
|
|
_pulseController,
|
|
_spinController,
|
|
]),
|
|
builder: (context, child) {
|
|
return SizedBox(
|
|
width: 80,
|
|
height: 80,
|
|
child: CustomPaint(
|
|
painter: _LoadingPainter(
|
|
pulseValue: _pulseAnimation.value,
|
|
spinValue: _spinController.value,
|
|
color: primaryColor,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
if (widget.status == 'SUCCESS') _buildCheckIcon(primaryColor),
|
|
|
|
if (widget.status == 'FAILED') _buildErrorIcon(theme),
|
|
|
|
const SizedBox(height: 40),
|
|
|
|
// Title
|
|
Text(
|
|
widget.title,
|
|
style: TextStyle(
|
|
fontSize: 22,
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.tertiary,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Subtitle
|
|
Text(
|
|
widget.subtitle,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey.shade600,
|
|
height: 1.4,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
|
|
const SizedBox(height: 50),
|
|
|
|
// Error state with retry
|
|
if (widget.status == 'FAILED')
|
|
Column(
|
|
children: [
|
|
Text(
|
|
widget.errorMessage ?? 'Something went wrong',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: theme.colorScheme.error,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Skeletonizer(
|
|
enabled: widget.isLoading,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.refresh, size: 20),
|
|
label: const Text('Retry'),
|
|
onPressed: widget.onRetry,
|
|
style: OutlinedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 32,
|
|
vertical: 14,
|
|
),
|
|
side: BorderSide(color: primaryColor, width: 1.5),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCheckIcon(Color primaryColor) {
|
|
return Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: primaryColor.withValues(alpha: 0.1),
|
|
),
|
|
child: Icon(Icons.check_circle_rounded, size: 80, color: primaryColor),
|
|
);
|
|
}
|
|
|
|
Widget _buildErrorIcon(ThemeData theme) {
|
|
return Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: theme.colorScheme.error.withValues(alpha: 0.1),
|
|
),
|
|
child: Icon(
|
|
Icons.error_outline_rounded,
|
|
size: 80,
|
|
color: theme.colorScheme.error,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StepIndicator extends StatelessWidget {
|
|
final int currentStep;
|
|
final String status;
|
|
final Color primaryColor;
|
|
|
|
const _StepIndicator({
|
|
required this.currentStep,
|
|
required this.status,
|
|
required this.primaryColor,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isSuccess = status == 'SUCCESS';
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: Colors.grey.shade200),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildStep(
|
|
number: '1',
|
|
label: 'Verify Payment',
|
|
isActive: currentStep == 1,
|
|
isCompleted: currentStep > 1 || (currentStep == 1 && isSuccess),
|
|
theme: theme,
|
|
),
|
|
Container(
|
|
width: 60,
|
|
height: 2,
|
|
margin: const EdgeInsets.symmetric(horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: currentStep >= 2 ? primaryColor : Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(1),
|
|
),
|
|
),
|
|
_buildStep(
|
|
number: '2',
|
|
label: 'Update Account',
|
|
isActive: currentStep == 2,
|
|
isCompleted: currentStep == 2 && isSuccess,
|
|
theme: theme,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStep({
|
|
required String number,
|
|
required String label,
|
|
required bool isActive,
|
|
required bool isCompleted,
|
|
required ThemeData theme,
|
|
}) {
|
|
final Color circleColor;
|
|
final Widget circleChild;
|
|
|
|
if (isCompleted) {
|
|
circleColor = theme.colorScheme.primary;
|
|
circleChild = const Icon(Icons.check, size: 16, color: Colors.white);
|
|
} else if (isActive) {
|
|
circleColor = theme.colorScheme.primary;
|
|
circleChild = Text(
|
|
number,
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
);
|
|
} else {
|
|
circleColor = Colors.grey.shade300;
|
|
circleChild = Text(
|
|
number,
|
|
style: TextStyle(
|
|
color: Colors.grey.shade600,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: circleColor),
|
|
child: Center(child: circleChild),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
|
color: isActive ? theme.colorScheme.tertiary : Colors.grey.shade500,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LoadingPainter extends CustomPainter {
|
|
final double pulseValue;
|
|
final double spinValue;
|
|
final Color color;
|
|
|
|
_LoadingPainter({
|
|
required this.pulseValue,
|
|
required this.spinValue,
|
|
required this.color,
|
|
});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final baseRadius = size.width / 2 - 8;
|
|
|
|
// Draw pulsing outer ring
|
|
final pulseRadius = baseRadius * pulseValue;
|
|
final pulsePaint = Paint()
|
|
..color = color.withValues(alpha: 0.15)
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawCircle(center, pulseRadius, pulsePaint);
|
|
|
|
// Draw ring outline
|
|
final ringPaint = Paint()
|
|
..color = color.withValues(alpha: 0.25)
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 3;
|
|
canvas.drawCircle(center, baseRadius, ringPaint);
|
|
|
|
// Draw spinning arc
|
|
final arcPaint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.stroke
|
|
..strokeWidth = 3.5
|
|
..strokeCap = StrokeCap.round;
|
|
|
|
final arcStart = spinValue * 2 * pi;
|
|
final arcSweep = pi * 1.2;
|
|
canvas.drawArc(
|
|
Rect.fromCircle(center: center, radius: baseRadius),
|
|
arcStart,
|
|
arcSweep,
|
|
false,
|
|
arcPaint,
|
|
);
|
|
|
|
// Draw inner dot
|
|
final dotPaint = Paint()
|
|
..color = color
|
|
..style = PaintingStyle.fill;
|
|
canvas.drawCircle(center, 6, dotPaint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_LoadingPainter oldDelegate) {
|
|
return oldDelegate.pulseValue != pulseValue ||
|
|
oldDelegate.spinValue != spinValue;
|
|
}
|
|
}
|