import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'claims_overview_scope.dart'; import 'claims_overview_theme.dart'; /// Thin indeterminate bar shown while dashboard data reloads. class ClaimsTopLoadingBar extends StatelessWidget { final bool visible; const ClaimsTopLoadingBar({super.key, required this.visible}); @override Widget build(BuildContext context) { return AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: visible ? const LinearProgressIndicator( minHeight: 3, backgroundColor: Color(0xFFE2E8F0), color: ClaimsOverviewTheme.primary, ) : const SizedBox.shrink(), ); } } /// Pulsing green dot + "Live" label for real-time analytics feel. class ClaimsLiveIndicator extends StatefulWidget { final String? createdAt; const ClaimsLiveIndicator({super.key, this.createdAt}); @override State createState() => _ClaimsLiveIndicatorState(); } class _ClaimsLiveIndicatorState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), )..repeat(reverse: true); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final createdAt = widget.createdAt?.trim(); if (createdAt == null || createdAt.isEmpty) { return const SizedBox.shrink(); } return Row( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _controller, builder: (_, __) { final scale = 0.85 + (_controller.value * 0.3); final opacity = 0.45 + (_controller.value * 0.55); return Container( width: 8 * scale, height: 8 * scale, decoration: BoxDecoration( shape: BoxShape.circle, color: ClaimsOverviewTheme.green.withValues(alpha: opacity), boxShadow: [ BoxShadow( color: ClaimsOverviewTheme.green.withValues(alpha: 0.35), blurRadius: 6 * scale, spreadRadius: 1, ), ], ), ); }, ), const SizedBox(width: 6), Text( 'Generated at', style: GoogleFonts.poppins( fontSize: 11, fontWeight: FontWeight.w400, color: ClaimsOverviewTheme.green, ), ), const SizedBox(width: 6), Text( '· $createdAt', style: GoogleFonts.poppins( fontSize: 11, fontWeight: FontWeight.w400, color: ClaimsOverviewTheme.green, ), ), ], ); } } /// Fade + slide entrance; replays when [replayToken] changes. class ClaimsStaggeredEntrance extends StatefulWidget { final Widget child; final int index; final int replayToken; final Duration baseDelay; const ClaimsStaggeredEntrance({ super.key, required this.child, this.index = 0, this.replayToken = 0, this.baseDelay = const Duration(milliseconds: 55), }); @override State createState() => _ClaimsStaggeredEntranceState(); } class _ClaimsStaggeredEntranceState extends State with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _fade; late Animation _slide; bool _animationStarted = false; @override void initState() { super.initState(); _initController(); } @override void didChangeDependencies() { super.didChangeDependencies(); if (ClaimsPdfExportScope.of(context)) { _controller.value = 1.0; return; } if (!_animationStarted) { _animationStarted = true; _runAnimation(); } } @override void didUpdateWidget(ClaimsStaggeredEntrance oldWidget) { super.didUpdateWidget(oldWidget); if (ClaimsPdfExportScope.of(context)) { _controller.value = 1.0; return; } if (oldWidget.replayToken != widget.replayToken) { _controller.reset(); _animationStarted = true; _runAnimation(); } } void _initController() { _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 520), ); _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic); _slide = Tween( begin: const Offset(0, 0.08), end: Offset.zero, ).animate(_fade); } Future _runAnimation() async { if (!mounted || ClaimsPdfExportScope.of(context)) { if (mounted) _controller.value = 1.0; return; } await Future.delayed(widget.baseDelay * widget.index); if (!mounted) return; await _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (ClaimsPdfExportScope.of(context)) return widget.child; return FadeTransition( opacity: _fade, child: SlideTransition(position: _slide, child: widget.child), ); } } /// Wraps tab body: skeleton while loading, then staggered content. class ClaimsTabAnimatedShell extends StatelessWidget { final bool isLoading; final Widget child; final int skeletonBlocks; const ClaimsTabAnimatedShell({ super.key, required this.isLoading, required this.child, this.skeletonBlocks = 4, }); @override Widget build(BuildContext context) { if (isLoading) { return ClaimsDashboardSkeleton(blockCount: skeletonBlocks); } return child; } } /// Shimmer placeholder grid while dummy data "loads". class ClaimsDashboardSkeleton extends StatefulWidget { final int blockCount; const ClaimsDashboardSkeleton({super.key, this.blockCount = 4}); @override State createState() => _ClaimsDashboardSkeletonState(); } class _ClaimsDashboardSkeletonState extends State with SingleTickerProviderStateMixin { late AnimationController _shimmer; @override void initState() { super.initState(); _shimmer = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), )..repeat(); } @override void dispose() { _shimmer.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: Column( children: List.generate(widget.blockCount, (i) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: _ShimmerBlock(animation: _shimmer, height: i == 0 ? 72 : 120), ); }), ), ); } } class _ShimmerBlock extends StatelessWidget { final Animation animation; final double height; const _ShimmerBlock({required this.animation, required this.height}); @override Widget build(BuildContext context) { return AnimatedBuilder( animation: animation, builder: (_, __) { return Container( height: height, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), gradient: LinearGradient( begin: Alignment(-1 + animation.value * 2, 0), end: Alignment(animation.value * 2, 0), colors: const [ Color(0xFFE8ECF0), Color(0xFFF8FAFC), Color(0xFFE8ECF0), ], ), border: Border.all(color: ClaimsOverviewTheme.border), ), ); }, ); } } /// Counts up numeric portions of [value] (e.g. "29,293,880" or "51.16%"). class ClaimsAnimatedValueText extends StatelessWidget { final String value; final TextStyle style; final int replayToken; final Duration duration; const ClaimsAnimatedValueText({ super.key, required this.value, required this.style, required this.replayToken, this.duration = const Duration(milliseconds: 1100), }); static double? _parseNumeric(String raw) { final cleaned = raw.replaceAll(',', '').replaceAll('%', '').trim(); return double.tryParse(cleaned); } static String _formatLikeOriginal(double n, String original) { final hasPercent = original.contains('%'); final hasComma = original.contains(','); final decimals = original.contains('.') ? original.split('.').last.replaceAll(RegExp(r'[^0-9]'), '').length : 0; String formatted; if (decimals > 0) { formatted = n.toStringAsFixed(decimals); } else { formatted = n.round().toString(); } if (hasComma) { final parts = formatted.split('.'); final intPart = parts[0]; final buf = StringBuffer(); for (var i = 0; i < intPart.length; i++) { if (i > 0 && (intPart.length - i) % 3 == 0) buf.write(','); buf.write(intPart[i]); } formatted = parts.length > 1 ? '${buf}.${parts[1]}' : buf.toString(); } return hasPercent ? '$formatted%' : formatted; } @override Widget build(BuildContext context) { final target = _parseNumeric(value); if (target == null) { return Text(value, style: style); } return TweenAnimationBuilder( key: ValueKey('$replayToken-$value'), tween: Tween(begin: 0, end: target), duration: duration, curve: Curves.easeOutCubic, builder: (_, v, __) { return Text( _formatLikeOriginal(v, value), style: style, ); }, ); } } /// Progress bar that animates from 0 → [value]. class ClaimsAnimatedProgressBar extends StatelessWidget { final double value; final Color color; final double minHeight; final int replayToken; const ClaimsAnimatedProgressBar({ super.key, required this.value, required this.color, this.minHeight = 8, required this.replayToken, }); @override Widget build(BuildContext context) { return TweenAnimationBuilder( key: ValueKey('$replayToken-$value'), tween: Tween(begin: 0, end: value.clamp(0.0, 1.0)), duration: const Duration(milliseconds: 900), curve: Curves.easeOutCubic, builder: (_, v, __) { return ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: v, minHeight: minHeight, backgroundColor: ClaimsOverviewTheme.border, color: color, ), ); }, ); } } /// Center loading indicator for full tab refresh. class ClaimsTabLoadingIndicator extends StatelessWidget { final String? message; const ClaimsTabLoadingIndicator({super.key, this.message}); @override Widget build(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox( width: 36, height: 36, child: CircularProgressIndicator( strokeWidth: 3, color: ClaimsOverviewTheme.primary, ), ), if (message != null) ...[ const SizedBox(height: 12), Text( message!, style: GoogleFonts.poppins( fontSize: 13, color: ClaimsOverviewTheme.textSecondary, ), ), ], ], ), ); } } /// Rotating refresh icon while [isRefreshing]. class ClaimsRefreshIcon extends StatelessWidget { final bool isRefreshing; final VoidCallback onPressed; const ClaimsRefreshIcon({ super.key, required this.isRefreshing, required this.onPressed, }); @override Widget build(BuildContext context) { return IconButton( onPressed: isRefreshing ? null : onPressed, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), icon: isRefreshing ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: ClaimsOverviewTheme.primary, ), ) : _SpinOnHover( child: Icon( Icons.refresh, size: 18, color: ClaimsOverviewTheme.textSecondary.withValues(alpha: 0.9), ), ), ); } } class _SpinOnHover extends StatefulWidget { final Widget child; const _SpinOnHover({required this.child}); @override State<_SpinOnHover> createState() => _SpinOnHoverState(); } class _SpinOnHoverState extends State<_SpinOnHover> { bool _hovering = false; @override Widget build(BuildContext context) { return MouseRegion( onEnter: (_) => setState(() => _hovering = true), onExit: (_) => setState(() => _hovering = false), child: AnimatedRotation( turns: _hovering ? 0.25 : 0, duration: const Duration(milliseconds: 280), curve: Curves.easeOut, child: widget.child, ), ); } } /// Chart fade-in wrapper keyed to [replayToken]. class ClaimsChartEntrance extends StatelessWidget { final int replayToken; final int index; final Widget child; const ClaimsChartEntrance({ super.key, required this.replayToken, this.index = 0, required this.child, }); @override Widget build(BuildContext context) { return ClaimsStaggeredEntrance( replayToken: replayToken, index: index, baseDelay: const Duration(milliseconds: 80), child: child, ); } } int claimsStaggerIndex({required int row, required int col, int cols = 4}) { return row * cols + col; } double claimsClampFraction(double v) => math.min(1.0, math.max(0.0, v));