Files
999/lib/screens/settings_screen.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

315 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 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/interpreter_persona.dart';
import '../services/settings_repository.dart';
import '../services/true_random_service.dart';
import '../widgets/gradient_star_badge.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = SettingsRepository();
return Scaffold(
appBar: AppBar(
title: const Row(
children: [
GradientStar(size: 24),
SizedBox(width: 10),
Text(
'Настройки',
style: TextStyle(fontWeight: FontWeight.w900),
),
],
),
),
body: ListenableBuilder(
listenable: settings,
builder: (context, _) {
final selected = settings.personaId;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Text(
'Голос толкователя',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'От этого зависит, как ИИ свяжет выпавшие знаки с вопросом. Базовый голос — прямой, без тумана.',
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
const SizedBox(height: 14),
for (final persona in InterpreterPersona.all) ...[
_PersonaCard(
persona: persona,
selected: persona.id == selected,
onTap: () {
HapticFeedback.selectionClick();
settings.setPersonaId(persona.id);
},
),
const SizedBox(height: 10),
],
const SizedBox(height: 18),
const _RandomOrgKeySection(),
],
);
},
),
);
}
}
class _PersonaCard extends StatelessWidget {
final InterpreterPersona persona;
final bool selected;
final VoidCallback onTap;
const _PersonaCard({
required this.persona,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF17192C) : theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected
? const Color(0xFFFFD54F)
: const Color(0xFFFFD54F).withValues(alpha: 0.22),
width: selected ? 1.6 : 1.0,
),
boxShadow: selected
? [
BoxShadow(
color: const Color(0xFFFFD54F).withValues(alpha: 0.14),
blurRadius: 12,
),
]
: null,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFF2A2D4A),
borderRadius: BorderRadius.circular(12),
),
child: Icon(persona.icon, color: const Color(0xFFFFD54F), size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
persona.title,
style: const TextStyle(
fontWeight: FontWeight.w900,
fontSize: 15.5,
color: Color(0xFFFFF9C4),
),
),
),
Icon(
selected
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
size: 20,
color: selected
? const Color(0xFFFFD54F)
: theme.colorScheme.outline,
),
],
),
const SizedBox(height: 4),
Text(
persona.subtitle,
style: TextStyle(
fontSize: 13,
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
],
),
),
],
),
),
),
);
}
}
class _RandomOrgKeySection extends StatefulWidget {
const _RandomOrgKeySection();
@override
State<_RandomOrgKeySection> createState() => _RandomOrgKeySectionState();
}
class _RandomOrgKeySectionState extends State<_RandomOrgKeySection> {
late final TextEditingController _controller;
bool _obscure = true;
bool _saving = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: SettingsRepository().randomOrgApiKey,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _save() async {
setState(() => _saving = true);
await SettingsRepository().setRandomOrgApiKey(_controller.text);
TrueRandomService.instance.invalidatePool();
TrueRandomService.instance.prefetch();
if (!mounted) return;
setState(() => _saving = false);
HapticFeedback.mediumImpact();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
SettingsRepository().hasRandomOrgApiKey
? 'Ключ RANDOM.ORG сохранён. Броски идут через JSON-RPC.'
: 'Ключ снят. Броски пойдут через бесплатный HTTP или локальный жребий.',
),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = SettingsRepository();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RANDOM.ORG',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
color: const Color(0xFFFFD54F),
letterSpacing: 0.3,
),
),
const SizedBox(height: 6),
Text(
'В запрос уходит UUID apiKey, не hashed key с дашборда. '
'Developer-ключ бесплатный, 1000 запросов/день.\n'
'Матрица 1000 слов пересобирается раз в сутки (полночь UTC) тем же API: '
'перестановка 0–999 без повторов.',
style: theme.textTheme.bodyMedium?.copyWith(
height: 1.35,
color: theme.colorScheme.onSurface.withValues(alpha: 0.78),
),
),
const SizedBox(height: 12),
TextField(
controller: _controller,
obscureText: _obscure,
autocorrect: false,
enableSuggestions: false,
keyboardType: TextInputType.visiblePassword,
decoration: InputDecoration(
labelText: 'API key (UUID)',
hintText: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
helperText: settings.hasRandomOrgApiKey
? 'Сейчас: ${settings.randomOrgApiKeyMasked}'
: 'Пусто — запасной HTTP без ключа',
filled: true,
fillColor: const Color(0xFF17192C),
suffixIcon: IconButton(
tooltip: _obscure ? 'Показать' : 'Скрыть',
icon: Icon(
_obscure ? Icons.visibility_outlined : Icons.visibility_off_outlined,
color: const Color(0xFFFFD54F),
),
onPressed: () => setState(() => _obscure = !_obscure),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16)),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: const Color(0xFFFFD54F).withValues(alpha: 0.25),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFFFFD54F), width: 1.5),
),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save_outlined),
label: Text(_saving ? 'Сохранение…' : 'Сохранить ключ'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF2A2D4A),
foregroundColor: const Color(0xFFFFF9C4),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFFFD54F), width: 1.2),
),
),
),
),
],
);
}
}