524 lines
15 KiB
Dart
524 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../../../../app/di/injection.dart';
|
|
import '../../../../core/auth/session_store.dart';
|
|
import '../../../../data/network/api_client.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/toast/toast.dart';
|
|
import '../../../../shared/widgets/toast/toast_type.dart';
|
|
import '../../data/apis/divination_api.dart';
|
|
import '../../data/models/divination_params.dart';
|
|
import '../../data/models/divination_result.dart';
|
|
import '../../data/services/divination_run_service.dart';
|
|
import 'auto_divination_screen.dart';
|
|
import 'manual_divination_screen.dart';
|
|
|
|
class DivinationScreen extends StatefulWidget {
|
|
const DivinationScreen({
|
|
super.key,
|
|
required this.sessionStore,
|
|
required this.userId,
|
|
required this.onCompleted,
|
|
this.runServiceOverride,
|
|
});
|
|
|
|
final SessionStore sessionStore;
|
|
final String userId;
|
|
final Future<void> Function(DivinationResultData result) onCompleted;
|
|
final DivinationRunService? runServiceOverride;
|
|
|
|
@override
|
|
State<DivinationScreen> createState() => _DivinationScreenState();
|
|
}
|
|
|
|
class _DivinationScreenState extends State<DivinationScreen> {
|
|
late DivinationParams _params;
|
|
final TextEditingController _questionController = TextEditingController();
|
|
late final DivinationRunService _runService;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final apiClient = ApiClient(
|
|
baseUrl: appDependencies.backendUrl,
|
|
tokenProvider: widget.sessionStore.getToken,
|
|
);
|
|
_runService =
|
|
widget.runServiceOverride ??
|
|
DivinationRunService(api: DivinationApi(apiClient: apiClient));
|
|
_params = DivinationParams(
|
|
method: DivinationMethod.manual,
|
|
questionType: QuestionType.career,
|
|
question: '',
|
|
divinationTime: DateTime.now(),
|
|
coinBalance: 0,
|
|
userId: widget.userId,
|
|
);
|
|
_questionController.addListener(_syncQuestion);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_questionController
|
|
..removeListener(_syncQuestion)
|
|
..dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _syncQuestion() {
|
|
_params = _params.copyWith(question: _questionController.text.trim());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
return Scaffold(
|
|
backgroundColor: colors.surface,
|
|
appBar: AppBar(
|
|
backgroundColor: colors.surface,
|
|
surfaceTintColor: colors.surface,
|
|
title: Text(l10n.divinationScreenTitle),
|
|
centerTitle: true,
|
|
),
|
|
body: _buildBody(context, l10n),
|
|
);
|
|
}
|
|
|
|
Widget _buildBody(BuildContext context, AppLocalizations l10n) {
|
|
return GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.xl,
|
|
AppSpacing.lg,
|
|
AppSpacing.xl,
|
|
AppSpacing.xl,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_GuideEntryCard(onTap: () => _showGuide(context, l10n)),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
_MethodSection(
|
|
selected: _params.method,
|
|
onChanged: (method) {
|
|
setState(() {
|
|
_params = _params.copyWith(method: method);
|
|
});
|
|
},
|
|
l10n: l10n,
|
|
),
|
|
const SizedBox(height: AppSpacing.xl),
|
|
Text(
|
|
l10n.divinationQuestionTypePrompt,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
_QuestionTypeSelector(
|
|
selected: _params.questionType,
|
|
onChanged: (type) {
|
|
setState(() {
|
|
_params = _params.copyWith(questionType: type);
|
|
});
|
|
},
|
|
l10n: l10n,
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Text(
|
|
l10n.divinationQuestionInputPrompt,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
_QuestionTextField(controller: _questionController, l10n: l10n),
|
|
const SizedBox(height: AppSpacing.xl),
|
|
_StartButton(onPressed: _onStart, l10n: l10n),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _onStart() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
if (_params.question.isEmpty) {
|
|
Toast.show(
|
|
context,
|
|
l10n.toastPleaseInputQuestion,
|
|
type: ToastType.warning,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_params.method == DivinationMethod.manual) {
|
|
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => ManualDivinationScreen(
|
|
params: nextParams,
|
|
runService: _runService,
|
|
onCompleted: widget.onCompleted,
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final nextParams = _params.copyWith(divinationTime: DateTime.now());
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => AutoDivinationScreen(
|
|
params: nextParams,
|
|
runService: _runService,
|
|
onCompleted: widget.onCompleted,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GuideEntryCard extends StatelessWidget {
|
|
const _GuideEntryCard({required this.onTap});
|
|
|
|
final VoidCallback onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return DivinationInstructionCard(
|
|
text: l10n.divinationRecommendManual,
|
|
onTap: onTap,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MethodSection extends StatelessWidget {
|
|
const _MethodSection({
|
|
required this.selected,
|
|
required this.onChanged,
|
|
required this.l10n,
|
|
});
|
|
|
|
final DivinationMethod selected;
|
|
final ValueChanged<DivinationMethod> onChanged;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l10n.divinationSelectMethod,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
IconButton(
|
|
onPressed: () => _showMethodTip(context, l10n),
|
|
icon: Icon(
|
|
Icons.help_outline,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
_MethodSegment(selected: selected, onChanged: onChanged, l10n: l10n),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MethodSegment extends StatelessWidget {
|
|
const _MethodSegment({
|
|
required this.selected,
|
|
required this.onChanged,
|
|
required this.l10n,
|
|
});
|
|
|
|
final DivinationMethod selected;
|
|
final ValueChanged<DivinationMethod> onChanged;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
return SizedBox(
|
|
height: 40,
|
|
child: Row(
|
|
children: [
|
|
_SegmentButton(
|
|
text: l10n.divinationManualMethod,
|
|
selected: selected == DivinationMethod.manual,
|
|
onTap: () => onChanged(DivinationMethod.manual),
|
|
color: colors.primary,
|
|
isLeft: true,
|
|
),
|
|
_SegmentButton(
|
|
text: l10n.divinationAutoMethod,
|
|
selected: selected == DivinationMethod.auto,
|
|
onTap: () => onChanged(DivinationMethod.auto),
|
|
color: colors.primary,
|
|
isRight: true,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SegmentButton extends StatelessWidget {
|
|
const _SegmentButton({
|
|
required this.text,
|
|
required this.selected,
|
|
required this.onTap,
|
|
required this.color,
|
|
this.isLeft = false,
|
|
this.isRight = false,
|
|
});
|
|
|
|
final String text;
|
|
final bool selected;
|
|
final VoidCallback onTap;
|
|
final Color color;
|
|
final bool isLeft;
|
|
final bool isRight;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.horizontal(
|
|
left: isLeft ? Radius.circular(AppRadius.sm) : Radius.zero,
|
|
right: isRight ? Radius.circular(AppRadius.sm) : Radius.zero,
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: selected ? color : color.withValues(alpha: 0),
|
|
borderRadius: BorderRadius.horizontal(
|
|
left: isLeft ? Radius.circular(AppRadius.sm) : Radius.zero,
|
|
right: isRight ? Radius.circular(AppRadius.sm) : Radius.zero,
|
|
),
|
|
border: Border.all(color: color),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
text,
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
color: selected ? Theme.of(context).colorScheme.onPrimary : color,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuestionTextField extends StatelessWidget {
|
|
const _QuestionTextField({required this.controller, required this.l10n});
|
|
|
|
final TextEditingController controller;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
return TextField(
|
|
controller: controller,
|
|
maxLines: 4,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.divinationQuestionInputHint,
|
|
filled: true,
|
|
fillColor: colors.surface,
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
borderSide: BorderSide(color: colors.outline),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
borderSide: BorderSide(color: colors.primary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StartButton extends StatelessWidget {
|
|
const _StartButton({required this.onPressed, required this.l10n});
|
|
|
|
final VoidCallback onPressed;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: colors.primary,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
),
|
|
onPressed: onPressed,
|
|
child: Text(l10n.divinationStartButton),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _showMethodTip(BuildContext context, AppLocalizations l10n) {
|
|
return showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AppModalDialog(
|
|
title: l10n.divinationMethodTipTitle,
|
|
message:
|
|
'${l10n.divinationMethodTipAuto}\n\n${l10n.divinationMethodTipManual}\n\n${l10n.divinationMethodTipRecommend}',
|
|
iconWidget: const GuaIcon(),
|
|
actions: [
|
|
AppModalDialogAction(
|
|
label: l10n.divinationIAcknowledge,
|
|
primary: true,
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _showGuide(BuildContext context, AppLocalizations l10n) {
|
|
return showDialog<void>(
|
|
context: context,
|
|
builder: (context) {
|
|
return DivinationGuideDialog(
|
|
title: l10n.divinationManualGuideTitle,
|
|
guideImages: const [
|
|
'assets/images/qigua/lc1.jpg',
|
|
'assets/images/qigua/lc2.jpg',
|
|
'assets/images/qigua/lc3.jpg',
|
|
'assets/images/qigua/lc4.jpg',
|
|
'assets/images/qigua/lc5.jpg',
|
|
],
|
|
instructionText: l10n.divinationManualGuideInstruction,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
class _QuestionTypeSelector extends StatelessWidget {
|
|
const _QuestionTypeSelector({
|
|
required this.selected,
|
|
required this.onChanged,
|
|
required this.l10n,
|
|
});
|
|
|
|
final QuestionType selected;
|
|
final ValueChanged<QuestionType> onChanged;
|
|
final AppLocalizations l10n;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
final types = <(QuestionType, String, IconData)>[
|
|
(QuestionType.career, l10n.questionTypeCareer, Icons.work),
|
|
(QuestionType.love, l10n.questionTypeLove, Icons.favorite),
|
|
(QuestionType.wealth, l10n.questionTypeWealth, Icons.attach_money),
|
|
(QuestionType.fortune, l10n.questionTypeFortune, Icons.trending_up),
|
|
(QuestionType.dream, l10n.questionTypeDream, Icons.bedtime),
|
|
(QuestionType.health, l10n.questionTypeHealth, Icons.health_and_safety),
|
|
(QuestionType.study, l10n.questionTypeStudy, Icons.school),
|
|
(QuestionType.search, l10n.questionTypeSearch, Icons.search),
|
|
(QuestionType.other, l10n.questionTypeOther, Icons.help),
|
|
];
|
|
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: types.map((item) {
|
|
final isSelected = selected == item.$1;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: AppSpacing.sm),
|
|
child: _TypeChip(
|
|
label: item.$2,
|
|
icon: item.$3,
|
|
isSelected: isSelected,
|
|
onTap: () => onChanged(item.$1),
|
|
selectedColor: colors.primary,
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TypeChip extends StatelessWidget {
|
|
const _TypeChip({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
required this.selectedColor,
|
|
});
|
|
|
|
final String label;
|
|
final IconData icon;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
final Color selectedColor;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
child: Container(
|
|
width: 92,
|
|
height: 45,
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? selectedColor.withValues(alpha: 0.1)
|
|
: colors.surface,
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
border: Border.all(
|
|
color: isSelected ? selectedColor : colors.outline,
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
size: 20,
|
|
color: isSelected ? selectedColor : colors.onSurface,
|
|
),
|
|
const SizedBox(width: AppSpacing.xs),
|
|
Text(
|
|
label,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: isSelected ? selectedColor : colors.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|