Commit cf85f694 authored by Administrator's avatar Administrator

Update 17 files via Son of Anton

parent 8f5af7a6
...@@ -11,9 +11,12 @@ import '../../features/dashboard/presentation/screens/dashboard_screen.dart'; ...@@ -11,9 +11,12 @@ import '../../features/dashboard/presentation/screens/dashboard_screen.dart';
import '../../features/dashboard/presentation/screens/profile_screen.dart'; import '../../features/dashboard/presentation/screens/profile_screen.dart';
import '../../features/chat/presentation/screens/chat_screen.dart'; import '../../features/chat/presentation/screens/chat_screen.dart';
import '../../features/akinator/presentation/screens/akinator_screen.dart'; import '../../features/akinator/presentation/screens/akinator_screen.dart';
import '../../features/quiz/presentation/screens/quiz_setup_screen.dart';
import '../../features/generator/presentation/screens/generator_screen.dart'; import '../../features/generator/presentation/screens/generator_screen.dart';
import '../../features/generator/presentation/screens/generator_results_screen.dart'; import '../../features/generator/presentation/screens/generator_results_screen.dart';
import '../../features/generator/data/datasources/generator_remote_datasource.dart'; import '../../features/generator/data/datasources/generator_remote_datasource.dart';
import '../../features/scenarios/presentation/screens/scenario_browser_screen.dart';
import '../../features/scenarios/presentation/screens/scenario_detail_screen.dart';
import '../../shared/bottom_nav_shell.dart'; import '../../shared/bottom_nav_shell.dart';
import '../../shared/more_menu_screen.dart'; import '../../shared/more_menu_screen.dart';
import '../../shared/settings_screen.dart'; import '../../shared/settings_screen.dart';
...@@ -60,7 +63,7 @@ class AppRouter { ...@@ -60,7 +63,7 @@ class AppRouter {
), ),
], ],
), ),
// ── Tab 1: Chat ── ✅ PHASE 3 // ── Tab 1: Chat ──
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
...@@ -69,7 +72,7 @@ class AppRouter { ...@@ -69,7 +72,7 @@ class AppRouter {
), ),
], ],
), ),
// ── Tab 2: Akinator ── ✅ PHASE 3 // ── Tab 2: Akinator ──
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
...@@ -78,16 +81,12 @@ class AppRouter { ...@@ -78,16 +81,12 @@ class AppRouter {
), ),
], ],
), ),
// ── Tab 3: Quiz ── // ── Tab 3: Quiz ── ✅ PHASE 4
StatefulShellBranch( StatefulShellBranch(
routes: [ routes: [
GoRoute( GoRoute(
path: '/app/quiz', path: '/app/quiz',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const QuizSetupScreen(),
icon: '📝',
title: 'Practice MCQ',
subtitle: 'Coming in Phase 4',
),
), ),
], ],
), ),
...@@ -98,15 +97,20 @@ class AppRouter { ...@@ -98,15 +97,20 @@ class AppRouter {
path: '/app/more', path: '/app/more',
builder: (_, __) => const MoreMenuScreen(), builder: (_, __) => const MoreMenuScreen(),
routes: [ routes: [
// ── Scenarios ── ✅ PHASE 4
GoRoute( GoRoute(
path: 'scenarios', path: 'scenarios',
builder: (_, __) => const PlaceholderScreen( builder: (_, __) => const ScenarioBrowserScreen(),
icon: '📚', routes: [
title: 'Scenario Browser', GoRoute(
subtitle: 'Coming in Phase 4', path: ':id',
), builder: (_, state) {
final id = state.pathParameters['id']!;
return ScenarioDetailScreen(scenarioId: id);
},
),
],
), ),
// ── Generator ── ✅ PHASE 3
GoRoute( GoRoute(
path: 'generator', path: 'generator',
builder: (_, __) => const GeneratorScreen(), builder: (_, __) => const GeneratorScreen(),
......
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class QuizOptionModel {
final String label;
final String text;
const QuizOptionModel({required this.label, required this.text});
factory QuizOptionModel.fromJson(Map<String, dynamic> json) {
return QuizOptionModel(
label: json['label'] as String,
text: json['text'] as String,
);
}
}
class QuizQuestionModel {
final String scenarioId;
final String title;
final String? shortDescription;
final String? scenarioParagraph;
final Map<String, dynamic>? givensTable;
final String? riskLevel;
final String? difficulty;
final String? eventType;
final List<QuizOptionModel> options;
final String correctLabel;
final String? bestAnswerRationale;
final Map<String, String> otherExplanations;
const QuizQuestionModel({
required this.scenarioId,
required this.title,
this.shortDescription,
this.scenarioParagraph,
this.givensTable,
this.riskLevel,
this.difficulty,
this.eventType,
required this.options,
required this.correctLabel,
this.bestAnswerRationale,
this.otherExplanations = const {},
});
factory QuizQuestionModel.fromJson(Map<String, dynamic> json) {
final opts = (json['options'] as List<dynamic>?)
?.map((e) => QuizOptionModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
final rawExplanations = json['other_explanations'];
final explanations = <String, String>{};
if (rawExplanations is Map) {
rawExplanations.forEach((k, v) {
explanations[k.toString()] = v.toString();
});
}
return QuizQuestionModel(
scenarioId: json['scenario_id'] as String,
title: json['title'] as String,
shortDescription: json['short_description'] as String?,
scenarioParagraph: json['scenario_paragraph'] as String?,
givensTable: json['givens_table'] as Map<String, dynamic>?,
riskLevel: json['risk_level'] as String?,
difficulty: json['difficulty'] as String?,
eventType: json['event_type'] as String?,
options: opts,
correctLabel: json['correct_label'] as String,
bestAnswerRationale: json['best_answer_rationale'] as String?,
otherExplanations: explanations,
);
}
/// Get the correct option's text
String get correctAnswerText {
final opt = options.where((o) => o.label == correctLabel).firstOrNull;
return opt?.text ?? '';
}
/// Get explanation for a selected answer text
String getExplanation(String selectedText) {
if (selectedText == correctAnswerText) {
return bestAnswerRationale ?? 'Correct!';
}
return otherExplanations[selectedText] ?? 'Incorrect choice.';
}
}
class QuizStartResponseModel {
final int quizId;
final int attemptId;
final int totalQuestions;
final List<QuizQuestionModel> questions;
const QuizStartResponseModel({
required this.quizId,
required this.attemptId,
required this.totalQuestions,
required this.questions,
});
factory QuizStartResponseModel.fromJson(Map<String, dynamic> json) {
return QuizStartResponseModel(
quizId: (json['quiz_id'] as num).toInt(),
attemptId: (json['attempt_id'] as num).toInt(),
totalQuestions: (json['total_questions'] as num).toInt(),
questions: (json['questions'] as List<dynamic>)
.map((e) => QuizQuestionModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
class QuizAnswerResultModel {
final String scenarioId;
final String selectedAnswer;
final String correctAnswer;
final bool isCorrect;
final String? explanation;
final String? correctRationale;
const QuizAnswerResultModel({
required this.scenarioId,
required this.selectedAnswer,
required this.correctAnswer,
required this.isCorrect,
this.explanation,
this.correctRationale,
});
factory QuizAnswerResultModel.fromJson(Map<String, dynamic> json) {
return QuizAnswerResultModel(
scenarioId: json['scenario_id'] as String,
selectedAnswer: json['selected_answer'] as String,
correctAnswer: json['correct_answer'] as String,
isCorrect: json['is_correct'] as bool,
explanation: json['explanation'] as String?,
correctRationale: json['correct_rationale'] as String?,
);
}
}
class QuizSubmitResultModel {
final int score;
final int correct;
final int total;
final int percentage;
final List<QuizAnswerResultModel> results;
const QuizSubmitResultModel({
required this.score,
required this.correct,
required this.total,
required this.percentage,
required this.results,
});
factory QuizSubmitResultModel.fromJson(Map<String, dynamic> json) {
return QuizSubmitResultModel(
score: (json['score'] as num).toInt(),
correct: (json['correct'] as num).toInt(),
total: (json['total'] as num).toInt(),
percentage: (json['percentage'] as num).toInt(),
results: (json['results'] as List<dynamic>)
.map((e) =>
QuizAnswerResultModel.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
class FilterOptionsModel {
final List<String> categories;
final List<String> difficulties;
final List<String> riskLevels;
const FilterOptionsModel({
this.categories = const [],
this.difficulties = const [],
this.riskLevels = const [],
});
factory FilterOptionsModel.fromJson(Map<String, dynamic> json) {
return FilterOptionsModel(
categories: List<String>.from(json['categories'] ?? []),
difficulties: List<String>.from(json['difficulties'] ?? []),
riskLevels: List<String>.from(json['risk_levels'] ?? []),
);
}
}
class QuizRemoteDatasource {
final Dio _dio;
QuizRemoteDatasource(this._dio);
Future<FilterOptionsModel> getFilters() async {
final response = await _dio.get(ApiEndpoints.filters);
return FilterOptionsModel.fromJson(response.data as Map<String, dynamic>);
}
Future<QuizStartResponseModel> startQuiz({
required int numQuestions,
String? difficulty,
String? category,
}) async {
final response = await _dio.post(
ApiEndpoints.quizStart,
data: {
'num_questions': numQuestions,
if (difficulty != null) 'difficulty': difficulty,
if (category != null) 'category': category,
},
);
return QuizStartResponseModel.fromJson(
response.data as Map<String, dynamic>);
}
Future<QuizSubmitResultModel> submitQuiz({
required int attemptId,
required List<Map<String, String>> answers,
}) async {
final response = await _dio.post(
ApiEndpoints.quizSubmit(attemptId),
data: answers,
);
return QuizSubmitResultModel.fromJson(
response.data as Map<String, dynamic>);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/quiz_repository.dart';
import '../datasources/quiz_remote_datasource.dart';
class QuizRepositoryImpl implements QuizRepository {
final QuizRemoteDatasource remote;
QuizRepositoryImpl(this.remote);
@override
Future<FilterOptionsModel> getFilters() async {
try {
return await remote.getFilters();
} on DioException catch (e) {
throw e.error ?? e;
}
}
@override
Future<QuizStartResponseModel> startQuiz({
required int numQuestions,
String? difficulty,
String? category,
}) async {
try {
return await remote.startQuiz(
numQuestions: numQuestions,
difficulty: difficulty,
category: category,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
@override
Future<QuizSubmitResultModel> submitQuiz({
required int attemptId,
required List<Map<String, String>> answers,
}) async {
try {
return await remote.submitQuiz(
attemptId: attemptId,
answers: answers,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
import '../../data/datasources/quiz_remote_datasource.dart';
abstract class QuizRepository {
Future<FilterOptionsModel> getFilters();
Future<QuizStartResponseModel> startQuiz({
required int numQuestions,
String? difficulty,
String? category,
});
Future<QuizSubmitResultModel> submitQuiz({
required int attemptId,
required List<Map<String, String>> answers,
});
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/dio_client.dart';
import '../../data/datasources/quiz_remote_datasource.dart';
import '../../data/repositories/quiz_repository_impl.dart';
import 'quiz_state.dart';
class QuizCubit extends Cubit<QuizState> {
final QuizRepositoryImpl _repo;
QuizCubit()
: _repo = QuizRepositoryImpl(QuizRemoteDatasource(DioClient.instance)),
super(const QuizSetup());
Future<void> loadFilters() async {
emit(const QuizSetup(isLoadingFilters: true));
try {
final filters = await _repo.getFilters();
emit(QuizSetup(filters: filters));
} catch (e) {
emit(QuizSetup(filters: const FilterOptionsModel()));
}
}
Future<void> startQuiz({
required int numQuestions,
String? difficulty,
String? category,
}) async {
emit(const QuizStarting());
try {
final response = await _repo.startQuiz(
numQuestions: numQuestions,
difficulty: difficulty,
category: category,
);
emit(QuizActive(
questions: response.questions,
currentIndex: 0,
userAnswers: List.filled(response.questions.length, null),
currentRevealed: false,
attemptId: response.attemptId,
));
} catch (e) {
emit(QuizError(e.toString()));
}
}
void selectAnswer(String label) {
final s = state;
if (s is! QuizActive || s.currentRevealed) return;
final newAnswers = List<String?>.from(s.userAnswers);
newAnswers[s.currentIndex] = label;
emit(s.copyWith(
userAnswers: newAnswers,
currentRevealed: true,
));
}
void nextQuestion() {
final s = state;
if (s is! QuizActive || s.isLastQuestion) return;
emit(s.copyWith(
currentIndex: s.currentIndex + 1,
currentRevealed: s.userAnswers[s.currentIndex + 1] != null,
));
}
Future<void> submitQuiz() async {
final s = state;
if (s is! QuizActive) return;
emit(const QuizSubmitting());
try {
// Build answer submissions — map label back to answer text
final answers = <Map<String, String>>[];
for (var i = 0; i < s.questions.length; i++) {
final q = s.questions[i];
final selectedLabel = s.userAnswers[i];
if (selectedLabel == null) continue;
final selectedOpt =
q.options.where((o) => o.label == selectedLabel).firstOrNull;
if (selectedOpt == null) continue;
answers.add({
'scenario_id': q.scenarioId,
'selected_answer': selectedOpt.text,
});
}
final result = await _repo.submitQuiz(
attemptId: s.attemptId,
answers: answers,
);
emit(QuizResults(
result: result,
questions: s.questions,
userAnswers: s.userAnswers,
));
} catch (e) {
emit(QuizError(e.toString()));
}
}
void reset() {
emit(const QuizSetup());
loadFilters();
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
import '../../data/datasources/quiz_remote_datasource.dart';
sealed class QuizState extends Equatable {
const QuizState();
@override
List<Object?> get props => [];
}
class QuizSetup extends QuizState {
final FilterOptionsModel? filters;
final bool isLoadingFilters;
const QuizSetup({this.filters, this.isLoadingFilters = false});
@override
List<Object?> get props => [filters, isLoadingFilters];
}
class QuizStarting extends QuizState {
const QuizStarting();
}
class QuizActive extends QuizState {
final List<QuizQuestionModel> questions;
final int currentIndex;
final List<String?> userAnswers; // label chosen per question, null = unanswered
final bool currentRevealed;
final int attemptId;
const QuizActive({
required this.questions,
required this.currentIndex,
required this.userAnswers,
required this.currentRevealed,
required this.attemptId,
});
QuizQuestionModel get currentQuestion => questions[currentIndex];
int get totalQuestions => questions.length;
bool get isLastQuestion => currentIndex == questions.length - 1;
int get answeredCount => userAnswers.where((a) => a != null).length;
QuizActive copyWith({
int? currentIndex,
List<String?>? userAnswers,
bool? currentRevealed,
}) {
return QuizActive(
questions: questions,
currentIndex: currentIndex ?? this.currentIndex,
userAnswers: userAnswers ?? this.userAnswers,
currentRevealed: currentRevealed ?? this.currentRevealed,
attemptId: attemptId,
);
}
@override
List<Object?> get props =>
[currentIndex, userAnswers, currentRevealed, attemptId];
}
class QuizSubmitting extends QuizState {
const QuizSubmitting();
}
class QuizResults extends QuizState {
final QuizSubmitResultModel result;
final List<QuizQuestionModel> questions;
final List<String?> userAnswers;
const QuizResults({
required this.result,
required this.questions,
required this.userAnswers,
});
@override
List<Object?> get props => [result.score];
}
class QuizError extends QuizState {
final String message;
const QuizError(this.message);
@override
List<Object?> get props => [message];
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../../core/widgets/fs_badge.dart';
import '../../../../core/widgets/option_button.dart';
import '../../../../core/widgets/givens_table_widget.dart';
import '../bloc/quiz_cubit.dart';
import '../bloc/quiz_state.dart';
class QuizQuestionScreen extends StatelessWidget {
final QuizCubit cubit;
const QuizQuestionScreen({super.key, required this.cubit});
@override
Widget build(BuildContext context) {
return BlocBuilder<QuizCubit, QuizState>(
bloc: cubit,
builder: (context, state) {
if (state is! QuizActive) return const SizedBox.shrink();
final q = state.currentQuestion;
final selectedLabel = state.userAnswers[state.currentIndex];
final revealed = state.currentRevealed;
return Column(
children: [
// Progress bar
Container(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 8),
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: (state.currentIndex + 1) / state.totalQuestions,
minHeight: 6,
backgroundColor: AppColors.border,
valueColor:
const AlwaysStoppedAnimation(AppColors.accent),
),
),
),
const SizedBox(width: 12),
Text(
'${state.currentIndex + 1}/${state.totalQuestions}',
style: AppTextStyles.labelMedium
.copyWith(color: AppColors.textSecondary),
),
],
),
),
// Scrollable content
Expanded(
child: ListView(
padding: AppSpacing.screenH,
children: [
AppSpacing.vSm,
// Title + badges
Text(q.title, style: AppTextStyles.titleMedium),
AppSpacing.vSm,
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (q.riskLevel != null) FSBadge.risk(q.riskLevel!),
if (q.difficulty != null)
FSBadge.difficulty(q.difficulty!),
if (q.eventType != null)
FSBadge(
text: q.eventType!,
variant: q.eventType!.toLowerCase() == 'major'
? FSBadgeVariant.red
: FSBadgeVariant.blue,
),
],
),
AppSpacing.vMd,
// Scenario paragraph
if (q.scenarioParagraph != null &&
q.scenarioParagraph!.isNotEmpty)
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.bgSecondary,
borderRadius: AppRadius.smAll,
border: Border(
left: BorderSide(
color: AppColors.accent, width: 3),
),
),
child: Text(
q.scenarioParagraph!,
style: AppTextStyles.bodyMedium
.copyWith(color: AppColors.textSecondary, height: 1.7),
),
),
AppSpacing.vMd,
// Givens table
if (q.givensTable != null && q.givensTable!.isNotEmpty)
GivensTableWidget(data: q.givensTable!),
if (q.givensTable != null && q.givensTable!.isNotEmpty)
AppSpacing.vMd,
// Options
...q.options.map((opt) {
OptionState optState;
if (!revealed) {
optState = selectedLabel == opt.label
? OptionState.selected
: OptionState.idle;
} else {
if (opt.label == q.correctLabel) {
optState = OptionState.correct;
} else if (opt.label == selectedLabel) {
optState = OptionState.wrong;
} else {
optState = OptionState.idle;
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: OptionButton(
label: opt.label,
text: opt.text,
state: optState,
onTap: revealed
? null
: () => cubit.selectAnswer(opt.label),
),
);
}),
// Explanation
if (revealed && selectedLabel != null) ...[
AppSpacing.vSm,
_buildExplanation(q, selectedLabel),
],
AppSpacing.vLg,
// Navigation buttons
if (revealed) ...[
if (!state.isLastQuestion)
FSButton(
label: 'Next Question →',
onPressed: () => cubit.nextQuestion(),
)
else
FSButton(
label: '✓ Finish Quiz',
onPressed: () => cubit.submitQuiz(),
variant: FSButtonVariant.gradient,
),
],
AppSpacing.vXl,
],
),
),
],
);
},
);
}
Widget _buildExplanation(QuizQuestionModel q, String selectedLabel) {
final isCorrect = selectedLabel == q.correctLabel;
final selectedOpt =
q.options.where((o) => o.label == selectedLabel).firstOrNull;
final explanation = selectedOpt != null
? q.getExplanation(selectedOpt.text)
: 'No explanation available.';
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isCorrect ? AppColors.successBg : AppColors.errorBg,
border: Border.all(
color: isCorrect
? AppColors.success.withOpacity(0.2)
: AppColors.error.withOpacity(0.2),
),
borderRadius: AppRadius.smAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isCorrect ? '✅ Correct!' : '❌ Incorrect',
style: AppTextStyles.titleSmall.copyWith(
color: isCorrect ? AppColors.successLight : AppColors.errorLight,
),
),
const SizedBox(height: 6),
Text(
explanation,
style: AppTextStyles.bodySmall.copyWith(height: 1.6),
),
if (!isCorrect && q.bestAnswerRationale != null) ...[
const SizedBox(height: 10),
Text(
'Correct answer rationale:',
style: AppTextStyles.labelSmall
.copyWith(color: AppColors.successLight),
),
const SizedBox(height: 4),
Text(
q.bestAnswerRationale!,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textSecondary, height: 1.6),
),
],
],
),
);
}
}
\ No newline at end of file
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../../core/widgets/fs_badge.dart';
import '../bloc/quiz_cubit.dart';
import '../bloc/quiz_state.dart';
import 'quiz_review_screen.dart';
class QuizResultsScreen extends StatefulWidget {
final QuizCubit cubit;
const QuizResultsScreen({super.key, required this.cubit});
@override
State<QuizResultsScreen> createState() => _QuizResultsScreenState();
}
class _QuizResultsScreenState extends State<QuizResultsScreen>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scoreAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
final s = widget.cubit.state;
final target = s is QuizResults ? s.result.percentage / 100.0 : 0.0;
_scoreAnim = Tween<double>(begin: 0, end: target).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<QuizCubit, QuizState>(
bloc: widget.cubit,
builder: (context, state) {
if (state is! QuizResults) return const SizedBox.shrink();
final result = state.result;
final scoreColor = result.percentage >= 70
? AppColors.success
: result.percentage >= 40
? AppColors.warning
: AppColors.error;
return ListView(
padding: AppSpacing.screenAll,
children: [
AppSpacing.vLg,
// Animated score circle
Center(
child: AnimatedBuilder(
animation: _scoreAnim,
builder: (_, __) {
final pct = (_scoreAnim.value * 100).round();
return SizedBox(
width: 140,
height: 140,
child: CustomPaint(
painter: _ScoreRingPainter(
progress: _scoreAnim.value,
color: scoreColor,
),
child: Center(
child: Text(
'$pct%',
style: AppTextStyles.displayLarge.copyWith(
color: scoreColor,
fontWeight: FontWeight.w900,
fontSize: 42,
),
),
),
),
);
},
),
),
AppSpacing.vLg,
Center(
child: Text(
'You scored ${result.percentage}%',
style: AppTextStyles.headlineLarge,
),
),
Center(
child: Text(
'${result.correct} out of ${result.total} correct',
style: AppTextStyles.bodyMedium
.copyWith(color: AppColors.textSecondary),
),
),
AppSpacing.vXl,
// Per-question results
...List.generate(result.results.length, (i) {
final r = result.results[i];
final q = i < state.questions.length ? state.questions[i] : null;
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.smAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
q?.title ?? 'Question ${i + 1}',
style: AppTextStyles.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
FSBadge(
text: r.isCorrect ? '✓ Correct' : '✗ Wrong',
variant: r.isCorrect
? FSBadgeVariant.green
: FSBadgeVariant.red,
),
],
),
if (!r.isCorrect) ...[
const SizedBox(height: 8),
Text(
'Your answer: ${r.selectedAnswer}',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.errorLight),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
Text(
'Correct: ${r.correctAnswer}',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.successLight),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
);
}),
AppSpacing.vLg,
FSButton(
label: '📖 Review All Answers',
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (_) => QuizReviewScreen(
questions: state.questions,
userAnswers: state.userAnswers,
results: result.results,
),
));
},
variant: FSButtonVariant.secondary,
),
AppSpacing.vSm,
FSButton(
label: '🔄 Try Again',
onPressed: () => widget.cubit.reset(),
variant: FSButtonVariant.gradient,
),
AppSpacing.vXl,
],
);
},
);
}
}
class _ScoreRingPainter extends CustomPainter {
final double progress;
final Color color;
_ScoreRingPainter({required this.progress, required this.color});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - 12) / 2;
canvas.drawCircle(
center,
radius,
Paint()
..color = AppColors.border
..style = PaintingStyle.stroke
..strokeWidth = 8,
);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
2 * math.pi * progress,
false,
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 8
..strokeCap = StrokeCap.round,
);
}
@override
bool shouldRepaint(_ScoreRingPainter old) =>
old.progress != progress || old.color != color;
}
\ No newline at end of file
import 'package:flutter/material.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/fs_badge.dart';
import '../../../../core/widgets/option_button.dart';
import '../../data/datasources/quiz_remote_datasource.dart';
class QuizReviewScreen extends StatefulWidget {
final List<QuizQuestionModel> questions;
final List<String?> userAnswers;
final List<QuizAnswerResultModel> results;
const QuizReviewScreen({
super.key,
required this.questions,
required this.userAnswers,
required this.results,
});
@override
State<QuizReviewScreen> createState() => _QuizReviewScreenState();
}
class _QuizReviewScreenState extends State<QuizReviewScreen> {
String _filter = 'all'; // 'all', 'correct', 'wrong'
List<int> get _filteredIndices {
final indices = <int>[];
for (var i = 0; i < widget.results.length; i++) {
if (_filter == 'all') {
indices.add(i);
} else if (_filter == 'correct' && widget.results[i].isCorrect) {
indices.add(i);
} else if (_filter == 'wrong' && !widget.results[i].isCorrect) {
indices.add(i);
}
}
return indices;
}
@override
Widget build(BuildContext context) {
final filtered = _filteredIndices;
return Scaffold(
appBar: AppBar(title: const Text('Review Answers')),
body: Column(
children: [
// Filter pills
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
child: Row(
children: [
_filterChip('All', 'all'),
const SizedBox(width: 8),
_filterChip('✓ Correct', 'correct'),
const SizedBox(width: 8),
_filterChip('✗ Wrong', 'wrong'),
],
),
),
Expanded(
child: filtered.isEmpty
? Center(
child: Text('No questions match this filter.',
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary)),
)
: ListView.builder(
padding: AppSpacing.screenH,
itemCount: filtered.length,
itemBuilder: (context, idx) {
final i = filtered[idx];
return _buildQuestionReview(i);
},
),
),
],
),
);
}
Widget _filterChip(String label, String value) {
final selected = _filter == value;
return GestureDetector(
onTap: () => setState(() => _filter = value),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: selected ? AppColors.accentGlow : AppColors.panel,
border: Border.all(
color: selected ? AppColors.accent : AppColors.border,
),
borderRadius: AppRadius.pill,
),
child: Text(
label,
style: AppTextStyles.labelMedium.copyWith(
color: selected ? AppColors.accentLight : AppColors.textSecondary,
),
),
),
);
}
Widget _buildQuestionReview(int index) {
final q = widget.questions[index];
final r = widget.results[index];
final selectedLabel = widget.userAnswers[index];
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Text('Q${index + 1}',
style: AppTextStyles.titleSmall
.copyWith(color: AppColors.accentLight)),
const SizedBox(width: 8),
Expanded(
child: Text(q.title,
style: AppTextStyles.titleSmall,
maxLines: 2,
overflow: TextOverflow.ellipsis),
),
FSBadge(
text: r.isCorrect ? 'Correct' : 'Wrong',
variant:
r.isCorrect ? FSBadgeVariant.green : FSBadgeVariant.red,
),
],
),
// Scenario
if (q.scenarioParagraph != null &&
q.scenarioParagraph!.isNotEmpty) ...[
AppSpacing.vSm,
Text(
q.scenarioParagraph!,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary, height: 1.6),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
AppSpacing.vMd,
// Options
...q.options.map((opt) {
OptionState optState;
if (opt.label == q.correctLabel) {
optState = OptionState.correct;
} else if (opt.label == selectedLabel) {
optState = OptionState.wrong;
} else {
optState = OptionState.idle;
}
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: OptionButton(
label: opt.label,
text: opt.text,
state: optState,
),
);
}),
// Rationale
if (r.correctRationale != null) ...[
AppSpacing.vSm,
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.successBg,
borderRadius: AppRadius.smAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Why the correct answer is right:',
style: AppTextStyles.labelSmall
.copyWith(color: AppColors.successLight)),
const SizedBox(height: 4),
Text(r.correctRationale!,
style: AppTextStyles.bodySmall.copyWith(height: 1.6)),
],
),
),
],
],
),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/fs_button.dart';
import '../../../../core/widgets/fs_error_state.dart';
import '../bloc/quiz_cubit.dart';
import '../bloc/quiz_state.dart';
import 'quiz_question_screen.dart';
import 'quiz_results_screen.dart';
class QuizSetupScreen extends StatefulWidget {
const QuizSetupScreen({super.key});
@override
State<QuizSetupScreen> createState() => _QuizSetupScreenState();
}
class _QuizSetupScreenState extends State<QuizSetupScreen> {
late final QuizCubit _cubit;
int _numQuestions = 5;
String? _difficulty;
String? _category;
@override
void initState() {
super.initState();
_cubit = QuizCubit()..loadFilters();
}
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
body: SafeArea(
child: BlocConsumer<QuizCubit, QuizState>(
listener: (context, state) {},
builder: (context, state) {
if (state is QuizActive) {
return QuizQuestionScreen(cubit: _cubit);
}
if (state is QuizStarting || state is QuizSubmitting) {
return const Center(child: CircularProgressIndicator());
}
if (state is QuizResults) {
return QuizResultsScreen(cubit: _cubit);
}
if (state is QuizError) {
return FSErrorState(
message: state.message,
onRetry: () => _cubit.reset(),
);
}
return _buildSetupForm(state as QuizSetup);
},
),
),
),
);
}
Widget _buildSetupForm(QuizSetup state) {
final filters = state.filters;
return ListView(
padding: AppSpacing.screenAll,
children: [
Text('Practice MCQ', style: AppTextStyles.headlineLarge),
AppSpacing.vXs,
Text('Test your knowledge with real financial scenarios',
style: AppTextStyles.bodySmall),
AppSpacing.vXl,
Container(
padding: AppSpacing.cardInnerLarge,
decoration: BoxDecoration(
color: AppColors.panel,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.mdAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Start a New Quiz', style: AppTextStyles.titleMedium),
AppSpacing.vLg,
// Number of questions
Text('NUMBER OF QUESTIONS',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textSecondary, letterSpacing: 1)),
AppSpacing.vSm,
Row(
children: [5, 10, 15, 20].map((n) {
final selected = _numQuestions == n;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _numQuestions = n),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: selected
? AppColors.accentGlow
: AppColors.bgSecondary,
border: Border.all(
color: selected
? AppColors.accent
: AppColors.border,
width: selected ? 1.5 : 1,
),
borderRadius: AppRadius.smAll,
),
alignment: Alignment.center,
child: Text(
'$n',
style: AppTextStyles.titleSmall.copyWith(
color: selected
? AppColors.accentLight
: AppColors.textSecondary,
),
),
),
),
);
}).toList(),
),
AppSpacing.vLg,
// Difficulty
Text('DIFFICULTY',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textSecondary, letterSpacing: 1)),
const SizedBox(height: 6),
_buildDropdown(
value: _difficulty,
hint: 'All Difficulties',
items: filters?.difficulties ?? [],
onChanged: (v) => setState(() => _difficulty = v),
),
AppSpacing.vMd,
// Category
Text('CATEGORY',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textSecondary, letterSpacing: 1)),
const SizedBox(height: 6),
_buildDropdown(
value: _category,
hint: 'All Categories',
items: filters?.categories ?? [],
onChanged: (v) => setState(() => _category = v),
),
],
),
),
AppSpacing.vLg,
FSButton(
label: '🚀 Start Quiz',
isLoading: state.isLoadingFilters,
onPressed: state.isLoadingFilters
? null
: () => _cubit.startQuiz(
numQuestions: _numQuestions,
difficulty: _difficulty,
category: _category,
),
variant: FSButtonVariant.gradient,
),
],
);
}
Widget _buildDropdown({
required String? value,
required String hint,
required List<String> items,
required ValueChanged<String?> onChanged,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.bgSecondary,
border: Border.all(color: AppColors.border),
borderRadius: AppRadius.smAll,
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
hint: Text(hint,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary)),
isExpanded: true,
dropdownColor: AppColors.panelLight,
style: AppTextStyles.bodyMedium,
items: [
DropdownMenuItem<String>(
value: null,
child: Text(hint,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textTertiary)),
),
...items.map((item) => DropdownMenuItem(
value: item,
child: Text(item, style: AppTextStyles.bodySmall),
)),
],
onChanged: onChanged,
),
),
);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../../../core/network/api_endpoints.dart';
class ScenarioSummaryModel {
final String id;
final String title;
final String? shortDescription;
final String? eventType;
final String? difficulty;
final String? category;
final String? riskLevel;
final String? createdAt;
const ScenarioSummaryModel({
required this.id,
required this.title,
this.shortDescription,
this.eventType,
this.difficulty,
this.category,
this.riskLevel,
this.createdAt,
});
factory ScenarioSummaryModel.fromJson(Map<String, dynamic> json) {
return ScenarioSummaryModel(
id: json['id'] as String,
title: json['title'] as String,
shortDescription: json['short_description'] as String?,
eventType: json['event_type'] as String?,
difficulty: json['difficulty'] as String?,
category: json['category'] as String?,
riskLevel: json['risk_level'] as String?,
createdAt: json['created_at'] as String?,
);
}
}
class ScenarioPageModel {
final List<ScenarioSummaryModel> scenarios;
final int total;
final int page;
final int pages;
const ScenarioPageModel({
required this.scenarios,
required this.total,
required this.page,
required this.pages,
});
factory ScenarioPageModel.fromJson(Map<String, dynamic> json) {
return ScenarioPageModel(
scenarios: (json['scenarios'] as List<dynamic>)
.map((e) =>
ScenarioSummaryModel.fromJson(e as Map<String, dynamic>))
.toList(),
total: (json['total'] as num).toInt(),
page: (json['page'] as num).toInt(),
pages: (json['pages'] as num).toInt(),
);
}
}
class ScenarioDetailModel {
final String id;
final String title;
final String? shortDescription;
final Map<String, dynamic>? givensTable;
final String? scenarioParagraph;
final String? bestAnswer;
final String? bestAnswerRationale;
final String? otherOption1;
final String? otherOption1Exp;
final String? otherOption2;
final String? otherOption2Exp;
final String? otherOption3;
final String? otherOption3Exp;
final String? eventType;
final String? difficulty;
final String? category;
final String? riskLevel;
final String? createdAt;
const ScenarioDetailModel({
required this.id,
required this.title,
this.shortDescription,
this.givensTable,
this.scenarioParagraph,
this.bestAnswer,
this.bestAnswerRationale,
this.otherOption1,
this.otherOption1Exp,
this.otherOption2,
this.otherOption2Exp,
this.otherOption3,
this.otherOption3Exp,
this.eventType,
this.difficulty,
this.category,
this.riskLevel,
this.createdAt,
});
factory ScenarioDetailModel.fromJson(Map<String, dynamic> json) {
var givens = json['givens_table'];
if (givens is String) {
try {
givens = null; // API should return parsed, but safety fallback
} catch (_) {}
}
return ScenarioDetailModel(
id: json['id'] as String,
title: json['title'] as String,
shortDescription: json['short_description'] as String?,
givensTable: givens is Map<String, dynamic> ? givens : null,
scenarioParagraph: json['scenario_paragraph'] as String?,
bestAnswer: json['best_answer'] as String?,
bestAnswerRationale: json['best_answer_rationale'] as String?,
otherOption1: json['other_option1'] as String?,
otherOption1Exp: json['other_option1_exp'] as String?,
otherOption2: json['other_option2'] as String?,
otherOption2Exp: json['other_option2_exp'] as String?,
otherOption3: json['other_option3'] as String?,
otherOption3Exp: json['other_option3_exp'] as String?,
eventType: json['event_type'] as String?,
difficulty: json['difficulty'] as String?,
category: json['category'] as String?,
riskLevel: json['risk_level'] as String?,
createdAt: json['created_at'] as String?,
);
}
}
class ScenarioRemoteDatasource {
final Dio _dio;
ScenarioRemoteDatasource(this._dio);
Future<ScenarioPageModel> getScenarios({
int page = 1,
int limit = 12,
String? difficulty,
String? category,
String? risk,
}) async {
final response = await _dio.get(
ApiEndpoints.scenarios,
queryParameters: {
'page': page,
'limit': limit,
if (difficulty != null && difficulty.isNotEmpty) 'difficulty': difficulty,
if (category != null && category.isNotEmpty) 'category': category,
if (risk != null && risk.isNotEmpty) 'risk': risk,
},
);
return ScenarioPageModel.fromJson(response.data as Map<String, dynamic>);
}
Future<ScenarioDetailModel> getDetail(String id) async {
final response = await _dio.get(ApiEndpoints.scenarioDetail(id));
return ScenarioDetailModel.fromJson(
response.data as Map<String, dynamic>);
}
}
\ No newline at end of file
import 'package:dio/dio.dart';
import '../../domain/repositories/scenario_repository.dart';
import '../datasources/scenario_remote_datasource.dart';
class ScenarioRepositoryImpl implements ScenarioRepository {
final ScenarioRemoteDatasource remote;
ScenarioRepositoryImpl(this.remote);
@override
Future<ScenarioPageModel> getScenarios({
int page = 1,
int limit = 12,
String? difficulty,
String? category,
String? risk,
}) async {
try {
return await remote.getScenarios(
page: page,
limit: limit,
difficulty: difficulty,
category: category,
risk: risk,
);
} on DioException catch (e) {
throw e.error ?? e;
}
}
@override
Future<ScenarioDetailModel> getDetail(String id) async {
try {
return await remote.getDetail(id);
} on DioException catch (e) {
throw e.error ?? e;
}
}
}
\ No newline at end of file
import '../../data/datasources/scenario_remote_datasource.dart';
abstract class ScenarioRepository {
Future<ScenarioPageModel> getScenarios({
int page,
int limit,
String? difficulty,
String? category,
String? risk,
});
Future<ScenarioDetailModel> getDetail(String id);
}
\ No newline at end of file
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/network/dio_client.dart';
import '../../data/datasources/scenario_remote_datasource.dart';
import '../../data/repositories/scenario_repository_impl.dart';
import 'scenario_state.dart';
class ScenarioListCubit extends Cubit<ScenarioListState> {
final ScenarioRepositoryImpl _repo;
ScenarioListCubit()
: _repo = ScenarioRepositoryImpl(
ScenarioRemoteDatasource(DioClient.instance)),
super(const ScenarioListState());
Future<void> loadScenarios({int page = 1}) async {
if (page == 1) {
emit(state.copyWith(isLoading: true, error: () => null));
} else {
emit(state.copyWith(isLoadingMore: true));
}
try {
final result = await _repo.getScenarios(
page: page,
difficulty: state.difficultyFilter,
category: state.categoryFilter,
risk: state.riskFilter,
);
final scenarios = page == 1
? result.scenarios
: [...state.scenarios, ...result.scenarios];
emit(state.copyWith(
scenarios: scenarios,
currentPage: result.page,
totalPages: result.pages,
isLoading: false,
isLoadingMore: false,
));
} catch (e) {
emit(state.copyWith(
isLoading: false,
isLoadingMore: false,
error: () => e.toString(),
));
}
}
Future<void> loadMore() async {
if (state.isLoadingMore || !state.hasMore) return;
await loadScenarios(page: state.currentPage + 1);
}
void applyFilters({String? difficulty, String? category, String? risk}) {
emit(ScenarioListState(
difficultyFilter: difficulty,
categoryFilter: category,
riskFilter: risk,
));
loadScenarios();
}
void clearFilters() {
emit(const ScenarioListState());
loadScenarios();
}
}
class ScenarioDetailCubit extends Cubit<ScenarioDetailState> {
final ScenarioRepositoryImpl _repo;
ScenarioDetailCubit()
: _repo = ScenarioRepositoryImpl(
ScenarioRemoteDatasource(DioClient.instance)),
super(const ScenarioDetailLoading());
Future<void> loadDetail(String id) async {
emit(const ScenarioDetailLoading());
try {
final scenario = await _repo.getDetail(id);
emit(ScenarioDetailLoaded(scenario));
} catch (e) {
emit(ScenarioDetailError(e.toString()));
}
}
}
\ No newline at end of file
import 'package:equatable/equatable.dart';
import '../../data/datasources/scenario_remote_datasource.dart';
class ScenarioListState extends Equatable {
final List<ScenarioSummaryModel> scenarios;
final int currentPage;
final int totalPages;
final bool isLoading;
final bool isLoadingMore;
final String? error;
final String? difficultyFilter;
final String? categoryFilter;
final String? riskFilter;
const ScenarioListState({
this.scenarios = const [],
this.currentPage = 1,
this.totalPages = 1,
this.isLoading = false,
this.isLoadingMore = false,
this.error,
this.difficultyFilter,
this.categoryFilter,
this.riskFilter,
});
bool get hasMore => currentPage < totalPages;
ScenarioListState copyWith({
List<ScenarioSummaryModel>? scenarios,
int? currentPage,
int? totalPages,
bool? isLoading,
bool? isLoadingMore,
String? Function()? error,
String? Function()? difficultyFilter,
String? Function()? categoryFilter,
String? Function()? riskFilter,
}) {
return ScenarioListState(
scenarios: scenarios ?? this.scenarios,
currentPage: currentPage ?? this.currentPage,
totalPages: totalPages ?? this.totalPages,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
error: error != null ? error() : this.error,
difficultyFilter: difficultyFilter != null ? difficultyFilter() : this.difficultyFilter,
categoryFilter: categoryFilter != null ? categoryFilter() : this.categoryFilter,
riskFilter: riskFilter != null ? riskFilter() : this.riskFilter,
);
}
@override
List<Object?> get props => [
scenarios.length,
currentPage,
totalPages,
isLoading,
isLoadingMore,
error,
difficultyFilter,
categoryFilter,
riskFilter,
];
}
sealed class ScenarioDetailState extends Equatable {
const ScenarioDetailState();
@override
List<Object?> get props => [];
}
class ScenarioDetailLoading extends ScenarioDetailState {
const ScenarioDetailLoading();
}
class ScenarioDetailLoaded extends ScenarioDetailState {
final ScenarioDetailModel scenario;
const ScenarioDetailLoaded(this.scenario);
@override
List<Object?> get props => [scenario.id];
}
class ScenarioDetailError extends ScenarioDetailState {
final String message;
const ScenarioDetailError(this.message);
@override
List<Object?> get props => [message];
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/widgets/scenario_card.dart';
import '../../../../core/widgets/fs_shimmer.dart';
import '../../../../core/widgets/fs_empty_state.dart';
import '../../../../core/widgets/fs_error_state.dart';
import '../bloc/scenario_cubit.dart';
import '../bloc/scenario_state.dart';
class ScenarioBrowserScreen extends StatefulWidget {
const ScenarioBrowserScreen({super.key});
@override
State<ScenarioBrowserScreen> createState() => _ScenarioBrowserScreenState();
}
class _ScenarioBrowserScreenState extends State<ScenarioBrowserScreen> {
late final ScenarioListCubit _cubit;
final _scrollCtrl = ScrollController();
@override
void initState() {
super.initState();
_cubit = ScenarioListCubit()..loadScenarios();
_scrollCtrl.addListener(_onScroll);
}
@override
void dispose() {
_scrollCtrl.dispose();
_cubit.close();
super.dispose();
}
void _onScroll() {
if (_scrollCtrl.position.pixels >=
_scrollCtrl.position.maxScrollExtent - 200) {
_cubit.loadMore();
}
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(title: const Text('Scenarios')),
body: BlocBuilder<ScenarioListCubit, ScenarioListState>(
builder: (context, state) {
return Column(
children: [
// Filter chips
_buildFilterBar(state),
// Content
Expanded(
child: _buildContent(state),
),
],
);
},
),
),
);
}
Widget _buildFilterBar(ScenarioListState state) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
_filterChip(
'All',
state.difficultyFilter == null &&
state.categoryFilter == null &&
state.riskFilter == null,
() => _cubit.clearFilters(),
),
const SizedBox(width: 8),
_filterChip('High Risk', state.riskFilter == 'High',
() => _cubit.applyFilters(risk: 'High')),
const SizedBox(width: 8),
_filterChip('Medium Risk', state.riskFilter == 'Medium',
() => _cubit.applyFilters(risk: 'Medium')),
const SizedBox(width: 8),
_filterChip('Low Risk', state.riskFilter == 'Low',
() => _cubit.applyFilters(risk: 'Low')),
const SizedBox(width: 8),
_filterChip('Easy', state.difficultyFilter == 'easy',
() => _cubit.applyFilters(difficulty: 'easy')),
const SizedBox(width: 8),
_filterChip('Medium', state.difficultyFilter == 'medium',
() => _cubit.applyFilters(difficulty: 'medium')),
const SizedBox(width: 8),
_filterChip('Hard', state.difficultyFilter == 'hard',
() => _cubit.applyFilters(difficulty: 'hard')),
],
),
);
}
Widget _filterChip(String label, bool selected, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: selected ? AppColors.accentGlow : AppColors.panel,
border: Border.all(
color: selected ? AppColors.accent : AppColors.border,
),
borderRadius: AppRadius.pill,
),
child: Text(
label,
style: AppTextStyles.labelMedium.copyWith(
color: selected ? AppColors.accentLight : AppColors.textSecondary,
),
),
),
);
}
Widget _buildContent(ScenarioListState state) {
if (state.isLoading) {
return ListView.builder(
padding: AppSpacing.screenH,
itemCount: 6,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: FSShimmer.card(height: 100),
),
);
}
if (state.error != null && state.scenarios.isEmpty) {
return FSErrorState(
message: state.error!,
onRetry: () => _cubit.loadScenarios(),
);
}
if (state.scenarios.isEmpty) {
return const FSEmptyState(
icon: '📚',
title: 'No scenarios found',
subtitle: 'Generate some first using the Scenario Generator!',
);
}
return RefreshIndicator(
color: AppColors.accent,
backgroundColor: AppColors.panel,
onRefresh: () => _cubit.loadScenarios(),
child: ListView.builder(
controller: _scrollCtrl,
padding: AppSpacing.screenH,
itemCount: state.scenarios.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.scenarios.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final s = state.scenarios[index];
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ScenarioCard(
title: s.title,
description: s.shortDescription,
riskLevel: s.riskLevel,
difficulty: s.difficulty,
eventType: s.eventType,
onTap: () => context.push('/app/more/scenarios/${s.id}'),
),
);
},
),
);
}
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../../core/theme/app_text_styles.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/utils/formatters.dart';
import '../../../../core/widgets/fs_badge.dart';
import '../../../../core/widgets/fs_error_state.dart';
import '../../../../core/widgets/givens_table_widget.dart';
import '../bloc/scenario_cubit.dart';
import '../bloc/scenario_state.dart';
class ScenarioDetailScreen extends StatefulWidget {
final String scenarioId;
const ScenarioDetailScreen({super.key, required this.scenarioId});
@override
State<ScenarioDetailScreen> createState() => _ScenarioDetailScreenState();
}
class _ScenarioDetailScreenState extends State<ScenarioDetailScreen> {
late final ScenarioDetailCubit _cubit;
@override
void initState() {
super.initState();
_cubit = ScenarioDetailCubit()..loadDetail(widget.scenarioId);
}
@override
void dispose() {
_cubit.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cubit,
child: Scaffold(
appBar: AppBar(title: const Text('Scenario Detail')),
body: BlocBuilder<ScenarioDetailCubit, ScenarioDetailState>(
builder: (context, state) {
if (state is ScenarioDetailLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is ScenarioDetailError) {
return FSErrorState(
message: state.message,
onRetry: () => _cubit.loadDetail(widget.scenarioId),
);
}
if (state is! ScenarioDetailLoaded) return const SizedBox.shrink();
final s = state.scenario;
return ListView(
padding: AppSpacing.screenAll,
children: [
// Title
Text(s.title, style: AppTextStyles.headlineMedium),
AppSpacing.vSm,
// Badges
Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (s.riskLevel != null) FSBadge.risk(s.riskLevel!),
if (s.difficulty != null)
FSBadge.difficulty(s.difficulty!),
if (s.eventType != null)
FSBadge(
text: s.eventType!,
variant: s.eventType!.toLowerCase() == 'major'
? FSBadgeVariant.red
: FSBadgeVariant.blue,
),
if (s.category != null)
FSBadge(text: s.category!, variant: FSBadgeVariant.purple),
],
),
AppSpacing.vLg,
// Givens Table
if (s.givensTable != null && s.givensTable!.isNotEmpty) ...[
Text('GIVEN DATA',
style: AppTextStyles.labelSmall.copyWith(
color: AppColors.textTertiary, letterSpacing: 1)),
AppSpacing.vSm,
GivensTableWidget(data: s.givensTable!),
AppSpacing.vLg,
],
// Scenario Paragraph
if (s.scenarioParagraph != null &&
s.scenarioParagraph!.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.bgSecondary,
borderRadius: AppRadius.smAll,
border: Border(
left: BorderSide(color: AppColors.accent, width: 3),
),
),
child: Text(
s.scenarioParagraph!,
style: AppTextStyles.bodyMedium.copyWith(height: 1.7),
),
),
AppSpacing.vLg,
],
// Best Answer
if (s.bestAnswer != null) ...[
_buildAnswerCard(
title: '✅ Best Answer',
answer: s.bestAnswer!,
rationale: s.bestAnswerRationale,
color: AppColors.success,
bgColor: AppColors.successBg,
),
AppSpacing.vMd,
],
// Other Options
if (s.otherOption1 != null)
_buildAnswerCard(
title: 'Other Option',
answer: s.otherOption1!,
rationale: s.otherOption1Exp,
color: AppColors.textTertiary,
bgColor: AppColors.panel,
),
if (s.otherOption1 != null) AppSpacing.vSm,
if (s.otherOption2 != null)
_buildAnswerCard(
title: 'Other Option',
answer: s.otherOption2!,
rationale: s.otherOption2Exp,
color: AppColors.textTertiary,
bgColor: AppColors.panel,
),
if (s.otherOption2 != null) AppSpacing.vSm,
if (s.otherOption3 != null)
_buildAnswerCard(
title: 'Other Option',
answer: s.otherOption3!,
rationale: s.otherOption3Exp,
color: AppColors.textTertiary,
bgColor: AppColors.panel,
),
AppSpacing.vLg,
// Timestamp
if (s.createdAt != null)
Text(
'Created: ${Formatters.dateTime(s.createdAt)}',
style: AppTextStyles.caption,
),
AppSpacing.vXl,
],
);
},
),
),
);
}
Widget _buildAnswerCard({
required String title,
required String answer,
String? rationale,
required Color color,
required Color bgColor,
}) {
return Container(
padding: AppSpacing.cardInner,
decoration: BoxDecoration(
color: bgColor,
border: Border.all(color: color.withOpacity(0.25)),
borderRadius: AppRadius.smAll,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: AppTextStyles.labelSmall.copyWith(color: color)),
const SizedBox(height: 6),
Text(answer, style: AppTextStyles.bodyMedium),
if (rationale != null && rationale.isNotEmpty) ...[
const SizedBox(height: 8),
Text(rationale,
style: AppTextStyles.bodySmall
.copyWith(color: AppColors.textSecondary, height: 1.6)),
],
],
),
);
}
}
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment