| import 'package:flutter/material.dart'; |
| import 'package:google_fonts/google_fonts.dart'; |
|
|
| import '../../theme/app_theme.dart'; |
| import '../../widgets/furi_text.dart'; |
| import '../../widgets/tts_button.dart'; |
|
|
| class DialogueOption { |
| DialogueOption({ |
| required this.text, |
| this.textSegments, |
| required this.isCorrect, |
| required this.feedback, |
| }); |
| final String text; |
| final List<FuriSegment>? textSegments; |
| final bool isCorrect; |
| final String feedback; |
|
|
| factory DialogueOption.fromJson(Map<String, dynamic> j) => DialogueOption( |
| text: (j['text'] ?? '') as String, |
| textSegments: parseFuriSegments(j['textSegments']), |
| isCorrect: (j['isCorrect'] ?? false) as bool, |
| feedback: (j['feedback'] ?? '') as String, |
| ); |
| } |
|
|
| class DialogueTurn { |
| DialogueTurn({ |
| required this.type, |
| required this.text, |
| this.textSegments, |
| this.translation, |
| this.speakerName, |
| this.options, |
| }); |
|
|
| final String type; |
| final String text; |
| final List<FuriSegment>? textSegments; |
| final String? translation; |
| final String? speakerName; |
| final List<DialogueOption>? options; |
|
|
| factory DialogueTurn.fromJson(Map<String, dynamic> j) => DialogueTurn( |
| type: (j['type'] ?? 'narration') as String, |
| text: (j['text'] ?? '') as String, |
| textSegments: parseFuriSegments(j['textSegments']), |
| translation: j['translation'] as String?, |
| speakerName: j['speakerName'] as String?, |
| options: j['options'] is List |
| ? (j['options'] as List).map((o) => DialogueOption.fromJson(o as Map<String, dynamic>)).toList() |
| : null, |
| ); |
| } |
|
|
| class DialogueFull { |
| DialogueFull({ |
| required this.id, |
| required this.title, |
| required this.scenario, |
| required this.level, |
| required this.turns, |
| }); |
|
|
| final String id; |
| final String title; |
| final String scenario; |
| final String level; |
| final List<DialogueTurn> turns; |
|
|
| factory DialogueFull.fromJson(Map<String, dynamic> j) => DialogueFull( |
| id: (j['id'] ?? '') as String, |
| title: (j['title'] ?? '') as String, |
| scenario: (j['scenario'] ?? '') as String, |
| level: (j['level'] ?? '') as String, |
| turns: ((j['turns'] as List?) ?? []).map((t) => DialogueTurn.fromJson(t as Map<String, dynamic>)).toList(), |
| ); |
| } |
|
|
| class DialogueLessonView extends StatefulWidget { |
| const DialogueLessonView({super.key, required this.dialogue, required this.lang, this.onMoreDialogues}); |
| final DialogueFull dialogue; |
| final String lang; |
| final VoidCallback? onMoreDialogues; |
|
|
| @override |
| State<DialogueLessonView> createState() => _DialogueLessonViewState(); |
| } |
|
|
| class _DialogueLessonViewState extends State<DialogueLessonView> { |
| bool _started = false; |
| int _turnIndex = 0; |
| int _score = 0; |
| final Map<int, int> _chosen = {}; |
| final Map<int, bool> _showTranslation = {}; |
| bool _completed = false; |
|
|
| List<DialogueTurn> get _turns => widget.dialogue.turns; |
| int get _totalChoices => _turns.where((turn) => turn.type == 'user_choice').length; |
|
|
| void _advance() { |
| if (_turnIndex + 1 >= _turns.length) { |
| setState(() => _completed = true); |
| } else { |
| setState(() => _turnIndex++); |
| } |
| } |
|
|
| void _handleChoice(int turnIdx, int optIdx) { |
| if (_chosen.containsKey(turnIdx)) return; |
| final correct = _turns[turnIdx].options![optIdx].isCorrect; |
| setState(() { |
| _chosen[turnIdx] = optIdx; |
| if (correct) _score++; |
| }); |
| Future.delayed(const Duration(milliseconds: 1200), _advance); |
| } |
|
|
| void _restart() { |
| setState(() { |
| _started = false; |
| _turnIndex = 0; |
| _score = 0; |
| _chosen.clear(); |
| _showTranslation.clear(); |
| _completed = false; |
| }); |
| } |
|
|
| @override |
| Widget build(BuildContext context) { |
| return SafeArea( |
| child: Center( |
| child: ConstrainedBox( |
| constraints: const BoxConstraints(maxWidth: 760), |
| child: AnimatedSwitcher( |
| duration: const Duration(milliseconds: 300), |
| child: !_started |
| ? _buildStart() |
| : _completed |
| ? _buildCompletion() |
| : _buildConversation(), |
| ), |
| ), |
| ), |
| ); |
| } |
|
|
| Widget _buildStart() { |
| return ListView( |
| key: const ValueKey('start'), |
| padding: const EdgeInsets.all(20), |
| children: [ |
| Container( |
| padding: const EdgeInsets.all(20), |
| decoration: brutalCard(), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| _Eyebrow('SCENARIO', color: kBrutalTeal), |
| const SizedBox(height: 8), |
| Text( |
| widget.dialogue.scenario, |
| style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 20, fontWeight: FontWeight.w900, height: 1.35), |
| ), |
| const SizedBox(height: 18), |
| Wrap( |
| spacing: 8, |
| runSpacing: 8, |
| children: [ |
| _Chip('${_turns.length} turns'), |
| _Chip('$_totalChoices choices'), |
| _Chip(widget.dialogue.level), |
| ], |
| ), |
| ], |
| ), |
| ), |
| const SizedBox(height: 24), |
| FilledButton.icon( |
| onPressed: () => setState(() => _started = true), |
| icon: const Icon(Icons.play_arrow_rounded), |
| label: const Text('START DIALOGUE'), |
| ), |
| ], |
| ); |
| } |
|
|
| Widget _buildCompletion() { |
| final pct = _totalChoices > 0 ? (_score / _totalChoices * 100).round() : 100; |
| return ListView( |
| key: const ValueKey('done'), |
| padding: const EdgeInsets.all(24), |
| children: [ |
| Container( |
| padding: const EdgeInsets.all(28), |
| decoration: brutalCard(), |
| child: Column( |
| children: [ |
| Container( |
| width: 78, |
| height: 78, |
| decoration: BoxDecoration( |
| color: kBrutalYellow, |
| borderRadius: BorderRadius.circular(kBrutalRadius), |
| border: Border.all(color: kBrutalBlack, width: 2), |
| boxShadow: const [ |
| BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0), |
| ], |
| ), |
| child: const Icon(Icons.emoji_events_rounded, color: kBrutalBlack, size: 44), |
| ), |
| const SizedBox(height: 18), |
| Text('$pct%', style: GoogleFonts.almarai(color: kBrutalTeal, fontSize: 48, fontWeight: FontWeight.w900)), |
| Text('$_score of $_totalChoices correct', style: GoogleFonts.almarai(color: kBrutalMuted, fontWeight: FontWeight.w900)), |
| const SizedBox(height: 24), |
| FilledButton.icon(onPressed: _restart, icon: const Icon(Icons.refresh_rounded), label: const Text('TRY AGAIN')), |
| const SizedBox(height: 12), |
| OutlinedButton( |
| onPressed: widget.onMoreDialogues ?? () => Navigator.of(context).maybePop(), |
| child: const Text('MORE DIALOGUES'), |
| ), |
| ], |
| ), |
| ), |
| ], |
| ); |
| } |
|
|
| Widget _buildConversation() { |
| final progress = (_turnIndex + 1) / _turns.length; |
| return Column( |
| key: const ValueKey('chat'), |
| children: [ |
| Padding( |
| padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), |
| child: Container( |
| padding: const EdgeInsets.all(12), |
| decoration: brutalCard(), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| ClipRRect( |
| borderRadius: BorderRadius.circular(kBrutalRadius), |
| child: LinearProgressIndicator( |
| value: progress, |
| minHeight: 12, |
| backgroundColor: const Color(0xFFE5E5E5), |
| color: kBrutalTeal, |
| ), |
| ), |
| const SizedBox(height: 8), |
| _Eyebrow('STEP ${_turnIndex + 1} OF ${_turns.length}'), |
| ], |
| ), |
| ), |
| ), |
| Expanded( |
| child: ListView.builder( |
| padding: const EdgeInsets.all(16), |
| itemCount: _turnIndex + 1, |
| itemBuilder: (context, idx) { |
| final turn = _turns[idx]; |
| return Padding( |
| padding: const EdgeInsets.only(bottom: 14), |
| child: _buildTurn(idx, turn), |
| ); |
| }, |
| ), |
| ), |
| ], |
| ); |
| } |
|
|
| Widget _buildTurn(int idx, DialogueTurn turn) { |
| if (turn.type == 'narration') { |
| final isCurrent = idx == _turnIndex; |
| return Container( |
| padding: const EdgeInsets.all(16), |
| decoration: brutalCard(color: kBrutalCream), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.stretch, |
| children: [ |
| Text( |
| turn.text, |
| textAlign: TextAlign.center, |
| style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 14, fontWeight: FontWeight.w800, fontStyle: FontStyle.italic), |
| ), |
| if (isCurrent) ...[ |
| const SizedBox(height: 12), |
| FilledButton(onPressed: _advance, child: const Text('CONTINUE')), |
| ], |
| ], |
| ), |
| ); |
| } |
|
|
| if (turn.type == 'character') { |
| final isCurrent = idx == _turnIndex; |
| final initial = (turn.speakerName?.isNotEmpty ?? false) ? turn.speakerName![0].toUpperCase() : '?'; |
| return Row( |
| crossAxisAlignment: CrossAxisAlignment.end, |
| children: [ |
| Container( |
| width: 54, |
| height: 54, |
| decoration: BoxDecoration( |
| color: kBrutalBlue, |
| borderRadius: BorderRadius.circular(kBrutalRadius), |
| border: Border.all(color: kBrutalBlack, width: 2), |
| boxShadow: const [ |
| BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0), |
| ], |
| ), |
| child: Center( |
| child: Text(initial, style: GoogleFonts.almarai(color: kBrutalWhite, fontWeight: FontWeight.w900, fontSize: 22)), |
| ), |
| ), |
| const SizedBox(width: 10), |
| Expanded( |
| child: Container( |
| padding: const EdgeInsets.all(16), |
| decoration: brutalCard(), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| if (turn.speakerName != null) _Eyebrow(turn.speakerName!.toUpperCase(), color: kBrutalBlue), |
| if (turn.speakerName != null) const SizedBox(height: 8), |
| Row( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| Expanded( |
| child: FuriText( |
| text: turn.text, |
| segments: turn.textSegments, |
| fontSize: 17, |
| fontWeight: FontWeight.w900, |
| ), |
| ), |
| const SizedBox(width: 8), |
| TtsButton(text: turn.text, lang: widget.lang, size: 32), |
| ], |
| ), |
| if (turn.translation != null) ...[ |
| const SizedBox(height: 12), |
| GestureDetector( |
| onTap: () => setState(() => _showTranslation[idx] = !(_showTranslation[idx] ?? false)), |
| child: Row( |
| mainAxisSize: MainAxisSize.min, |
| children: [ |
| Icon((_showTranslation[idx] ?? false) ? Icons.visibility_off : Icons.visibility, color: kBrutalBlue, size: 16), |
| const SizedBox(width: 6), |
| Text( |
| (_showTranslation[idx] ?? false) ? turn.translation! : 'Show translation', |
| style: GoogleFonts.almarai(color: kBrutalBlue, fontWeight: FontWeight.w900, fontSize: 13), |
| ), |
| ], |
| ), |
| ), |
| ], |
| if (isCurrent && !_chosen.containsKey(idx)) ...[ |
| const SizedBox(height: 14), |
| FilledButton(onPressed: _advance, child: const Text('CONTINUE')), |
| ], |
| ], |
| ), |
| ), |
| ), |
| ], |
| ); |
| } |
|
|
| if (turn.type == 'user_choice') { |
| return Container( |
| padding: const EdgeInsets.all(16), |
| decoration: brutalCard(), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| Text( |
| turn.text.isNotEmpty ? turn.text : 'What do you say?', |
| style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 18, fontWeight: FontWeight.w900), |
| ), |
| const SizedBox(height: 12), |
| ...List.generate(turn.options?.length ?? 0, (optionIndex) { |
| final option = turn.options![optionIndex]; |
| final made = _chosen[idx]; |
| final isChosen = made == optionIndex; |
| final isCorrect = option.isCorrect; |
| Color borderColor = kBrutalBlack; |
| Color bg = kBrutalWhite; |
| Color textColor = kBrutalSlate; |
|
|
| if (made != null) { |
| if (isCorrect) { |
| bg = kBrutalGreen; |
| textColor = kBrutalWhite; |
| } else if (isChosen) { |
| bg = kBrutalRed; |
| textColor = kBrutalWhite; |
| } else { |
| textColor = kBrutalMuted; |
| } |
| } |
|
|
| return Padding( |
| padding: const EdgeInsets.only(bottom: 10), |
| child: GestureDetector( |
| onTap: made == null ? () => _handleChoice(idx, optionIndex) : null, |
| child: Container( |
| width: double.infinity, |
| padding: const EdgeInsets.all(14), |
| decoration: BoxDecoration( |
| color: bg, |
| borderRadius: BorderRadius.circular(kBrutalRadius), |
| border: Border.all(color: borderColor, width: 2), |
| boxShadow: const [BoxShadow(color: kBrutalBlack, offset: Offset(3, 3), blurRadius: 0)], |
| ), |
| child: Column( |
| crossAxisAlignment: CrossAxisAlignment.start, |
| children: [ |
| FuriText( |
| text: option.text, |
| segments: option.textSegments, |
| fontSize: 15, |
| color: textColor, |
| fontWeight: FontWeight.w900, |
| ), |
| if (made != null && isChosen && option.feedback.isNotEmpty) ...[ |
| const SizedBox(height: 6), |
| Text(option.feedback, style: GoogleFonts.almarai(color: textColor, fontSize: 12, fontWeight: FontWeight.w900)), |
| ], |
| ], |
| ), |
| ), |
| ), |
| ); |
| }), |
| ], |
| ), |
| ); |
| } |
|
|
| return const SizedBox.shrink(); |
| } |
| } |
|
|
| class _Chip extends StatelessWidget { |
| const _Chip(this.label); |
| final String label; |
|
|
| @override |
| Widget build(BuildContext context) { |
| return Container( |
| padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), |
| decoration: BoxDecoration( |
| color: kBrutalWhite, |
| borderRadius: BorderRadius.circular(99), |
| border: Border.all(color: kBrutalBlack, width: 2), |
| ), |
| child: Text(label, style: GoogleFonts.almarai(color: kBrutalSlate, fontSize: 12, fontWeight: FontWeight.w900)), |
| ); |
| } |
| } |
|
|
| class _Eyebrow extends StatelessWidget { |
| const _Eyebrow(this.text, {this.color = kBrutalMuted}); |
| final String text; |
| final Color color; |
|
|
| @override |
| Widget build(BuildContext context) { |
| return Text( |
| text, |
| style: GoogleFonts.almarai(color: color, fontSize: 11, fontWeight: FontWeight.w900, letterSpacing: 1.2), |
| ); |
| } |
| } |
|
|