feat: implement pretest review feature and media player optimization

This commit is contained in:
Naresh Pratista 2024-10-31 16:03:22 +07:00
parent 210c40812b
commit 64454f596e
35 changed files with 1606 additions and 875 deletions

View File

@ -0,0 +1,2 @@
const String baseUrl =
'https://3be6-2001-448a-50a0-58e3-1c9f-405a-b360-22ed.ngrok-free.app/';

View File

@ -1,7 +1,7 @@
// ignore_for_file: avoid_print
import 'package:dio/dio.dart';
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
class DioClient {
final Dio _dio = Dio();
@ -393,7 +393,7 @@ class DioClient {
Future<Response> getStudentAnswers(String stdLearningId, String token) async {
try {
final response = await _dio.get(
'/studentAnswer/$stdLearningId',
'/studentAnswers/$stdLearningId',
options: Options(
headers: {
'Authorization': 'Bearer $token',

View File

@ -1,2 +0,0 @@
const String baseUrl =
'https://70e7-2001-448a-50a0-2604-b558-2a22-54f6-65ce.ngrok-free.app/';

View File

@ -25,7 +25,7 @@ class ExerciseRepository {
try {
final response = await _dioClient.getStudentAnswers(stdLearningId, token);
if (response.statusCode == 200) {
return response.data['data'];
return response.data;
} else {
throw Exception('Failed to load student answers');
}

View File

@ -32,20 +32,4 @@ class LevelRepository {
rethrow;
}
}
Future<Map<String, dynamic>> getStudentAnswers(
String stdLearningId, String token) async {
try {
final response = await _dioClient.getStudentAnswers(stdLearningId, token);
if (response.statusCode == 200 && response.data != null) {
return response.data['data'];
} else {
throw Exception(
'Failed to fetch student answers: ${response.statusCode}');
}
} catch (e) {
throw Exception('Failed to fetch student answers: $e');
}
}
}

View File

@ -15,6 +15,7 @@ class GlobalButton extends StatelessWidget {
final Color? borderColor;
final double borderWidth;
final bool transparentBackground;
final bool isLoading;
const GlobalButton({
super.key,
@ -31,6 +32,7 @@ class GlobalButton extends StatelessWidget {
this.borderColor,
this.borderWidth = 1.0,
this.transparentBackground = false,
this.isLoading = false,
});
@override
@ -61,49 +63,66 @@ class GlobalButton extends StatelessWidget {
),
),
onPressed: onPressed,
child: spaceBetween && icon != null
? Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
text,
style: AppTextStyles.whiteTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
color: textColor,
),
),
Icon(
icon,
size: iconSize,
color: textColor ?? Colors.white,
),
],
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
text,
style: AppTextStyles.whiteTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
color: textColor,
),
),
if (icon != null)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
icon,
size: iconSize,
color: textColor ?? Colors.white,
),
),
],
),
child: isLoading ? _buildLoadingIndicator() : _buildButtonContent(),
),
),
);
}
Widget _buildLoadingIndicator() {
return SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(textColor ?? Colors.white),
strokeWidth: 2.0,
),
);
}
Widget _buildButtonContent() {
if (spaceBetween && icon != null) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
text,
style: AppTextStyles.whiteTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
color: textColor,
),
),
Icon(
icon,
size: iconSize,
color: textColor ?? Colors.white,
),
],
);
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
text,
style: AppTextStyles.whiteTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.bold,
color: textColor,
),
),
if (icon != null)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Icon(
icon,
size: iconSize,
color: textColor ?? Colors.white,
),
),
],
);
}
}
}

View File

@ -9,7 +9,9 @@ class UserProvider with ChangeNotifier {
String? _jwtToken;
Map<String, dynamic>? _userData;
File? _selectedImage;
bool _isLoading = false;
bool get isLoading => _isLoading;
bool get isLoggedIn => _isLoggedIn;
String? get jwtToken => _jwtToken;
Map<String, dynamic>? get userData => _userData;
@ -19,6 +21,11 @@ class UserProvider with ChangeNotifier {
_loadLoginStatus();
}
void setLoading(bool value) {
_isLoading = value;
notifyListeners();
}
Future<void> _loadLoginStatus() async {
try {
_jwtToken = await _userRepository.getToken();
@ -60,6 +67,7 @@ class UserProvider with ChangeNotifier {
}
Future<bool> login({required String email, required String password}) async {
setLoading(true);
try {
final response = await _userRepository.loginUser({
'EMAIL': email,
@ -84,6 +92,8 @@ class UserProvider with ChangeNotifier {
} catch (e) {
print('Login error: $e');
return false;
} finally {
setLoading(false);
}
}

View File

@ -128,53 +128,58 @@ class SigninScreen extends StatelessWidget {
const SizedBox(height: 24),
GlobalButton(
text: 'Login',
onPressed: () async {
// Validate email and password fields
validatorProvider.validateField(
'email',
_emailController.text,
validator: validatorProvider.emailValidator,
);
validatorProvider.validateField(
'password',
_passwordController.text,
validator: validatorProvider.passwordValidator,
);
isLoading: userProvider.isLoading,
onPressed: userProvider.isLoading
? null
: () async {
// Validate email and password fields
validatorProvider.validateField(
'email',
_emailController.text,
validator: validatorProvider.emailValidator,
);
validatorProvider.validateField(
'password',
_passwordController.text,
validator: validatorProvider.passwordValidator,
);
// If no errors, proceed with login
if (validatorProvider.getError('email') == null &&
validatorProvider.getError('password') == null) {
final isSuccess = await userProvider.login(
email: _emailController.text,
password: _passwordController.text,
);
// If no errors, proceed with login
if (validatorProvider.getError('email') == null &&
validatorProvider.getError('password') ==
null) {
final isSuccess = await userProvider.login(
email: _emailController.text,
password: _passwordController.text,
);
if (isSuccess) {
// Navigate to HomeScreen after successful login
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => const HomeScreen()),
(Route<dynamic> route) =>
false, // Remove all previous routes
).then((_) {
// Reset the fields after login
validatorProvider.resetFields();
_emailController.clear();
_passwordController.clear();
});
} else {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Login failed, please check your credentials'),
backgroundColor: Colors.red,
),
);
}
}
},
if (isSuccess) {
// Navigate to HomeScreen after successful login
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) =>
const HomeScreen()),
(Route<dynamic> route) =>
false, // Remove all previous routes
).then((_) {
// Reset the fields after login
validatorProvider.resetFields();
_emailController.clear();
_passwordController.clear();
});
} else {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Login failed, please check your credentials'),
backgroundColor: Colors.red,
),
);
}
}
},
),
const SizedBox(height: 8),
Row(
@ -187,6 +192,7 @@ class SigninScreen extends StatelessWidget {
),
GestureDetector(
onTap: () {
context.read<ValidatorProvider>().resetFields();
Navigator.push(
context,
MaterialPageRoute(

View File

@ -227,13 +227,12 @@ class SignupScreen extends StatelessWidget {
),
GestureDetector(
onTap: () {
context.read<ValidatorProvider>().resetFields();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SigninScreen()),
).then((_) {
context.read<ValidatorProvider>().resetFields();
});
);
},
child: Text(
' Login Here',

View File

@ -51,6 +51,7 @@ class HistoryProvider with ChangeNotifier {
_error = null;
} catch (e) {
_error = 'Error fetching learning history: ${e.toString()}';
_learningHistory = [];
} finally {
_isLoading = false;
notifyListeners();
@ -91,10 +92,23 @@ class HistoryProvider with ChangeNotifier {
}
Future<void> refreshData(String token) async {
if (_sectionProvider.sections.isEmpty) {
_isLoading = true;
_error = null;
notifyListeners();
try {
await _sectionProvider.fetchSections(token);
if (_sectionProvider.sections.isNotEmpty) {
await fetchLearningHistory(token);
} else {
_error = 'No sections available';
}
} catch (e) {
_error = 'Error refreshing data: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
await fetchLearningHistory(token);
}
Future<void> loadHistoryData(String token) async {
@ -103,29 +117,52 @@ class HistoryProvider with ChangeNotifier {
notifyListeners();
try {
// Fetch sections and learning history in parallel
final sectionsResult = _sectionProvider.fetchSections(token);
// Use the first section ID for initial history fetch
final firstSectionId = await sectionsResult
.then((sections) => sections.isNotEmpty ? sections.first.id : null);
if (firstSectionId != null) {
final historyResult =
_repository.getLearningHistory(firstSectionId, token);
// Wait for both futures to complete
final results = await Future.wait([sectionsResult, historyResult]);
_learningHistory = results[1] as List<LearningHistory>;
await _sectionProvider.fetchSections(token);
if (_sectionProvider.sections.isNotEmpty) {
String firstSectionId = _sectionProvider.sections.first.id;
_learningHistory =
await _repository.getLearningHistory(firstSectionId, token);
} else {
_error = 'No sections available';
}
} catch (e) {
_error = 'Error loading data: ${e.toString()}';
_learningHistory = []; // Clear the list in case of error
} finally {
_isLoading = false;
notifyListeners();
}
}
// Future<void> loadHistoryData(String token) async {
// _isLoading = true;
// _error = null;
// notifyListeners();
// try {
// // Fetch sections and learning history in parallel
// final sectionsResult = _sectionProvider.fetchSections(token);
// // Use the first section ID for initial history fetch
// final firstSectionId = await sectionsResult
// .then((sections) => sections.isNotEmpty ? sections.first.id : null);
// if (firstSectionId != null) {
// final historyResult =
// _repository.getLearningHistory(firstSectionId, token);
// // Wait for both futures to complete
// final results = await Future.wait([sectionsResult, historyResult]);
// _learningHistory = results[1] as List<LearningHistory>;
// } else {
// _error = 'No sections available';
// }
// } catch (e) {
// _error = 'Error loading data: ${e.toString()}';
// } finally {
// _isLoading = false;
// notifyListeners();
// }
// }
}

View File

@ -10,76 +10,50 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:provider/provider.dart';
class HistoryScreen extends StatefulWidget {
const HistoryScreen({
super.key,
});
@override
State<HistoryScreen> createState() => _HistoryScreenState();
}
class _HistoryScreenState extends State<HistoryScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final historyProvider =
Provider.of<HistoryProvider>(context, listen: false);
final userProvider = Provider.of<UserProvider>(context, listen: false);
historyProvider.fetchLearningHistory(userProvider.jwtToken!);
});
}
class HistoryScreen extends StatelessWidget {
const HistoryScreen({super.key});
bool isNotFoundError(String error) {
return error.toLowerCase().contains('no learning history found') ||
error.toLowerCase().contains('not found');
}
// @override
// void initState() {
// super.initState();
// WidgetsBinding.instance.addPostFrameCallback((_) {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// final historyProvider =
// Provider.of<HistoryProvider>(context, listen: false);
// final userProvider = Provider.of<UserProvider>(context, listen: false);
// historyProvider.refreshData(userProvider.jwtToken!);
// });
// });
// }
@override
Widget build(BuildContext context) {
return Consumer<HistoryProvider>(
builder: (context, historyProvider, chiild) {
return Scaffold(
backgroundColor: AppColors.bgSoftColor,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 30.0,
),
child: Column(
children: [
_buildHeader(),
const SizedBox(height: 8),
const CustomTabBar(),
const SizedBox(height: 8),
Expanded(
child: _buildContent(historyProvider),
),
],
builder: (context, historyProvider, child) {
return Scaffold(
backgroundColor: AppColors.bgSoftColor,
body: SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 30.0,
),
child: Column(
children: [
_buildHeader(),
const SizedBox(height: 8),
const CustomTabBar(),
const SizedBox(height: 8),
Expanded(
child: _buildContent(context, historyProvider),
),
],
),
),
),
),
),
);
});
);
},
);
}
Widget _buildContent(HistoryProvider historyProvider) {
Widget _buildContent(BuildContext context, HistoryProvider historyProvider) {
print("isLoading: ${historyProvider.isLoading}");
print("error: ${historyProvider.error}");
print("historyLength: ${historyProvider.learningHistory.length}");
if (historyProvider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
@ -87,7 +61,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
if (historyProvider.error != null) {
return isNotFoundError(historyProvider.error!)
? _buildEmptyState(context)
: _buildErrorState(historyProvider.error!);
: _buildErrorState(context, historyProvider.error!);
}
if (historyProvider.learningHistory.isEmpty) {
@ -145,7 +119,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
);
}
Widget _buildErrorState(String error) {
Widget _buildErrorState(BuildContext context, String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

@ -206,6 +206,22 @@ class _HomeContentState extends State<HomeContent> {
);
}
Widget _buildCompletedTopicsContent(CompletedTopicsProvider provider) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
itemCount: 1,
itemBuilder: (context, index) {
return ProgressCard(
completedTopic: provider.completedTopics,
);
},
);
}
@override
Widget build(BuildContext context) {
return Consumer2<UserProvider, CompletedTopicsProvider>(builder: (
@ -403,20 +419,27 @@ class _HomeContentState extends State<HomeContent> {
? _buildShimmerEffect()
: completedTopicsProvider.completedTopics.isEmpty
? _buildNoDataWidget()
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
itemCount: 1,
itemBuilder: (context, index) {
return ProgressCard(
completedTopic: completedTopicsProvider
.completedTopics, // Kirim seluruh list
);
},
),
: _buildCompletedTopicsContent(
completedTopicsProvider),
// completedTopicsProvider.isLoading
// ? _buildShimmerEffect()
// : completedTopicsProvider.completedTopics.isEmpty
// ? _buildNoDataWidget()
// : ListView.builder(
// shrinkWrap: true,
// physics: const NeverScrollableScrollPhysics(),
// padding: const EdgeInsets.symmetric(
// horizontal: 16.0,
// ),
// itemCount: 1,
// itemBuilder: (context, index) {
// return ProgressCard(
// completedTopic: completedTopicsProvider
// .completedTopics, // Kirim seluruh list
// );
// },
// ),
],
),
),

View File

@ -1,5 +1,4 @@
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/features/home/models/completed_topics_model.dart';
import 'package:english_learning/features/home/widgets/progress_bar.dart';

View File

@ -1,3 +1,5 @@
import 'package:english_learning/features/learning/modules/exercises/models/review_exercise_model.dart';
class ExerciseModel {
final String idAdminExercise;
final String idLevel;
@ -61,21 +63,6 @@ class ExerciseModel {
choices: choices,
);
}
factory ExerciseModel.fromReviewJson(Map<String, dynamic> json) {
final exerciseDetails = json['exerciseDetails'];
return ExerciseModel(
idAdminExercise: exerciseDetails['ID_ADMIN_EXERCISE'],
idLevel: json['ID_LEVEL'] ?? '', // This might be available in parent data
title: exerciseDetails['TITLE'],
question: exerciseDetails['QUESTION'],
questionType: exerciseDetails['QUESTION_TYPE'],
timeAdminExc: DateTime.parse(json['TIME_STUDENT_EXC']),
answerStudent: json['ANSWER_STUDENT'],
isCorrect: json['IS_CORRECT'] == 1,
choices: null,
);
}
}
class MultipleChoice {
@ -138,6 +125,13 @@ class Pair {
final String right;
Pair({required this.left, required this.right});
factory Pair.fromJson(Map<String, dynamic> json) {
return Pair(
left: json['LEFT_PAIR'] ?? '',
right: json['RIGHT_PAIR'] ?? '',
);
}
}
class MatchingPair {
@ -147,17 +141,24 @@ class MatchingPair {
factory MatchingPair.fromJsonList(List? jsonList) {
if (jsonList == null) {
return MatchingPair(
pairs: []); // Return an empty list if jsonList is null
return MatchingPair(pairs: []);
}
List<Pair> pairs = [];
for (var pair in jsonList) {
pairs.add(Pair(
left: pair['LEFT_PAIR'] ?? '', // Use empty string as default if null
right: pair['RIGHT_PAIR'] ?? '',
));
}
List<Pair> pairs = jsonList.map((item) => Pair.fromJson(item)).toList();
return MatchingPair(pairs: pairs);
}
// Helper methods
List<String> get leftPairs => pairs.map((pair) => pair.left).toList();
List<String> get rightPairs => pairs.map((pair) => pair.right).toList();
}
extension ReviewMatchingPairListExtension on List<ReviewMatchingPair> {
MatchingPair toMatchingPair() {
List<Pair> pairs = map((review) => Pair(
left: review.leftPair,
right: review.rightPair,
)).toList();
return MatchingPair(pairs: pairs);
}
}

View File

@ -0,0 +1,150 @@
class ReviewExerciseModel {
final String idStudentLearning;
final String idLevel;
final String nameSection;
final String nameTopic;
final String nameLevel;
final int score;
final bool isPass;
final List<ReviewExerciseDetail> stdExercises;
ReviewExerciseModel({
required this.idStudentLearning,
required this.idLevel,
required this.nameSection,
required this.nameTopic,
required this.nameLevel,
required this.score,
required this.isPass,
required this.stdExercises,
});
factory ReviewExerciseModel.fromJson(Map<String, dynamic> json) {
return ReviewExerciseModel(
idStudentLearning: json['ID_STUDENT_LEARNING'],
idLevel: json['ID_LEVEL'],
nameSection: json['NAME_SECTION'],
nameTopic: json['NAME_TOPIC'],
nameLevel: json['NAME_LEVEL'],
score: json['SCORE'],
isPass: json['IS_PASS'] == 1,
stdExercises: (json['stdExercises'] as List)
.map((e) => ReviewExerciseDetail.fromJson(e))
.toList(),
);
}
}
class ReviewExerciseDetail {
final String idStudentExercise;
final String idAdminExercise;
final String title;
final String question;
final String questionType;
final int scoreWeight;
final String? image;
final String? video;
final String? audio;
final String answerStudent;
final int isCorrect;
final double resultScoreStudent;
final List<ReviewMultipleChoice>? multipleChoices;
final List<ReviewMatchingPair>? matchingPairs;
ReviewExerciseDetail({
required this.idStudentExercise,
required this.idAdminExercise,
required this.title,
required this.question,
required this.questionType,
this.image,
this.video,
this.audio,
required this.scoreWeight,
required this.answerStudent,
required this.isCorrect,
required this.resultScoreStudent,
this.multipleChoices,
this.matchingPairs,
});
factory ReviewExerciseDetail.fromJson(Map<String, dynamic> json) {
return ReviewExerciseDetail(
idStudentExercise: json['ID_STUDENT_EXERCISE'] ?? '',
idAdminExercise: json['ID_ADMIN_EXERCISE'] ?? '',
title: json['TITLE'] ?? '',
question: json['QUESTION'] ?? '',
questionType: json['QUESTION_TYPE'] ?? '',
image: json['IMAGE'],
video: json['VIDEO'],
audio: json['AUDIO'],
scoreWeight: json['SCORE_WEIGHT'] ?? 0,
answerStudent: json['ANSWER_STUDENT'] ?? '',
isCorrect: json['IS_CORRECT'] ?? 0,
resultScoreStudent:
double.tryParse(json['RESULT_SCORE_STUDENT'].toString()) ?? 0.0,
multipleChoices: json['multipleChoices'] != null
? (json['multipleChoices'] as List)
.map((e) => ReviewMultipleChoice.fromJson(e))
.toList()
: null,
matchingPairs: json['matchingPairs'] != null
? (json['matchingPairs'] as List)
.map((e) => ReviewMatchingPair.fromJson(e))
.toList()
: null,
);
}
List<ReviewMatchingPair> get matchingPairsOrEmpty {
return matchingPairs ?? [];
}
List<ReviewMultipleChoice> get multipleChoicesOrEmpty {
return multipleChoices ?? [];
}
}
class ReviewMultipleChoice {
final String optionA;
final String optionB;
final String optionC;
final String optionD;
final String optionE;
ReviewMultipleChoice({
required this.optionA,
required this.optionB,
required this.optionC,
required this.optionD,
required this.optionE,
});
factory ReviewMultipleChoice.fromJson(Map<String, dynamic> json) {
return ReviewMultipleChoice(
optionA: json['OPTION_A'] ?? '',
optionB: json['OPTION_B'] ?? '',
optionC: json['OPTION_C'] ?? '',
optionD: json['OPTION_D'] ?? '',
optionE: json['OPTION_E'] ?? '',
);
}
}
class ReviewMatchingPair {
final String leftPair;
final String rightPair;
ReviewMatchingPair({
required this.leftPair,
required this.rightPair,
});
factory ReviewMatchingPair.fromJson(Map<String, dynamic> json) {
return ReviewMatchingPair(
leftPair: json['LEFT_PAIR'],
rightPair: json['RIGHT_PAIR'],
);
}
}

View File

@ -1,3 +1,4 @@
import 'package:english_learning/features/learning/modules/exercises/models/review_exercise_model.dart';
import 'package:english_learning/features/learning/modules/feedback/models/feedback_model.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/services/repositories/exercise_repository.dart';
@ -14,6 +15,8 @@ class ExerciseProvider extends ChangeNotifier {
// State variables
List<ExerciseModel> _exercises = [];
List<ReviewExerciseDetail> _reviewExercises = [];
ReviewExerciseModel? _reviewData;
Map<int, List<Map<String, String>>> _matchingAnswers = {};
List<String> _answers = [];
List<List<Color>> _leftColors = [];
@ -24,7 +27,6 @@ class ExerciseProvider extends ChangeNotifier {
String _nameLevel = '';
String? _activeLeftOption;
String? _studentLearningId;
bool _isReview = false;
// Constants
final List<Color> _pairColors = [
@ -44,7 +46,7 @@ class ExerciseProvider extends ChangeNotifier {
String get nameLevel => _nameLevel;
String? get activeLeftOption => _activeLeftOption;
String? get studentLearningId => _studentLearningId;
bool get isReview => _isReview;
List<ReviewExerciseDetail> get reviewExercises => _reviewExercises;
// Initialization methods
void initializeAnswers() {
@ -79,7 +81,6 @@ class ExerciseProvider extends ChangeNotifier {
// }
// }
void answerQuestion(int index, String answer) {
if (_isReview) return;
if (index >= 0 && index < _answers.length) {
if (_exercises[index].choices is MatchingPair) {
_handleMatchingPairAnswer(index, answer);
@ -208,20 +209,30 @@ class ExerciseProvider extends ChangeNotifier {
void goToExercise(int index) {
if (index >= 0 && index < _exercises.length) {
_currentExerciseIndex = index;
print('Going to exercise: $_currentExerciseIndex'); // Debug print
notifyListeners();
}
}
void nextQuestion() {
if (_currentExerciseIndex < _answers.length - 1) {
print('Current index before next: $_currentExerciseIndex'); // Debug print
if (_currentExerciseIndex <
(_exercises.isNotEmpty
? _exercises.length - 1
: _reviewExercises.length - 1)) {
_currentExerciseIndex++;
print('Moving to next question: $_currentExerciseIndex'); // Debug print
notifyListeners();
}
}
void previousQuestion() {
print(
'Current index before previous: $_currentExerciseIndex'); // Debug print
if (_currentExerciseIndex > 0) {
_currentExerciseIndex--;
print(
'Moving to previous question: $_currentExerciseIndex'); // Debug print
notifyListeners();
}
}
@ -245,6 +256,7 @@ class ExerciseProvider extends ChangeNotifier {
// API methods
Future<void> fetchExercises(String levelId) async {
_isLoading = true;
_currentExerciseIndex = 0;
notifyListeners();
try {
@ -270,33 +282,93 @@ class ExerciseProvider extends ChangeNotifier {
}
}
// Di dalam ExerciseProvider class
Future<void> fetchReviewExercises(String stdLearningId) async {
_isLoading = true;
_isReview = true;
_currentExerciseIndex = 0;
notifyListeners();
try {
final token = await _userProvider.getValidToken();
if (token == null) {
throw Exception('No valid token found');
}
if (token == null) throw Exception('No valid token found');
final data = await _repository.getStudentAnswers(stdLearningId, token);
_nameTopic = data['NAME_TOPIC'];
_nameLevel = data['NAME_LEVEL'];
final exercisesData = data['stdExercises'];
_exercises = exercisesData
.map<ExerciseModel>((json) => ExerciseModel.fromReviewJson(json))
.toList();
_answers = _exercises.map((e) => e.answerStudent ?? '').toList();
final response =
await _repository.getStudentAnswers(stdLearningId, token);
// Parse the response data
final payload = response['payload'];
_reviewData = ReviewExerciseModel.fromJson(payload);
// Sort the exercises based on the title number
_reviewExercises = (_reviewData?.stdExercises ?? [])
..sort((a, b) {
// Extract numbers from titles (e.g., "Soal 1" -> 1)
int aNumber = int.tryParse(a.title.split(' ').last) ?? 0;
int bNumber = int.tryParse(b.title.split(' ').last) ?? 0;
return aNumber.compareTo(bNumber);
});
_nameTopic = _reviewData?.nameTopic ?? '';
_nameLevel = _reviewData?.nameLevel ?? '';
// Reset current index
_currentExerciseIndex = 0;
} catch (e) {
print('Error fetching review exercises: $e');
rethrow;
} finally {
_isLoading = false;
notifyListeners();
}
}
dynamic get currentExercise {
if (_reviewExercises.isNotEmpty) {
return _reviewExercises[_currentExerciseIndex];
} else if (_exercises.isNotEmpty) {
return _exercises[_currentExerciseIndex];
}
return null;
}
String getAnswerFormatted(ReviewExerciseDetail exercise) {
switch (exercise.questionType) {
case 'MCQ':
if (exercise.multipleChoices != null) {
final choice = exercise.answerStudent;
final choices = exercise.multipleChoices!.first;
switch (choice) {
case 'A':
return choices.optionA;
case 'B':
return choices.optionB;
case 'C':
return choices.optionC;
case 'D':
return choices.optionD;
case 'E':
return choices.optionE;
default:
return exercise.answerStudent;
}
}
return exercise.answerStudent;
case 'TFQ':
return exercise.answerStudent == '1' ? 'True' : 'False';
case 'MPQ':
if (exercise.answerStudent.isEmpty) return '';
return exercise.answerStudent.split(', ').map((pair) {
final parts = pair.split('-');
if (parts.length == 2) {
return '${parts[0]}${parts[1]}';
}
return pair;
}).join('\n');
default:
return exercise.answerStudent;
}
}
Future<Map<String, dynamic>> submitAnswersAndGetScore() async {
print('submitAnswersAndGetScore called');
try {
@ -398,6 +470,7 @@ class ExerciseProvider extends ChangeNotifier {
_nameTopic = previous._nameTopic;
_nameLevel = previous._nameLevel;
_studentLearningId = previous._studentLearningId;
_reviewData = previous._reviewData;
}
}
}

View File

@ -1,5 +1,6 @@
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/exercise_content.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/content/exercise_content.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/exercise_navigator.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/exercise_progress.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/instruction_dialog.dart';
@ -31,6 +32,7 @@ class _ExerciseScreenState extends State<ExerciseScreen> {
_scrollController = ScrollController();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = context.read<ExerciseProvider>();
if (widget.isReview) {
provider.fetchReviewExercises(widget.studentLearningId);
} else {
@ -62,11 +64,32 @@ class _ExerciseScreenState extends State<ExerciseScreen> {
body: Center(child: CircularProgressIndicator()),
);
}
final currentExercise = provider.currentExercise;
final hasExercises = widget.isReview
? provider.reviewExercises.isNotEmpty
: provider.exercises.isNotEmpty;
if (!hasExercises || currentExercise == null) {
return const Scaffold(
body: Center(child: Text('No exercises available')),
);
}
return Scaffold(
backgroundColor: AppColors.bgSoftColor,
appBar: AppBar(
elevation: 0,
automaticallyImplyLeading: false,
leading: widget.isReview
? IconButton(
icon: const Icon(
BootstrapIcons.arrow_left,
color: AppColors.whiteColor,
),
onPressed: () => Navigator.of(context).pop(),
)
: null, // Show back button only in review mode
iconTheme: const IconThemeData(color: AppColors.whiteColor),
centerTitle: true,
title: Text(
@ -81,38 +104,47 @@ class _ExerciseScreenState extends State<ExerciseScreen> {
gradient: AppColors.gradientTheme,
),
),
actions: [
IconButton(
icon: const Icon(Icons.info_outline, color: AppColors.whiteColor),
onPressed: () => _showInstructions(context),
),
],
actions: widget.isReview
? [
IconButton(
icon: const Icon(
BootstrapIcons.info_circle,
color: AppColors.whiteColor,
size: 22,
),
onPressed: () => _showInstructions(context),
),
]
: null,
),
body: SafeArea(
child: Column(
children: [
const Padding(
padding: EdgeInsets.all(16.0),
child: ExerciseProgress(),
),
if (!widget.isReview)
const Padding(
padding: EdgeInsets.all(16.0),
child: ExerciseProgress(),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => provider.fetchExercises(widget.levelId!),
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
if (provider.isReview)
Text('Review Mode', style: TextStyle(fontSize: 24)),
const SizedBox(height: 16),
const ExerciseContent(),
const SizedBox(height: 24),
ExerciseNavigator(onScrollToTop: _scrollToTop),
const SizedBox(height: 32),
],
),
child: SingleChildScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
const SizedBox(height: 16),
ExerciseContent(
key: ValueKey(provider.currentExerciseIndex),
exercise: currentExercise,
isReview: widget.isReview,
),
const SizedBox(height: 24),
ExerciseNavigator(
onScrollToTop: _scrollToTop,
isReview: widget.isReview,
),
const SizedBox(height: 32),
],
),
),
),

View File

@ -0,0 +1,299 @@
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/features/learning/modules/exercises/models/exercise_model.dart';
import 'package:english_learning/features/learning/modules/exercises/models/review_exercise_model.dart';
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/question/matching_pairs_question.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/question/multiple_choice_question.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/question/true_false_question.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/see_progress_modal.dart';
import 'package:english_learning/features/learning/modules/material/widgets/audio_player_widget.dart';
import 'package:english_learning/features/learning/modules/material/widgets/image_widget.dart';
import 'package:english_learning/features/learning/modules/material/widgets/video_player_widget.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ExerciseContent extends StatefulWidget {
final dynamic exercise;
final bool isReview;
const ExerciseContent({
super.key,
required this.exercise,
this.isReview = false,
});
@override
State<ExerciseContent> createState() => _ExerciseContentState();
}
class _ExerciseContentState extends State<ExerciseContent>
with WidgetsBindingObserver {
final GlobalKey<VideoPlayerWidgetState> _videoPlayerKey = GlobalKey();
final GlobalKey<AudioPlayerWidgetState> _audioPlayerKey = GlobalKey();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
_stopAndResetAllMedia();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_stopAndResetAllMedia();
}
}
void _stopAndResetAllMedia() {
_videoPlayerKey.currentState?.stopAndResetVideo();
_audioPlayerKey.currentState?.stopAndResetAudio();
}
@override
Widget build(BuildContext context) {
return Consumer<ExerciseProvider>(builder: (context, provider, child) {
final exercises =
widget.isReview ? provider.reviewExercises : provider.exercises;
if (exercises.isEmpty) {
return const Center(child: Text('No exercises available'));
}
final currentIndex = provider.currentExerciseIndex;
final currentExercise = exercises[currentIndex];
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (Widget child, Animation<double> animation) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
)),
child: child,
),
);
},
child: Container(
key: ValueKey(currentIndex),
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Question ${currentIndex + 1}',
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
if (!widget.isReview)
GestureDetector(
onTap: () {
showModalBottomSheet(
backgroundColor: AppColors.whiteColor,
context: context,
builder: (context) => const SeeProgressModal(),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
);
},
child: Text(
'See Progress',
style: AppTextStyles.blueTextStyle.copyWith(
decoration: TextDecoration.underline,
decorationColor: AppColors.blueColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
if (widget.isReview)
_buildStatusIndicator(
currentExercise as ReviewExerciseDetail),
],
),
const SizedBox(height: 12),
const Divider(
color: AppColors.disableColor,
thickness: 1,
),
const SizedBox(height: 12),
Text(
_getQuestionText(currentExercise),
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 16),
if (widget.exercise.image != null &&
widget.exercise.image!.isNotEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ImageWidget(
imageFileName: widget.exercise.image!,
baseUrl: '${baseUrl}uploads/exercise/image/',
),
),
),
if (widget.exercise.audio != null &&
widget.exercise.audio!.isNotEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: AudioPlayerWidget(
key: _audioPlayerKey,
audioFileName: widget.exercise.audio!,
baseUrl: '${baseUrl}uploads/exercise/audio/',
),
),
),
if (widget.exercise.video != null &&
widget.exercise.video!.isNotEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: VideoPlayerWidget(
key: _videoPlayerKey,
videoUrl: widget.exercise.video!,
),
),
),
const SizedBox(height: 14),
_buildQuestionWidget(
widget.exercise,
widget.isReview,
)
],
),
),
),
);
});
}
String _getQuestionText(dynamic exercise) {
if (exercise is ReviewExerciseDetail) {
return exercise.question;
} else if (exercise is ExerciseModel) {
return exercise.question;
}
return '';
}
Widget _buildQuestionWidget(
dynamic exercise,
bool isReview,
) {
String questionType = '';
if (exercise is ReviewExerciseDetail) {
questionType = exercise.questionType;
} else if (exercise is ExerciseModel) {
questionType = exercise.questionType;
}
switch (questionType) {
case 'MCQ':
return MultipleChoiceQuestion(
exercise: exercise,
isReview: isReview,
);
case 'TFQ':
return TrueFalseQuestion(
exercise: exercise,
isReview: isReview,
);
case 'MPQ':
return MatchingPairsQuestion(
exercise: exercise,
isReview: isReview,
);
default:
return const Text('Unsupported question type');
}
}
Widget _buildStatusIndicator(ReviewExerciseDetail exercise) {
if (exercise.questionType == 'MPQ') {
return Row(
children: [
Icon(
exercise.resultScoreStudent > 0
? BootstrapIcons.check_circle_fill
: BootstrapIcons.x_circle_fill,
color: exercise.resultScoreStudent > 0 ? Colors.green : Colors.red,
size: 18,
),
const SizedBox(width: 4),
Text(
'${exercise.resultScoreStudent}/${exercise.scoreWeight}',
style: AppTextStyles.blackTextStyle.copyWith(
color:
exercise.resultScoreStudent > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
],
);
} else {
return Row(
children: [
Icon(
exercise.isCorrect == 1
? BootstrapIcons.check_circle_fill
: BootstrapIcons.x_circle_fill,
color: exercise.isCorrect == 1 ? Colors.green : Colors.red,
size: 18,
),
const SizedBox(width: 4),
Text(
exercise.isCorrect == 1 ? 'Correct' : 'Incorrect',
style: TextStyle(
color: exercise.isCorrect == 1 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
],
);
}
}
}

View File

@ -1,149 +0,0 @@
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/features/learning/modules/exercises/models/exercise_model.dart';
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/question/matching_pairs_question.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/question/multiple_choice_question.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/question/true_false_question.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/see_progress_modal.dart';
import 'package:english_learning/features/learning/modules/material/widgets/image_widget.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class ExerciseContent extends StatelessWidget {
const ExerciseContent({super.key});
Widget _buildQuestionWidget(
ExerciseModel exercise,
ExerciseProvider provider,
) {
switch (exercise.questionType) {
case 'MCQ':
return MultipleChoiceQuestion(
exercise: exercise,
);
case 'TFQ':
return TrueFalseQuestion(
exercise: exercise,
);
case 'MPQ':
return MatchingPairsQuestion(
exercise: exercise,
);
default:
return const Text('Unsupported question type');
}
}
@override
Widget build(BuildContext context) {
return Consumer<ExerciseProvider>(builder: (context, provider, child) {
if (provider.exercises.isEmpty) {
return const Center(child: Text('No exercises available'));
}
final exercise = provider.exercises[provider.currentExerciseIndex];
return TweenAnimationBuilder(
duration: const Duration(milliseconds: 500),
tween: Tween<double>(begin: 0, end: 1),
builder: (context, double value, child) {
return Opacity(
opacity: value,
child: Transform.translate(
offset: Offset(50 * (1 - value), 0),
child: child,
),
);
},
child: Container(
key: ValueKey(provider.currentExerciseIndex),
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.whiteColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.1),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Question ${provider.currentExerciseIndex + 1}',
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
GestureDetector(
onTap: () {
showModalBottomSheet(
backgroundColor: AppColors.whiteColor,
context: context,
builder: (context) => const SeeProgressModal(),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(20),
),
),
);
},
child: Text(
'See Progress',
style: AppTextStyles.blueTextStyle.copyWith(
decoration: TextDecoration.underline,
decorationColor: AppColors.blueColor,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 12),
const Divider(
color: AppColors.disableColor,
thickness: 1,
),
const SizedBox(height: 12),
Text(
exercise.question,
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: 16),
if (exercise.image != null && exercise.image!.isNotEmpty)
Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: ImageWidget(
imageFileName: exercise.image!,
baseUrl: '${baseUrl}uploads/exercise/image/',
),
),
),
const SizedBox(height: 14),
_buildQuestionWidget(
exercise,
provider,
)
],
),
),
),
);
});
}
}

View File

@ -1,4 +1,5 @@
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/core/widgets/global_button.dart';
import 'package:english_learning/features/auth/provider/user_provider.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/complete_submission.dart';
import 'package:english_learning/features/learning/modules/exercises/widgets/incomplete_submission.dart';
@ -9,184 +10,145 @@ import 'package:provider/provider.dart';
class ExerciseNavigator extends StatelessWidget {
final VoidCallback? onScrollToTop;
final bool isReview;
const ExerciseNavigator({
super.key,
required this.onScrollToTop,
this.isReview = false,
});
@override
Widget build(BuildContext context) {
return Consumer2<ExerciseProvider, UserProvider>(
builder: (context, exerciseProvider, userProvider, _) {
final currentExerciseIndex = exerciseProvider.currentExerciseIndex;
final isFirstExercise = currentExerciseIndex == 0;
final isLastExercise =
currentExerciseIndex == exerciseProvider.exercises.length - 1;
builder: (context, exerciseProvider, userProvider, _) {
final currentExerciseIndex = exerciseProvider.currentExerciseIndex;
final totalExercises = isReview
? exerciseProvider.reviewExercises.length
: exerciseProvider.exercises.length;
final isFirstExercise = currentExerciseIndex == 0;
final isLastExercise = currentExerciseIndex == totalExercises - 1;
Future<void> submitAnswers() async {
try {
final result = await exerciseProvider.submitAnswersAndGetScore();
print('Submit result: $result'); // Logging
Future<void> submitAnswers() async {
try {
final result = await exerciseProvider.submitAnswersAndGetScore();
print('Submit result: $result');
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => ResultScreen(
currentLevel: result['CURRENT_LEVEL_NAME'] ?? '',
nextLevel: result['NEXT_LEARNING_NAME'] ?? '',
score: int.tryParse(result['SCORE'].toString()) ?? 0,
isCompleted: result['IS_PASS'] == 1,
stdLearningId: result['STUDENT_LEARNING_ID']?.toString(),
),
),
);
} catch (e) {
print('Error submitting answers: $e'); // Logging
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
if (context.mounted) {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (context) => ResultScreen(
currentLevel: result['CURRENT_LEVEL_NAME'] ?? '',
nextLevel: result['NEXT_LEARNING_NAME'] ?? '',
score: int.tryParse(result['SCORE'].toString()) ?? 0,
isCompleted: result['IS_PASS'] == 1,
stdLearningId: result['STUDENT_LEARNING_ID']?.toString(),
),
),
);
}
} catch (e) {
print('Error submitting answers: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: $e')),
);
}
}
}
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isFirstExercise)
Expanded(
child: ElevatedButton(
onPressed: () {
exerciseProvider.previousQuestion();
onScrollToTop?.call();
},
style: ElevatedButton.styleFrom(
shadowColor: Colors.transparent,
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isFirstExercise)
Expanded(
flex: 3,
child: GlobalButton(
text: 'Previous',
height: 45,
onPressed: () {
exerciseProvider.previousQuestion();
onScrollToTop?.call();
},
backgroundColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: AppColors.blueColor,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'Previous',
style: AppTextStyles.blueTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
borderColor: AppColors.blueColor,
textColor: AppColors.blueColor,
transparentBackground: true,
),
),
),
if (!isFirstExercise && !isLastExercise) const SizedBox(width: 8),
if (!isLastExercise)
Expanded(
child: ElevatedButton(
onPressed: () {
exerciseProvider.nextQuestion();
onScrollToTop?.call();
},
style: ElevatedButton.styleFrom(
shadowColor: Colors.transparent,
if (!isFirstExercise && !isLastExercise) const SizedBox(width: 8),
if (!isLastExercise)
Expanded(
flex: 3,
child: GlobalButton(
text: 'Next',
height: 45,
onPressed: () {
exerciseProvider.nextQuestion();
onScrollToTop?.call();
},
backgroundColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(
color: AppColors.blueColor,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Text(
'Next',
style: AppTextStyles.blueTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
borderColor: AppColors.blueColor,
textColor: AppColors.blueColor,
transparentBackground: true,
),
),
),
if (isLastExercise && !isFirstExercise) const SizedBox(width: 8),
if (isLastExercise)
Expanded(
child: ElevatedButton(
onPressed: () {
if (exerciseProvider.hasAnsweredQuestions()) {
if (exerciseProvider.hasUnansweredQuestions()) {
// Ada pertanyaan yang belum dijawab
showDialog(
context: context,
builder: (BuildContext context) {
return IncompleteSubmission(
onCheckAgain: () {
Navigator.of(context).pop();
},
onSubmit: () async {
Navigator.of(context).pop();
submitAnswers();
},
);
},
);
if (isLastExercise && !isFirstExercise) const SizedBox(width: 8),
if (isLastExercise)
Expanded(
flex: 5,
child: GlobalButton(
text: isReview ? 'Back to Level List' : 'Submit',
height: 45,
onPressed: () {
if (isReview) {
Navigator.of(context).pop();
} else {
// Semua pertanyaan sudah dijawab
showDialog(
context: context,
builder: (BuildContext context) {
return CompleteSubmission(
onCheckAgain: () {
Navigator.of(context).pop();
},
onSubmit: () async {
Navigator.of(context).pop();
submitAnswers();
if (exerciseProvider.hasAnsweredQuestions()) {
if (exerciseProvider.hasUnansweredQuestions()) {
showDialog(
context: context,
builder: (BuildContext context) {
return IncompleteSubmission(
onCheckAgain: () => Navigator.of(context).pop(),
onSubmit: () async {
Navigator.of(context).pop();
submitAnswers();
},
);
},
);
},
);
} else {
showDialog(
context: context,
builder: (BuildContext context) {
return CompleteSubmission(
onCheckAgain: () => Navigator.of(context).pop(),
onSubmit: () async {
Navigator.of(context).pop();
submitAnswers();
},
);
},
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Please answer at least one question before submitting.',
),
),
);
}
}
} else {
// Tidak ada pertanyaan yang dijawab
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Please answer at least one question before submitting.')),
);
}
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Ink(
decoration: BoxDecoration(
gradient: AppColors.gradientTheme,
borderRadius: BorderRadius.circular(12),
),
child: GestureDetector(
onTap: exerciseProvider.nextQuestion,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
alignment: Alignment.center,
child: Text(
'Submit',
style: AppTextStyles.whiteTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
},
gradient: AppColors.gradientTheme,
),
),
),
],
);
});
],
);
},
);
}
}

View File

@ -1,3 +1,5 @@
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:english_learning/core/widgets/global_button.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
@ -11,7 +13,6 @@ class InstructionsDialog extends StatelessWidget {
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.whiteColor,
shape: BoxShape.rectangle,
@ -24,47 +25,52 @@ class InstructionsDialog extends StatelessWidget {
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Instructions',
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
_buildInstructionItem(
icon: Icons.question_answer,
text: 'Answer all questions to complete the exercise.',
),
const SizedBox(height: 12),
_buildInstructionItem(
icon: Icons.compare_arrows,
text:
'For matching pairs, select a left option first, then match it with a right option.',
),
const SizedBox(height: 12),
_buildInstructionItem(
icon: Icons.edit,
text:
'You can change your answers at any time before submitting.',
),
const SizedBox(height: 24),
ElevatedButton(
child: const Text('Got it!'),
onPressed: () => Navigator.of(context).pop(),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.blueColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Instructions',
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 22,
fontWeight: FontWeight.bold,
),
padding:
const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
),
),
],
const SizedBox(height: 16),
const Divider(
thickness: 0.5,
),
Padding(
padding: const EdgeInsets.only(top: 32, left: 32, right: 32),
child: Column(
children: [
_buildInstructionItem(
icon: BootstrapIcons.chat_square_text,
text: 'Answer all questions to complete the exercise.',
),
const SizedBox(height: 12),
_buildInstructionItem(
icon: BootstrapIcons.arrow_left_right,
text:
'For matching pairs, select a left option first, then match it with a right option.',
),
const SizedBox(height: 12),
_buildInstructionItem(
icon: BootstrapIcons.pencil,
text:
'You can change your answers at any time before submitting.',
),
const SizedBox(height: 32),
GlobalButton(
text: 'Got It',
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
],
),
),
),
);
@ -73,12 +79,16 @@ class InstructionsDialog extends StatelessWidget {
Widget _buildInstructionItem({required IconData icon, required String text}) {
return Row(
children: [
Icon(icon, color: AppColors.blueColor),
const SizedBox(width: 12),
Icon(
icon,
color: AppColors.blueColor,
size: 24,
),
const SizedBox(width: 16),
Expanded(
child: Text(
text,
style: AppTextStyles.blackTextStyle.copyWith(fontSize: 16),
style: AppTextStyles.greyTextStyle.copyWith(fontSize: 14),
),
),
],

View File

@ -1,3 +1,4 @@
import 'package:english_learning/features/learning/modules/exercises/models/review_exercise_model.dart';
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
@ -5,55 +6,131 @@ import 'package:english_learning/features/learning/modules/exercises/models/exer
import 'package:provider/provider.dart';
class MatchingPairsQuestion extends StatelessWidget {
final ExerciseModel exercise;
final dynamic exercise;
final bool isReview;
const MatchingPairsQuestion({
super.key,
required this.exercise,
this.isReview = false,
});
@override
Widget build(BuildContext context) {
final provider = Provider.of<ExerciseProvider>(context);
final matchingPair = exercise.choices as MatchingPair;
List<dynamic> pairs = [];
Map<String, String> studentAnswers = {};
final currentIndex = provider.currentExerciseIndex;
if (exercise is ExerciseModel) {
pairs = (exercise.choices as MatchingPair).pairs;
} else if (exercise is ReviewExerciseDetail) {
if (exercise.matchingPairs != null) {
pairs = exercise.matchingPairs!.map((reviewPair) {
return Pair(
left: reviewPair.leftPair,
right: reviewPair.rightPair,
);
}).toList();
// Parse student answers
if (exercise.answerStudent.isNotEmpty) {
final answerPairs = exercise.answerStudent.split(', ');
for (var pair in answerPairs) {
final parts = pair.split('-');
if (parts.length == 2) {
studentAnswers[parts[0]] = parts[1];
}
}
}
}
}
// Create a reverse mapping for easier lookup of left pair from right pair
Map<String, String> reverseStudentAnswers = {};
studentAnswers.forEach((left, right) {
reverseStudentAnswers[right] = left;
});
return Column(
children: [
...matchingPair.pairs.asMap().entries.map((entry) {
...pairs.asMap().entries.map((entry) {
int pairIndex = entry.key;
Pair pair = entry.value;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: _buildOptionItem(
context,
pair.left,
provider,
currentIndex,
pairIndex,
true,
dynamic pair = entry.value;
String left = pair is Pair ? pair.left : pair.leftPair;
String right = pair is Pair ? pair.right : pair.rightPair;
// Get the corresponding color for this pair
Color pairColor = _getPairColor(pairIndex);
// Find if this item is matched in student answers
String? studentMatchedRight = studentAnswers[left];
String? matchedLeftForRight = reverseStudentAnswers[right];
// Find the color index for the matched pair
int? matchedPairIndex;
if (isReview && matchedLeftForRight != null) {
matchedPairIndex = pairs.indexWhere((p) {
if (p is Pair) {
return p.left == matchedLeftForRight;
}
return false;
});
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: _buildOptionItem(
context,
left,
provider,
currentIndex,
pairIndex,
true,
studentMatchedRight != null,
studentAnswers,
pairColor,
),
),
),
const SizedBox(width: 10),
Expanded(
child: _buildOptionItem(
context,
pair.right,
provider,
currentIndex,
pairIndex,
false,
const SizedBox(width: 10),
Expanded(
child: _buildOptionItem(
context,
right,
provider,
currentIndex,
matchedPairIndex ?? pairIndex,
false,
matchedLeftForRight != null,
studentAnswers,
matchedPairIndex != null
? _getPairColor(matchedPairIndex)
: pairColor,
),
),
),
],
],
),
);
}).toList(),
],
);
}
Color _getPairColor(int index) {
final List<Color> colors = [
Colors.blue,
Colors.green,
Colors.orange,
Colors.purple,
Colors.red,
];
return colors[index % colors.length];
}
Widget _buildOptionItem(
BuildContext context,
String option,
@ -61,61 +138,79 @@ class MatchingPairsQuestion extends StatelessWidget {
int exerciseIndex,
int pairIndex,
bool isLeft,
bool isMatched,
Map<String, String> studentAnswers,
Color pairColor,
) {
Color? color = isLeft
? provider.getLeftColor(exerciseIndex, pairIndex)
: provider.getRightColor(exerciseIndex, pairIndex);
final isSelected = provider.isOptionSelected(exerciseIndex, option, isLeft);
final isActive = isLeft && provider.activeLeftOption == option;
Color? color;
bool isSelected = false;
bool isActive = false;
if (isReview) {
if (isMatched) {
color = pairColor;
isSelected = true;
}
} else {
if (isLeft) {
color = provider.getLeftColor(exerciseIndex, pairIndex);
isActive = provider.activeLeftOption == option;
} else {
color = provider.getRightColor(exerciseIndex, pairIndex);
}
isSelected = provider.isOptionSelected(exerciseIndex, option, isLeft);
}
return GestureDetector(
onTap: () {
if (!isLeft && provider.activeLeftOption == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a left option first.')),
);
} else {
provider.answerQuestion(exerciseIndex, option);
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Container(
decoration: BoxDecoration(
color:
isSelected ? color : (isActive ? color! : AppColors.whiteColor),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(20),
bottomRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
),
border: Border.all(
color: isSelected
? color ?? AppColors.cardDisabledColor
: AppColors.cardDisabledColor,
width: isSelected ? 2 : 1,
),
boxShadow: isActive
? [
BoxShadow(
color: color?.withOpacity(0.5) ??
Colors.grey.withOpacity(0.5),
spreadRadius: 1,
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: null,
onTap: isReview
? null
: () {
if (!isLeft && provider.activeLeftOption == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a left option first.'),
duration: Duration(seconds: 1),
),
);
} else {
provider.answerQuestion(exerciseIndex, option);
}
},
child: Container(
decoration: BoxDecoration(
color: isSelected
? color ?? pairColor
: (isActive ? pairColor : AppColors.whiteColor),
borderRadius: const BorderRadius.only(
topRight: Radius.circular(20),
bottomRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
child: Text(
option,
style: AppTextStyles.blackTextStyle.copyWith(
fontWeight: FontWeight.w900,
color: isSelected || isActive
? AppColors.whiteColor
: AppColors.blackColor,
),
border: Border.all(
color: isSelected
? color ?? AppColors.cardDisabledColor
: AppColors.cardDisabledColor,
width: isSelected ? 2 : 1,
),
boxShadow: isActive && !isReview
? [
BoxShadow(
color: pairColor.withOpacity(0.5),
spreadRadius: 1,
blurRadius: 4,
offset: const Offset(0, 2),
)
]
: null,
),
padding: const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
child: Text(
option,
style: AppTextStyles.blackTextStyle.copyWith(
fontWeight: FontWeight.w900,
color: isSelected || isActive
? AppColors.whiteColor
: AppColors.blackColor,
),
),
),

View File

@ -1,3 +1,4 @@
import 'package:english_learning/features/learning/modules/exercises/models/review_exercise_model.dart';
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
@ -5,54 +6,85 @@ import 'package:english_learning/features/learning/modules/exercises/models/exer
import 'package:provider/provider.dart';
class MultipleChoiceQuestion extends StatelessWidget {
final ExerciseModel exercise;
final dynamic exercise;
final bool isReview;
const MultipleChoiceQuestion({
super.key,
required this.exercise,
this.isReview = false,
});
@override
Widget build(BuildContext context) {
final provider = Provider.of<ExerciseProvider>(context);
final multipleChoice = exercise.choices as MultipleChoice;
final options = [
multipleChoice.optionA,
multipleChoice.optionB,
multipleChoice.optionC,
multipleChoice.optionD,
multipleChoice.optionE,
];
List<String> options = [];
String? studentAnswer;
return _buildOptionsList(options, provider);
if (exercise is ExerciseModel) {
final multipleChoice = exercise.choices as MultipleChoice;
options = [
multipleChoice.optionA,
multipleChoice.optionB,
multipleChoice.optionC,
multipleChoice.optionD,
multipleChoice.optionE,
];
} else if (exercise is ReviewExerciseDetail) {
if (exercise.multipleChoices?.isNotEmpty ?? false) {
final choices = exercise.multipleChoices!.first;
options = [
choices.optionA,
choices.optionB,
choices.optionC,
choices.optionD,
choices.optionE,
];
studentAnswer = exercise.answerStudent;
}
}
return _buildOptionsList(context, options, provider, studentAnswer);
}
Widget _buildOptionsList(List<String> options, ExerciseProvider provider) {
final optionLabels = List.generate(
options.length,
(index) =>
String.fromCharCode(65 + index), // Generate labels "A", "B", etc.
);
Widget _buildOptionsList(
BuildContext context,
List<String> options,
ExerciseProvider provider,
String? studentAnswer,
) {
return Column(
children: options.asMap().entries.map((entry) {
final index = entry.key;
final option = entry.value;
final optionLabel = String.fromCharCode(65 + index); // A, B, C, D, E
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: options.length,
itemBuilder: (context, i) {
final option = options[i];
final isSelected =
provider.answers[provider.currentExerciseIndex] == optionLabels[i];
bool isSelected = false;
if (isReview) {
isSelected = studentAnswer == optionLabel;
} else {
isSelected =
provider.answers[provider.currentExerciseIndex] == optionLabel;
}
return GestureDetector(
onTap: () =>
provider.answerQuestion(provider.currentExerciseIndex, option),
child: _buildOptionItem(optionLabels[i], option, isSelected),
onTap: isReview
? null
: () {
provider.answerQuestion(
provider.currentExerciseIndex, option);
},
child: _buildOptionItem(optionLabel, option, isSelected),
);
},
}).toList(),
);
}
Widget _buildOptionItem(String label, String option, bool isSelected) {
final backgroundColor =
isSelected ? AppColors.blueColor : AppColors.whiteColor;
final textColor = isSelected ? AppColors.whiteColor : AppColors.blackColor;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
@ -60,7 +92,7 @@ class MultipleChoiceQuestion extends StatelessWidget {
children: [
Container(
decoration: BoxDecoration(
color: isSelected ? AppColors.blueColor : AppColors.whiteColor,
color: backgroundColor,
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: isSelected
@ -68,16 +100,13 @@ class MultipleChoiceQuestion extends StatelessWidget {
: AppColors.cardDisabledColor,
),
),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
child: Text(
label,
style: AppTextStyles.blackTextStyle.copyWith(
fontWeight: FontWeight.w900,
color:
isSelected ? AppColors.whiteColor : AppColors.blackColor,
),
padding:
const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
child: Text(
label,
style: AppTextStyles.blackTextStyle.copyWith(
fontWeight: FontWeight.w900,
color: textColor,
),
),
),
@ -85,9 +114,8 @@ class MultipleChoiceQuestion extends StatelessWidget {
Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 8.0),
width: double.infinity,
decoration: BoxDecoration(
color: isSelected ? AppColors.blueColor : AppColors.whiteColor,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(20),
bottomRight: Radius.circular(20),
@ -104,8 +132,7 @@ class MultipleChoiceQuestion extends StatelessWidget {
child: Text(
option,
style: AppTextStyles.blackTextStyle.copyWith(
color:
isSelected ? AppColors.whiteColor : AppColors.blackColor,
color: textColor,
),
),
),

View File

@ -1,49 +1,90 @@
// true_false_question.dart
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/features/learning/modules/exercises/models/exercise_model.dart';
import 'package:provider/provider.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/features/learning/modules/exercises/models/review_exercise_model.dart';
import 'package:english_learning/features/learning/modules/exercises/providers/exercise_provider.dart';
class TrueFalseQuestion extends StatelessWidget {
final ExerciseModel exercise;
final dynamic exercise;
final bool isReview;
const TrueFalseQuestion({
super.key,
required this.exercise,
this.isReview = false,
});
@override
Widget build(BuildContext context) {
final provider = Provider.of<ExerciseProvider>(context);
final options = ['True', 'False'];
String? studentAnswer = _getStudentAnswer();
return _buildOptionsList(options, provider);
final options = [
{'label': 'A', 'value': '1', 'text': 'True'},
{'label': 'B', 'value': '0', 'text': 'False'}
];
return _buildOptionsList(context, options, provider, studentAnswer);
}
Widget _buildOptionsList(List<String> options, ExerciseProvider provider) {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: options.length,
itemBuilder: (context, i) {
final option = options[i];
final isSelected = provider.answers[provider.currentExerciseIndex] ==
(i == 0 ? '1' : '0');
String? _getStudentAnswer() {
if (isReview && exercise is ReviewExerciseDetail) {
return exercise.answerStudent;
}
return null;
}
Widget _buildOptionsList(
BuildContext context,
List<Map<String, String>> options,
ExerciseProvider provider,
String? studentAnswer,
) {
return Column(
children: options.map((option) {
final value = option['value']!;
final label = option['label']!;
final text = option['text']!;
bool isSelected = false;
bool isCorrect = false;
if (isReview && exercise is ReviewExerciseDetail) {
isSelected = studentAnswer == value;
isCorrect = exercise.isCorrect == 1;
} else {
isSelected = provider.answers[provider.currentExerciseIndex] == value;
}
return GestureDetector(
// onTap: () =>
// provider.answerQuestion(provider.currentExerciseIndex, option),
onTap: () => provider.answerQuestion(
provider.currentExerciseIndex, i == 0 ? '1' : '0'),
child: _buildOptionItem(option, isSelected),
onTap: isReview
? null
: () {
provider.answerQuestion(provider.currentExerciseIndex, value);
},
child: _buildOptionItem(
label,
text,
isSelected,
isReview && isSelected,
isCorrect,
),
);
},
}).toList(),
);
}
Widget _buildOptionItem(String option, bool isSelected) {
Widget _buildOptionItem(
String label,
String text,
bool isSelected,
bool isReviewSelected,
bool isCorrect,
) {
final backgroundColor =
isSelected ? AppColors.blueColor : AppColors.whiteColor;
final textColor = isSelected ? AppColors.whiteColor : AppColors.blackColor;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
@ -51,7 +92,7 @@ class TrueFalseQuestion extends StatelessWidget {
children: [
Container(
decoration: BoxDecoration(
color: isSelected ? AppColors.blueColor : AppColors.whiteColor,
color: backgroundColor,
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: isSelected
@ -59,18 +100,13 @@ class TrueFalseQuestion extends StatelessWidget {
: AppColors.cardDisabledColor,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14.0,
vertical: 10.0,
),
child: Text(
option,
style: AppTextStyles.blackTextStyle.copyWith(
fontWeight: FontWeight.w900,
color:
isSelected ? AppColors.whiteColor : AppColors.blackColor,
),
padding:
const EdgeInsets.symmetric(horizontal: 14.0, vertical: 10.0),
child: Text(
label,
style: AppTextStyles.blackTextStyle.copyWith(
fontWeight: FontWeight.w900,
color: textColor,
),
),
),
@ -78,9 +114,8 @@ class TrueFalseQuestion extends StatelessWidget {
Expanded(
child: Container(
margin: const EdgeInsets.only(bottom: 8.0),
width: double.infinity,
decoration: BoxDecoration(
color: isSelected ? AppColors.blueColor : AppColors.whiteColor,
color: backgroundColor,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(20),
bottomRight: Radius.circular(20),
@ -95,10 +130,9 @@ class TrueFalseQuestion extends StatelessWidget {
padding:
const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0),
child: Text(
option,
text,
style: AppTextStyles.blackTextStyle.copyWith(
color:
isSelected ? AppColors.whiteColor : AppColors.blackColor,
color: textColor,
),
),
),

View File

@ -71,22 +71,6 @@ class LevelProvider with ChangeNotifier {
// _lastCompletedLevel!['ID_LEVEL'] == levelId;
// }
Future<void> fetchStudentAnswers(String stdLearningId, String token) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_studentAnswers =
await _levelRepository.getStudentAnswers(stdLearningId, token);
} catch (e) {
_error = 'Error fetching student answers: ${e.toString()}';
} finally {
_isLoading = false;
notifyListeners();
}
}
bool isPretestFinished(String levelId) {
return _levels.any(
(level) => level.idLevel == levelId && level.idStudentLearning != null);

View File

@ -27,6 +27,32 @@ class PretestCard extends StatelessWidget {
final isFinished = levelProvider.isPretestFinished(pretest.idLevel);
final score = levelProvider.getPretestScore(pretest.idLevel);
// final isAllowed = levelProvider.isLevelAllowed(pretest.idLevel);
void navigateToMaterial() {
if (isFinished) {
// Mode review untuk pretest yang sudah selesai
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MaterialScreen(
levelId: pretest.idLevel,
isReview: true,
studentLearningId: pretest.idStudentLearning,
),
),
);
} else {
// Mode normal untuk pretest yang belum dikerjakan
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MaterialScreen(
levelId: pretest.idLevel,
isReview: false,
),
),
);
}
}
return Card(
shape: RoundedRectangleBorder(
@ -140,17 +166,7 @@ class PretestCard extends StatelessWidget {
// );
// }
// : () {},
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => MaterialScreen(
levelId: pretest.idLevel,
isReview: isFinished,
),
),
);
},
onPressed: navigateToMaterial,
),
],
),

View File

@ -1,5 +1,5 @@
import 'package:english_learning/core/services/dio_client.dart';
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/core/services/repositories/student_learning_repository.dart';
import 'package:english_learning/core/widgets/global_button.dart';
import 'package:english_learning/features/auth/provider/user_provider.dart';
@ -15,11 +15,13 @@ import 'package:provider/provider.dart';
class MaterialScreen extends StatefulWidget {
final String levelId;
final bool isReview;
final String? studentLearningId;
const MaterialScreen({
super.key,
required this.levelId,
this.isReview = false,
this.studentLearningId,
});
@override
@ -59,6 +61,11 @@ class _MaterialScreenState extends State<MaterialScreen>
}
Future<void> _createStudentLearning() async {
if (widget.isReview && widget.studentLearningId != null) {
// Jika mode review dan studentLearningId tersedia, langsung navigasi ke ExerciseScreen
_navigateToExercise(widget.studentLearningId!);
return;
}
setState(() {
_isLoading = true;
});
@ -77,15 +84,7 @@ class _MaterialScreenState extends State<MaterialScreen>
print('Student Learning created: ${result['message']}');
// Navigate to ExerciseScreen
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ExerciseScreen(
levelId: widget.levelId,
studentLearningId: result['payload']['ID_STUDENT_LEARNING'],
),
),
);
_navigateToExercise(result['payload']['ID_STUDENT_LEARNING']);
} catch (e) {
// Show error message
ScaffoldMessenger.of(context).showSnackBar(
@ -98,6 +97,19 @@ class _MaterialScreenState extends State<MaterialScreen>
}
}
void _navigateToExercise(String studentLearningId) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => ExerciseScreen(
levelId: widget.levelId,
studentLearningId: studentLearningId,
isReview: widget.isReview,
),
),
);
}
@override
Widget build(BuildContext context) {
return Consumer<LevelProvider>(builder: (context, levelProvider, child) {

View File

@ -16,21 +16,48 @@ class VideoPlayerWidget extends StatefulWidget {
}
class VideoPlayerWidgetState extends State<VideoPlayerWidget> {
late Widget _videoWidget;
VideoPlayerController? _videoController;
YoutubePlayerController? _youtubeController;
FlickManager? _flickManager;
bool _isLoading = true;
String? _error;
bool get wantKeepAlive => true;
bool _isYoutubeReady = false;
String? _youtubeId;
@override
void initState() {
super.initState();
_youtubeId = _extractYoutubeId(widget.videoUrl);
_initializeVideoPlayerWidget();
}
String? _extractYoutubeId(String url) {
try {
RegExp regExp = RegExp(
r'^.*(?:(?:youtu\.be\/|v\/|vi\/|u\/\w\/|embed\/|shorts\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*',
caseSensitive: false,
multiLine: false,
);
String? videoId = YoutubePlayer.convertUrlToId(url);
if (videoId != null) return videoId;
Match? match = regExp.firstMatch(url);
if (match != null && match.groupCount >= 1) {
return match.group(1);
}
if (url.contains('youtu.be/')) {
return url.split('youtu.be/')[1].split(RegExp(r'[?&]'))[0];
}
return null;
} catch (e) {
print('Error extracting YouTube ID: $e');
return null;
}
}
String _getPlayableUrl(String url) {
if (url.contains('drive.google.com')) {
final regex = RegExp(r'/d/([a-zA-Z0-9-_]+)');
@ -43,54 +70,66 @@ class VideoPlayerWidgetState extends State<VideoPlayerWidget> {
return url;
}
void _initializeVideoPlayerWidget() {
if (YoutubePlayer.convertUrlToId(widget.videoUrl) != null) {
_youtubeController = YoutubePlayerController(
initialVideoId: YoutubePlayer.convertUrlToId(widget.videoUrl)!,
flags: const YoutubePlayerFlags(
autoPlay: false,
mute: false,
),
);
Future<void> _initializeVideoPlayerWidget() async {
if (_youtubeId != null) {
try {
_youtubeController = YoutubePlayerController(
initialVideoId: _youtubeId!,
flags: const YoutubePlayerFlags(
autoPlay: false,
mute: false,
hideControls: false,
controlsVisibleAtStart: true,
enableCaption: true,
useHybridComposition: true,
forceHD: true,
),
);
_videoWidget = YoutubePlayer(
controller: _youtubeController!,
showVideoProgressIndicator: true,
onReady: () {
_youtubeController!.addListener(_youtubeListener);
if (mounted) {
setState(() {}); // Trigger rebuild with controller
}
} catch (e) {
if (mounted) {
setState(() {
_error = "Error initializing YouTube player: $e";
_isLoading = false;
});
},
);
}
}
} else {
_videoController = VideoPlayerController.networkUrl(
Uri.parse(_getPlayableUrl(widget.videoUrl)),
);
_videoController!.initialize().then((_) {
try {
_videoController = VideoPlayerController.networkUrl(
Uri.parse(_getPlayableUrl(widget.videoUrl)),
);
await _videoController!.initialize();
_flickManager = FlickManager(
videoPlayerController: _videoController!,
autoPlay: false,
);
_videoWidget = FlickVideoPlayer(
flickManager: _flickManager!,
);
setState(() {
_isLoading = false;
});
}).catchError((error) {
setState(() {
_error = "Error loading video: $error";
_isLoading = false;
});
});
if (mounted) {
setState(() {
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = "Error initializing video player: $e";
_isLoading = false;
});
}
}
}
}
void _youtubeListener() {
if (_youtubeController!.value.playerState == PlayerState.ended) {
_youtubeController!.seekTo(Duration.zero);
_youtubeController!.pause();
if (_youtubeController?.value.playerState == PlayerState.ended) {
_youtubeController?.seekTo(Duration.zero);
_youtubeController?.pause();
}
}
@ -104,20 +143,105 @@ class VideoPlayerWidgetState extends State<VideoPlayerWidget> {
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
} else if (_error != null) {
return Center(child: Text(_error!));
if (_youtubeId != null && _youtubeController != null) {
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: YoutubePlayerBuilder(
player: YoutubePlayer(
controller: _youtubeController!,
showVideoProgressIndicator: true,
progressIndicatorColor: Colors.red,
progressColors: const ProgressBarColors(
playedColor: Colors.red,
handleColor: Colors.redAccent,
),
onReady: () {
if (mounted) {
setState(() {
_isYoutubeReady = true;
_isLoading = false;
});
}
_youtubeController!.addListener(_youtubeListener);
},
onEnded: (YoutubeMetaData metaData) {
_youtubeController!.seekTo(Duration.zero);
_youtubeController!.pause();
},
bottomActions: [
CurrentPosition(),
ProgressBar(isExpanded: true),
RemainingDuration(),
PlaybackSpeedButton(),
],
),
builder: (context, player) {
return Container(
color: Colors.black,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _isYoutubeReady
? player
: const Center(
child: CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
);
},
),
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: _videoWidget,
child: Container(
color: Colors.black,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _buildContent(),
),
),
),
);
}
Widget _buildContent() {
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_error!,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
);
}
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
);
}
if (_flickManager != null) {
return FlickVideoPlayer(flickManager: _flickManager!);
}
return const SizedBox.shrink();
}
void stopAndResetVideo() {
if (_youtubeController != null) {
_youtubeController!.seekTo(Duration.zero);

View File

@ -1,4 +1,4 @@
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/features/auth/provider/user_provider.dart';
import 'package:english_learning/features/learning/modules/level/screens/level_list_screen.dart';
import 'package:english_learning/features/learning/modules/topics/providers/topic_provider.dart';

View File

@ -2,10 +2,10 @@ import 'package:english_learning/features/auth/provider/user_provider.dart';
import 'package:english_learning/features/learning/provider/section_provider.dart';
import 'package:english_learning/features/learning/widgets/section_card.dart';
import 'package:english_learning/features/learning/modules/topics/screens/topics_list_screen.dart';
import 'package:english_learning/features/learning/widgets/section_card_shimmer.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart';
class LearningScreen extends StatefulWidget {
const LearningScreen({
@ -102,9 +102,7 @@ class _LearningScreenState extends State<LearningScreen> {
return ListView.builder(
itemCount: 5, // Misalnya, kita menampilkan 5 shimmer items
itemBuilder: (context, index) {
return Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
return ShimmerWidget(
child: Container(
margin: const EdgeInsets.only(bottom: 16),
child: Row(

View File

@ -1,9 +1,9 @@
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/features/learning/modules/model/section_model.dart';
import 'package:flutter/material.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
class LearningCard extends StatelessWidget {
class LearningCard extends StatefulWidget {
final Section section;
final VoidCallback? onTap;
@ -13,6 +13,12 @@ class LearningCard extends StatelessWidget {
this.onTap,
});
@override
State<LearningCard> createState() => _LearningCardState();
}
class _LearningCardState extends State<LearningCard>
with SingleTickerProviderStateMixin {
String _getFullImageUrl(String thumbnail) {
if (thumbnail.startsWith('http')) {
return thumbnail;
@ -24,7 +30,7 @@ class LearningCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
onTap: widget.onTap,
child: Card(
color: AppColors.whiteColor,
shape: RoundedRectangleBorder(
@ -40,7 +46,7 @@ class LearningCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
_getFullImageUrl(section.thumbnail),
_getFullImageUrl(widget.section.thumbnail),
width: 90,
height: 104,
fit: BoxFit.cover,
@ -72,7 +78,7 @@ class LearningCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.name,
widget.section.name,
style: AppTextStyles.blackTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w900,
@ -80,7 +86,7 @@ class LearningCard extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
section.description,
widget.section.description,
style: AppTextStyles.disableTextStyle.copyWith(
fontSize: 13,
fontWeight: FontWeight.w500,

View File

@ -3,6 +3,7 @@ import 'package:shimmer/shimmer.dart';
class ShimmerWidget extends StatelessWidget {
final Widget child;
const ShimmerWidget({
super.key,
required this.child,

View File

@ -1,5 +1,5 @@
import 'dart:io';
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/core/utils/styles/theme.dart';
import 'package:english_learning/core/widgets/form_field/custom_field_widget.dart';
import 'package:english_learning/core/widgets/global_button.dart';
@ -60,7 +60,9 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
};
try {
userProvider.setLoading(true);
await userProvider.updateUserProfile(updatedData);
userProvider.setLoading(false);
showDialog(
context: context,
@ -186,7 +188,10 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
const SizedBox(height: 24),
GlobalButton(
text: 'Save Changes',
onPressed: () => _updateUserProfile(context),
isLoading: userProvider.isLoading,
onPressed: userProvider.isLoading
? null
: () => _updateUserProfile(context),
),
],
),

View File

@ -1,5 +1,5 @@
import 'package:bootstrap_icons/bootstrap_icons.dart';
import 'package:english_learning/core/services/repositories/constants.dart';
import 'package:english_learning/core/services/constants.dart';
import 'package:english_learning/features/auth/provider/user_provider.dart';
import 'package:english_learning/features/auth/screens/signin/signin_screen.dart';
import 'package:english_learning/features/settings/modules/change_password/screens/change_password_screen.dart';

View File

@ -676,10 +676,10 @@ packages:
dependency: transitive
description:
name: path_provider
sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
path_provider_android:
dependency: transitive
description: