Files
999/lib/widgets/d10_dice_roller_widget.dart
T
Alexyz 7f91366201 feat: Random.org dice and daily word matrix, interpreter voices, curated lexicon
Wire cleromancy rolls and the once-per-UTC-day 000-999 word shuffle to RANDOM.ORG JSON-RPC, with a local CSPRNG fallback. Add settings for interpreter personas and a cleaner default oracle prompt. Replace proper names and bureaucratic filler in the 1000-word lexicon with sacred symbols.
2026-09-08 13:46:11 +05:00

357 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<int> onRollCompleted;
const D10DiceRollerWidget({
super.key,
required this.digits,
required this.isRolling,
required this.onRollPressed,
required this.onRollCompleted,
});
@override
State<D10DiceRollerWidget> createState() => D10DiceRollerWidgetState();
}
class D10DiceRollerWidgetState extends State<D10DiceRollerWidget> {
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<void> 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>(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),
),
),
],
),
);
}
}