Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
F
FinSim
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Fares
FinSim
Commits
cf85f694
Commit
cf85f694
authored
Apr 08, 2026
by
Administrator
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Update 17 files via Son of Anton
parent
8f5af7a6
Changes
17
Hide whitespace changes
Inline
Side-by-side
Showing
17 changed files
with
2206 additions
and
14 deletions
+2206
-14
router.dart
finsim_flutter/lib/core/config/router.dart
+18
-14
quiz_remote_datasource.dart
...eatures/quiz/data/datasources/quiz_remote_datasource.dart
+233
-0
quiz_repository_impl.dart
...features/quiz/data/repositories/quiz_repository_impl.dart
+50
-0
quiz_repository.dart
...ib/features/quiz/domain/repositories/quiz_repository.dart
+14
-0
quiz_cubit.dart
...utter/lib/features/quiz/presentation/bloc/quiz_cubit.dart
+114
-0
quiz_state.dart
...utter/lib/features/quiz/presentation/bloc/quiz_state.dart
+88
-0
quiz_question_screen.dart
...tures/quiz/presentation/screens/quiz_question_screen.dart
+228
-0
quiz_results_screen.dart
...atures/quiz/presentation/screens/quiz_results_screen.dart
+239
-0
quiz_review_screen.dart
...eatures/quiz/presentation/screens/quiz_review_screen.dart
+208
-0
quiz_setup_screen.dart
...features/quiz/presentation/screens/quiz_setup_screen.dart
+223
-0
scenario_remote_datasource.dart
...cenarios/data/datasources/scenario_remote_datasource.dart
+167
-0
scenario_repository_impl.dart
...scenarios/data/repositories/scenario_repository_impl.dart
+39
-0
scenario_repository.dart
...es/scenarios/domain/repositories/scenario_repository.dart
+12
-0
scenario_cubit.dart
.../features/scenarios/presentation/bloc/scenario_cubit.dart
+87
-0
scenario_state.dart
.../features/scenarios/presentation/bloc/scenario_state.dart
+89
-0
scenario_browser_screen.dart
...enarios/presentation/screens/scenario_browser_screen.dart
+190
-0
scenario_detail_screen.dart
...cenarios/presentation/screens/scenario_detail_screen.dart
+207
-0
No files found.
finsim_flutter/lib/core/config/router.dart
View file @
cf85f694
...
@@ -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
(),
...
...
finsim_flutter/lib/features/quiz/data/datasources/quiz_remote_datasource.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/data/repositories/quiz_repository_impl.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/domain/repositories/quiz_repository.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/presentation/bloc/quiz_cubit.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/presentation/bloc/quiz_state.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/presentation/screens/quiz_question_screen.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/presentation/screens/quiz_results_screen.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/presentation/screens/quiz_review_screen.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/quiz/presentation/screens/quiz_setup_screen.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/data/datasources/scenario_remote_datasource.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/data/repositories/scenario_repository_impl.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/domain/repositories/scenario_repository.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/presentation/bloc/scenario_cubit.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/presentation/bloc/scenario_state.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/presentation/screens/scenario_browser_screen.dart
0 → 100644
View file @
cf85f694
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
finsim_flutter/lib/features/scenarios/presentation/screens/scenario_detail_screen.dart
0 → 100644
View file @
cf85f694
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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment