import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../services/true_random_service.dart'; import 'gradient_star_badge.dart'; class D10DiceRollerWidget extends StatefulWidget { final String digits; // Current 0..3 digits string, e.g. "7", "74", "742" or "" final bool isRolling; final VoidCallback onRollPressed; final ValueChanged onRollCompleted; const D10DiceRollerWidget({ super.key, required this.digits, required this.isRolling, required this.onRollPressed, required this.onRollCompleted, }); @override State createState() => D10DiceRollerWidgetState(); } class D10DiceRollerWidgetState extends State { final _flicker = Random.secure(); Timer? _rollTimer; int? _animatedDigit0; int? _animatedDigit1; int? _animatedDigit2; bool _internalRolling = false; RandomSource? _lastSource; @override void initState() { super.initState(); TrueRandomService.instance.prefetch(); } Future triggerRollAnimation() async { if (_internalRolling) return; setState(() { _internalRolling = true; _animatedDigit0 = _flicker.nextInt(10); _animatedDigit1 = _flicker.nextInt(10); _animatedDigit2 = _flicker.nextInt(10); }); // Local CSPRNG is the fallback. RANDOM.ORG replaces all three faces // together if it arrives before the first slot locks (up to ~960ms). var target0 = _flicker.nextInt(10); var target1 = _flicker.nextInt(10); var target2 = _flicker.nextInt(10); var source = RandomSource.local; DiceRoll? remoteRoll; unawaited( TrueRandomService.instance.take(3).then((roll) { remoteRoll = roll; }, onError: (Object _, StackTrace _) {}), ); var locked0 = false; var locked1 = false; var locked2 = false; var lock0At = 10; var lock1At = 18; var lock2At = 26; var ticks = 0; const interval = Duration(milliseconds: 60); _rollTimer?.cancel(); _rollTimer = Timer.periodic(interval, (timer) { if (!mounted) { timer.cancel(); return; } ticks++; if (!locked0) { final ready = remoteRoll != null && remoteRoll!.digits.length == 3; if (ticks >= 10 && (ready || ticks >= 16)) { if (ready) { target0 = remoteRoll!.digits[0]; target1 = remoteRoll!.digits[1]; target2 = remoteRoll!.digits[2]; source = remoteRoll!.source; } locked0 = true; lock0At = ticks; lock1At = lock0At + 8; lock2At = lock1At + 8; setState(() => _animatedDigit0 = target0); HapticFeedback.mediumImpact(); } else { setState(() => _animatedDigit0 = _flicker.nextInt(10)); } } if (!locked1) { if (locked0 && ticks >= lock1At) { locked1 = true; setState(() => _animatedDigit1 = target1); HapticFeedback.mediumImpact(); } else { setState(() => _animatedDigit1 = _flicker.nextInt(10)); } } if (!locked2) { if (locked1 && ticks >= lock2At) { locked2 = true; setState(() => _animatedDigit2 = target2); HapticFeedback.heavyImpact(); } else { setState(() => _animatedDigit2 = _flicker.nextInt(10)); } } if (locked2 && ticks >= lock2At + 2) { timer.cancel(); setState(() { _internalRolling = false; _lastSource = source; }); widget.onRollCompleted(target0 * 100 + target1 * 10 + target2); } }); } @override void dispose() { _rollTimer?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; // Determine current display values for the 3 slots String d0 = '•'; String d1 = '•'; String d2 = '•'; if (_internalRolling) { d0 = _animatedDigit0 != null ? '$_animatedDigit0' : '•'; d1 = _animatedDigit1 != null ? '$_animatedDigit1' : '•'; d2 = _animatedDigit2 != null ? '$_animatedDigit2' : '•'; } else { final text = widget.digits; if (text.isNotEmpty) d0 = text[0]; if (text.length > 1) d1 = text[1]; if (text.length > 2) d2 = text[2]; } return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( color: isDark ? const Color(0xFF181B2E) : theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(22), border: Border.all( color: const Color(0xFFFFD54F).withValues(alpha: 0.3), width: 1.2, ), ), child: Column( children: [ Row( children: [ const GradientStar(size: 15), const SizedBox(width: 6), const Expanded( child: Text( 'КЛЕРОМАНТИЯ • 3× d10', style: TextStyle( fontSize: 11.5, fontWeight: FontWeight.w900, letterSpacing: 0.5, color: Color(0xFFFFD54F), ), ), ), Text( _internalRolling ? 'Бросок...' : (widget.digits.length == 3 ? (_lastSource == RandomSource.randomOrg ? 'RANDOM.ORG' : 'Зафиксировано') : '3 цифры'), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: _internalRolling ? const Color(0xFFBA68C8) : theme.colorScheme.outline, ), ), ], ), const SizedBox(height: 12), // 3 Dice Slots Display Row( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildDieSlot( title: '1-й d10 (Сотни)', value: d0, isActive: widget.digits.isNotEmpty || _internalRolling, isDark: isDark, ), const SizedBox(width: 12), _buildDieSlot( title: '2-й d10 (Десятки)', value: d1, isActive: widget.digits.length >= 2 || _internalRolling, isDark: isDark, ), const SizedBox(width: 12), _buildDieSlot( title: '3-й d10 (Единицы)', value: d2, isActive: widget.digits.length >= 3 || _internalRolling, isDark: isDark, ), ], ), const SizedBox(height: 12), // Action Button: Roll 3xd10 SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: _internalRolling ? null : () { triggerRollAnimation(); widget.onRollPressed(); }, icon: _internalRolling ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation(Color(0xFFFFD54F)), ), ) : const Icon(Icons.casino_rounded, size: 20, color: Color(0xFFFFD54F)), label: Text( _internalRolling ? 'ВРАЩЕНИЕ ЖРЕБИЯ...' : 'СОВЕРШИТЬ 3 БРОСКА d10', style: const TextStyle( fontWeight: FontWeight.w900, letterSpacing: 0.6, color: Color(0xFFFFF9C4), ), ), style: FilledButton.styleFrom( backgroundColor: const Color(0xFF2A2D4A), padding: const EdgeInsets.symmetric(vertical: 13), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: const BorderSide( color: Color(0xFFFFD54F), width: 1.2, ), ), ), ), ), ], ), ); } Widget _buildDieSlot({ required String title, required String value, required bool isActive, required bool isDark, }) { final isFilled = value != '•'; return Expanded( child: Column( children: [ AnimatedContainer( duration: const Duration(milliseconds: 200), height: 64, alignment: Alignment.center, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: isFilled ? [ const Color(0xFF2E2452), const Color(0xFF1E1B38), ] : [ isDark ? const Color(0xFF131525) : const Color(0xFFEAEBF5), isDark ? const Color(0xFF0F111E) : const Color(0xFFDEDFEF), ], ), borderRadius: BorderRadius.circular(16), border: Border.all( color: isFilled ? const Color(0xFFFFD54F) : const Color(0xFFFFD54F).withValues(alpha: 0.25), width: isFilled ? 1.6 : 1.0, ), boxShadow: isFilled ? [ BoxShadow( color: const Color(0xFFFFD54F).withValues(alpha: 0.2), blurRadius: 10, spreadRadius: 1, ), ] : null, ), child: Text( value, style: TextStyle( fontSize: 28, fontWeight: FontWeight.w900, color: isFilled ? const Color(0xFFFFF9C4) : isDark ? Colors.white24 : Colors.black26, ), ), ), const SizedBox(height: 6), Text( title, textAlign: TextAlign.center, style: const TextStyle( fontSize: 10.5, fontWeight: FontWeight.w600, color: Color(0xFFFFD54F), ), ), ], ), ); } }