Files
velocity-pay-flutter/lib/widgets/step_loading_widget.dart
2026-06-29 15:10:37 +02:00

413 lines
12 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;
final int pollAttempt;
final int pollMaxAttempts;
const StepLoadingWidget({
super.key,
required this.currentStep,
required this.title,
required this.subtitle,
required this.status,
required this.isLoading,
this.errorMessage,
this.onRetry,
this.pollAttempt = 0,
this.pollMaxAttempts = 0,
});
@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,
),
// Poll attempt counter
if (widget.pollMaxAttempts > 0 &&
widget.status != 'SUCCESS' &&
widget.status != 'FAILED')
Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
'Attempt ${widget.pollAttempt} of ${widget.pollMaxAttempts}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 36),
// 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;
}
}