1e22f27de2
- Add invite code display and binding functionality via API - Fix notification unread count sync on auth state change - Improve notification mark read with server state validation - Add auth state listener to trigger notification refresh - Add YaoCoinConverter for coin-to-yao type conversion - Remove YaoLegend from divination screens (UI cleanup) - Abbreviate relation labels in yao detail view - Add re-register notice to account delete screen - Update 'coins' terminology to 'points' in localization - Fix backend points consumption to only run in CHAT mode - Add HttpxAuthNoiseFilter to suppress auth endpoint logging - Fix notification static_schema import path - Add test coverage for notification bloc error handling - Update AGENTS.md page header rules and image handling - Delete deprecated run-dev.sh script
798 lines
23 KiB
Dart
798 lines
23 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:onboarding_overlay/onboarding_overlay.dart';
|
|
import 'package:sensors_plus/sensors_plus.dart';
|
|
import 'package:vibration/vibration.dart';
|
|
|
|
import '../../../../l10n/app_localizations.dart';
|
|
import '../../../../shared/theme/design_tokens.dart';
|
|
import '../../../../shared/widgets/app_modal_dialog.dart';
|
|
import '../../../../shared/widgets/gua_icon.dart';
|
|
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
|
import '../../../../shared/widgets/divination/divination_terms.dart';
|
|
import '../../../../shared/widgets/divination/yao_line_row.dart';
|
|
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
|
import '../../../../shared/widgets/toast/toast.dart';
|
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
import '../../data/models/divination_backend_models.dart';
|
|
import '../../data/apis/divination_api.dart';
|
|
import '../../data/models/divination_params.dart';
|
|
import '../../data/models/divination_result.dart';
|
|
import '../../data/models/yao_coin_converter.dart';
|
|
import '../../data/services/divination_run_service.dart';
|
|
import 'divination_processing_screen.dart';
|
|
|
|
class AutoDivinationScreen extends StatefulWidget {
|
|
const AutoDivinationScreen({
|
|
super.key,
|
|
required this.params,
|
|
required this.runService,
|
|
this.divinationApi,
|
|
required this.onCompleted,
|
|
});
|
|
|
|
final DivinationParams params;
|
|
final DivinationRunService runService;
|
|
final DivinationApi? divinationApi;
|
|
final Future<void> Function(DivinationResultData result) onCompleted;
|
|
|
|
@override
|
|
State<AutoDivinationScreen> createState() => _AutoDivinationScreenState();
|
|
}
|
|
|
|
class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
|
with TickerProviderStateMixin {
|
|
final Random _random = Random.secure();
|
|
final List<YaoType> _yaoStates = List<YaoType>.filled(
|
|
6,
|
|
YaoType.undetermined,
|
|
);
|
|
late final AnimationController _spinController;
|
|
late final AnimationController _blinkController;
|
|
StreamSubscription<AccelerometerEvent>? _accSubscription;
|
|
DateTime _selectedTime = DateTime.now();
|
|
bool _isSpinning = false;
|
|
bool _coin1Yang = true;
|
|
bool _coin2Yang = true;
|
|
bool _coin3Yang = true;
|
|
int _countdown = 0;
|
|
int _shakeCount = 0;
|
|
DateTime _lastShake = DateTime.fromMillisecondsSinceEpoch(0);
|
|
bool _spinLocked = false;
|
|
bool _submitting = false;
|
|
|
|
final GlobalKey<OnboardingState> _onboardingKey =
|
|
GlobalKey<OnboardingState>();
|
|
final ScrollController _scrollController = ScrollController();
|
|
final GlobalKey _step2TargetKey = GlobalKey();
|
|
final GlobalKey _step3TargetKey = GlobalKey();
|
|
final GlobalKey _step4TargetKey = GlobalKey();
|
|
final FocusNode _step1Focus = FocusNode();
|
|
final FocusNode _step2Focus = FocusNode();
|
|
final FocusNode _step3Focus = FocusNode();
|
|
final FocusNode _step4Focus = FocusNode();
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedTime = widget.params.divinationTime;
|
|
_spinController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 500),
|
|
);
|
|
_blinkController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 500),
|
|
)..repeat(reverse: true);
|
|
_listenShake();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_accSubscription?.cancel();
|
|
_scrollController.dispose();
|
|
_spinController.dispose();
|
|
_blinkController.dispose();
|
|
_step1Focus.dispose();
|
|
_step2Focus.dispose();
|
|
_step3Focus.dispose();
|
|
_step4Focus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
return Scaffold(
|
|
backgroundColor: colors.surface,
|
|
appBar: AppBar(
|
|
title: Text(l10n.autoScreenTitle),
|
|
centerTitle: true,
|
|
backgroundColor: colors.surface,
|
|
surfaceTintColor: colors.surface,
|
|
),
|
|
body: _buildBody(context, l10n),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
|
|
final steps = [
|
|
OnboardingStep(
|
|
focusNode: _step1Focus,
|
|
titleText: l10n.autoGuideStep1Title,
|
|
bodyText: l10n.autoGuideStep1Body,
|
|
titleTextColor: Colors.white,
|
|
bodyTextColor: Colors.white,
|
|
hasArrow: false,
|
|
hasLabelBox: true,
|
|
fullscreen: true,
|
|
overlayColor: Colors.black.withValues(alpha: 0.7),
|
|
),
|
|
OnboardingStep(
|
|
focusNode: _step2Focus,
|
|
titleText: l10n.autoGuideStep2Title,
|
|
bodyText: l10n.autoGuideStep2Body,
|
|
titleTextColor: Colors.white,
|
|
bodyTextColor: Colors.white,
|
|
hasArrow: true,
|
|
hasLabelBox: true,
|
|
arrowPosition: ArrowPosition.top,
|
|
overlayColor: Colors.black.withValues(alpha: 0.7),
|
|
delay: const Duration(milliseconds: 320),
|
|
),
|
|
OnboardingStep(
|
|
focusNode: _step3Focus,
|
|
titleText: l10n.autoGuideStep3Title,
|
|
bodyText: l10n.autoGuideStep3Body,
|
|
titleTextColor: Colors.white,
|
|
bodyTextColor: Colors.white,
|
|
hasArrow: true,
|
|
hasLabelBox: true,
|
|
arrowPosition: ArrowPosition.top,
|
|
overlayColor: Colors.black.withValues(alpha: 0.7),
|
|
delay: const Duration(milliseconds: 320),
|
|
),
|
|
OnboardingStep(
|
|
focusNode: _step4Focus,
|
|
titleText: l10n.autoGuideStep4Title,
|
|
bodyText: l10n.autoGuideStep4Body,
|
|
titleTextColor: Colors.white,
|
|
bodyTextColor: Colors.white,
|
|
hasArrow: true,
|
|
hasLabelBox: true,
|
|
arrowPosition: ArrowPosition.top,
|
|
overlayColor: Colors.black.withValues(alpha: 0.7),
|
|
delay: const Duration(milliseconds: 320),
|
|
),
|
|
];
|
|
|
|
return Onboarding(
|
|
key: _onboardingKey,
|
|
steps: steps,
|
|
onChanged: _onGuideStepChanged,
|
|
child: SingleChildScrollView(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(AppSpacing.xl),
|
|
child: Column(
|
|
children: [
|
|
_InstructionCard(onTap: _showGuide),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_TimeSelectorCard(
|
|
selectedTime: _selectedTime,
|
|
onPickTime: _pickTime,
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Container(
|
|
key: _step2TargetKey,
|
|
child: Focus(
|
|
focusNode: _step2Focus,
|
|
child: _YaoPickerCard(
|
|
isSpinning: _isSpinning,
|
|
coin1Yang: _coin1Yang,
|
|
coin2Yang: _coin2Yang,
|
|
coin3Yang: _coin3Yang,
|
|
spinController: _spinController,
|
|
countdown: _countdown,
|
|
shakeCount: _shakeCount,
|
|
canShake: _canShake,
|
|
onStartShake: _startSpin,
|
|
buttonText: _buttonText(l10n),
|
|
statusText: _statusText(l10n),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Container(
|
|
key: _step3TargetKey,
|
|
child: Focus(
|
|
focusNode: _step3Focus,
|
|
child: _HexagramCard(yaoStates: _yaoStates),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Container(
|
|
key: _step4TargetKey,
|
|
child: Focus(
|
|
focusNode: _step4Focus,
|
|
child: _ResolveButton(
|
|
enabled: _shakeCount >= 6 && !_submitting,
|
|
onPressed: _submitRun,
|
|
blinkAnimation: _blinkController,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool get _canShake => !_isSpinning && _shakeCount < 6;
|
|
|
|
String _buttonText(AppLocalizations l10n) {
|
|
if (_isSpinning) return l10n.autoShaking;
|
|
if (_shakeCount == 0) return l10n.autoStartShake;
|
|
if (_shakeCount < 6) return l10n.autoContinueShake;
|
|
return l10n.autoFinishShake;
|
|
}
|
|
|
|
String _statusText(AppLocalizations l10n) {
|
|
if (_isSpinning && _countdown > 0) {
|
|
return l10n.autoShakeCountdown(_countdown);
|
|
}
|
|
if (_shakeCount >= 6) {
|
|
return l10n.autoShakeComplete;
|
|
}
|
|
return l10n.autoShakeRemaining(6 - _shakeCount);
|
|
}
|
|
|
|
void _listenShake() {
|
|
_accSubscription = accelerometerEventStream().listen((event) {
|
|
if (!_canShake) return;
|
|
final acc =
|
|
sqrt(event.x * event.x + event.y * event.y + event.z * event.z) - 9.8;
|
|
final now = DateTime.now();
|
|
if (acc > 15 && now.difference(_lastShake).inMilliseconds > 1500) {
|
|
_lastShake = now;
|
|
_startSpin();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _startSpin() async {
|
|
if (!_canShake || _spinLocked) return;
|
|
_spinLocked = true;
|
|
setState(() {
|
|
_isSpinning = true;
|
|
_countdown = 3;
|
|
});
|
|
try {
|
|
await _vibrateStrong();
|
|
if (!mounted) return;
|
|
_spinController.repeat();
|
|
for (int i = 3; i > 0; i--) {
|
|
await Future<void>.delayed(const Duration(seconds: 1));
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_countdown = i - 1;
|
|
});
|
|
}
|
|
if (!mounted) return;
|
|
final c1 = _random.nextBool();
|
|
final c2 = _random.nextBool();
|
|
final c3 = _random.nextBool();
|
|
final ziCount = [c1, c2, c3].where((v) => v).length;
|
|
final yao = YaoCoinConverter.fromZiCount(ziCount);
|
|
setState(() {
|
|
_isSpinning = false;
|
|
_coin1Yang = c1;
|
|
_coin2Yang = c2;
|
|
_coin3Yang = c3;
|
|
if (_shakeCount < 6) {
|
|
_yaoStates[_shakeCount] = yao;
|
|
_shakeCount++;
|
|
}
|
|
});
|
|
_spinController
|
|
..stop()
|
|
..reset();
|
|
await _vibrateStrong();
|
|
} finally {
|
|
_spinLocked = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _pickTime() async {
|
|
final result = await showDateTimePickerBottomSheet(
|
|
context: context,
|
|
initialDateTime: _selectedTime,
|
|
minDateTime: DateTime(2000),
|
|
maxDateTime: DateTime(2100),
|
|
);
|
|
if (result == null || !mounted) return;
|
|
setState(() {
|
|
_selectedTime = result;
|
|
});
|
|
}
|
|
|
|
Future<void> _submitRun() async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
PointsBalanceData points;
|
|
try {
|
|
points = await widget.runService.getPointsBalance();
|
|
} catch (_) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
Toast.show(context, l10n.errorRequestGeneric, type: ToastType.error);
|
|
return;
|
|
}
|
|
if (!points.canRun || points.availableBalance < points.runCost) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
Toast.show(context, l10n.toastCoinInsufficient, type: ToastType.warning);
|
|
return;
|
|
}
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
final shouldStart = await showDialog<bool>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AppModalDialog(
|
|
title: l10n.divinationCostDialogTitle,
|
|
message: l10n.divinationCostDialogBody(
|
|
points.runCost,
|
|
points.availableBalance,
|
|
),
|
|
iconWidget: const GuaIcon(),
|
|
actions: [
|
|
AppModalDialogAction(
|
|
label: l10n.cancel,
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
),
|
|
AppModalDialogAction(
|
|
label: l10n.divinationCostDialogConfirm,
|
|
primary: true,
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (shouldStart != true) {
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_submitting = true;
|
|
});
|
|
try {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
await Navigator.of(context).push<void>(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => DivinationProcessingScreen(
|
|
params: widget.params.copyWith(divinationTime: _selectedTime),
|
|
yaoStates: _yaoStates,
|
|
runService: widget.runService,
|
|
divinationApi: widget.divinationApi,
|
|
onCompleted: widget.onCompleted,
|
|
),
|
|
),
|
|
);
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_submitting = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _vibrateStrong() async {
|
|
if (!widget.params.allowVibration) {
|
|
return;
|
|
}
|
|
final hasVibrator = await Vibration.hasVibrator();
|
|
if (hasVibrator == true) {
|
|
await Vibration.vibrate(duration: 280, amplitude: 255);
|
|
return;
|
|
}
|
|
await HapticFeedback.heavyImpact();
|
|
}
|
|
|
|
void _showGuide() {
|
|
_scrollToGuideStep(0);
|
|
Future<void>.delayed(const Duration(milliseconds: 120), () {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
_onboardingKey.currentState?.show();
|
|
});
|
|
}
|
|
|
|
void _onGuideStepChanged(int currentIndex) {
|
|
_scrollToGuideStep(currentIndex + 1);
|
|
}
|
|
|
|
void _scrollToGuideStep(int stepIndex) {
|
|
final GlobalKey? targetKey = switch (stepIndex) {
|
|
2 => _step3TargetKey,
|
|
3 => _step4TargetKey,
|
|
_ => null,
|
|
};
|
|
final targetContext = targetKey?.currentContext;
|
|
if (targetContext == null) {
|
|
return;
|
|
}
|
|
Scrollable.ensureVisible(
|
|
targetContext,
|
|
duration: const Duration(milliseconds: 260),
|
|
curve: Curves.easeOut,
|
|
alignment: 0.12,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InstructionCard extends StatelessWidget {
|
|
const _InstructionCard({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return DivinationInstructionCard(
|
|
text: l10n.autoShakeInstruction,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimeSelectorCard extends StatelessWidget {
|
|
const _TimeSelectorCard({
|
|
required this.selectedTime,
|
|
required this.onPickTime,
|
|
});
|
|
|
|
final DateTime selectedTime;
|
|
final VoidCallback onPickTime;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
color: colors.surface,
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.autoSelectTime,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: colors.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
DateFormat.yMd(
|
|
Localizations.localeOf(context).toString(),
|
|
).add_Hm().format(selectedTime),
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
OutlinedButton(
|
|
onPressed: onPickTime,
|
|
child: Text(l10n.divinationModify),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _YaoPickerCard extends StatelessWidget {
|
|
const _YaoPickerCard({
|
|
required this.isSpinning,
|
|
required this.coin1Yang,
|
|
required this.coin2Yang,
|
|
required this.coin3Yang,
|
|
required this.spinController,
|
|
required this.countdown,
|
|
required this.shakeCount,
|
|
required this.canShake,
|
|
required this.onStartShake,
|
|
required this.buttonText,
|
|
required this.statusText,
|
|
});
|
|
|
|
final bool isSpinning;
|
|
final bool coin1Yang;
|
|
final bool coin2Yang;
|
|
final bool coin3Yang;
|
|
final AnimationController spinController;
|
|
final int countdown;
|
|
final int shakeCount;
|
|
final bool canShake;
|
|
final VoidCallback onStartShake;
|
|
final String buttonText;
|
|
final String statusText;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
color: colors.surface,
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
l10n.autoCoinDivination,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: colors.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
Text(
|
|
statusText,
|
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
|
color: colors.primary,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
children: [
|
|
_CoinColumn(
|
|
isSpinning: isSpinning,
|
|
isYang: coin1Yang,
|
|
spinController: spinController,
|
|
seed: 1,
|
|
),
|
|
_CoinColumn(
|
|
isSpinning: isSpinning,
|
|
isYang: coin2Yang,
|
|
spinController: spinController,
|
|
seed: 2,
|
|
),
|
|
_CoinColumn(
|
|
isSpinning: isSpinning,
|
|
isYang: coin3Yang,
|
|
spinController: spinController,
|
|
seed: 3,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
FilledButton(
|
|
onPressed: canShake ? onStartShake : null,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: isSpinning
|
|
? colors.surfaceContainerHighest
|
|
: colors.primary,
|
|
fixedSize: const Size(120, 40),
|
|
),
|
|
child: Text(buttonText),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CoinColumn extends StatelessWidget {
|
|
const _CoinColumn({
|
|
required this.isSpinning,
|
|
required this.isYang,
|
|
required this.spinController,
|
|
required this.seed,
|
|
});
|
|
|
|
final bool isSpinning;
|
|
final bool isYang;
|
|
final AnimationController spinController;
|
|
final int seed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
return Column(
|
|
children: [
|
|
_CoinFace(
|
|
isSpinning: isSpinning,
|
|
isYang: isYang,
|
|
spinController: spinController,
|
|
seed: seed,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
_coinFaceLabel(context, isYang),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: colors.onSurface,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _coinFaceLabel(BuildContext context, bool isYang) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return isYang ? l10n.autoCoinFaceZi : l10n.autoCoinFaceHua;
|
|
}
|
|
}
|
|
|
|
class _CoinFace extends StatelessWidget {
|
|
const _CoinFace({
|
|
required this.isSpinning,
|
|
required this.isYang,
|
|
required this.spinController,
|
|
required this.seed,
|
|
});
|
|
|
|
final bool isSpinning;
|
|
final bool isYang;
|
|
final AnimationController spinController;
|
|
final int seed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: spinController,
|
|
builder: (context, _) {
|
|
final progress = (spinController.value + (seed % 100) / 100) % 1;
|
|
final rotationY = isSpinning
|
|
? (progress < 0.5 ? progress * 2 * 180 : (1 - progress) * 2 * 180)
|
|
: (isYang ? 0 : 180);
|
|
final showingYang = isSpinning ? rotationY < 90 : isYang;
|
|
final image = showingYang
|
|
? 'assets/images/qigua/zi.jpg'
|
|
: 'assets/images/qigua/hua.jpg';
|
|
return Transform(
|
|
alignment: Alignment.center,
|
|
transform: Matrix4.identity()
|
|
..setEntry(3, 2, 0.001)
|
|
..rotateY(rotationY * pi / 180),
|
|
child: ClipOval(
|
|
child: Image.asset(image, width: 80, height: 80, fit: BoxFit.cover),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HexagramCard extends StatelessWidget {
|
|
const _HexagramCard({required this.yaoStates});
|
|
|
|
final List<YaoType> yaoStates;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Card(
|
|
margin: EdgeInsets.zero,
|
|
color: colors.surface,
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.md),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
child: Column(
|
|
children: [
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
l10n.autoHexagramForming,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: colors.primary,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.md),
|
|
for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]),
|
|
const SizedBox(height: AppSpacing.xs),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _YaoRow extends StatelessWidget {
|
|
const _YaoRow({required this.index, required this.type});
|
|
|
|
final int index;
|
|
final YaoType type;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
|
|
child: YaoLineRow(
|
|
name: DivinationTerms.yaoName(l10n, index),
|
|
type: type,
|
|
showChangeMark: true,
|
|
lineHeight: 8,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ResolveButton extends StatelessWidget {
|
|
const _ResolveButton({
|
|
required this.enabled,
|
|
required this.onPressed,
|
|
required this.blinkAnimation,
|
|
});
|
|
|
|
final bool enabled;
|
|
final VoidCallback onPressed;
|
|
final Animation<double> blinkAnimation;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final colors = Theme.of(context).colorScheme;
|
|
return AnimatedBuilder(
|
|
animation: blinkAnimation,
|
|
builder: (context, _) {
|
|
final base = colors.primary;
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: FilledButton(
|
|
onPressed: enabled ? onPressed : null,
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: enabled
|
|
? base.withValues(alpha: 0.6 + blinkAnimation.value * 0.4)
|
|
: base,
|
|
),
|
|
child: Text(l10n.autoStartResolve),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|