PraxaLing / mobile /lib /features /practice /dialogue_screen.dart
Reubencf's picture
Deploy PraxaLing: component refactor, bug fixes, unified neo-brutal design, Qwen3.5 everywhere
2992997
Raw
History Blame Contribute Delete
16.5 kB
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),
);
}
}