feat: add flashcards and quizes features

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Achmad Baihaqi 2026-06-28 23:06:52 +07:00
parent e2c2936fae
commit 1ab2705f46
30 changed files with 6878 additions and 0 deletions

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import 'flashcard_histories_controller.dart';
class FlashcardHistoriesBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<FlashcardHistoriesController>(
() => FlashcardHistoriesController(),
);
}
}

View File

@ -0,0 +1,38 @@
import 'package:epic_story_app/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart';
import 'package:get/get.dart';
import '../../../domain/entities/flashcards/flashcard_history_entity.dart';
import '../flashcard_home/flashcard_home_controller.dart';
class FlashcardHistoriesController extends GetxController {
late FlashcardHistoryState selectedState;
final flashcardHomeController = Get.find<FlashCardHomeController>();
var histories = <FlashcardHistoryEntity>[].obs;
void openFromHistory(FlashcardHistoryEntity history) {
flashcardHomeController.goToFlashcardReadFromHistory(history);
}
var title = ''.obs;
@override
void onInit() {
super.onInit();
selectedState = Get.arguments;
switch (selectedState) {
case FlashcardHistoryState.allHistories:
histories.value = flashcardHomeController.historyFlashcards;
title.value = 'Semua Riwayat';
break;
case FlashcardHistoryState.learnHistories:
histories.value = flashcardHomeController.uncompletedFlashcardHistories;
title.value = 'Flashcard yang belum kamu selesaikan';
break;
case FlashcardHistoryState.quizHistories:
histories.value = flashcardHomeController.completedFlashcardHistories;
title.value = 'Kuis yang belum kamu kerjakan';
break;
}
}
}

View File

@ -0,0 +1,232 @@
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'flashcard_histories_controller.dart';
class FlashcardHistoriesPage extends GetView<FlashcardHistoriesController> {
const FlashcardHistoriesPage({super.key});
static const Color _mainBackground = Color(0xFFE0CEFF);
static const List<Color> _gradientCycle = [
Color(0xFFFFE588),
Color(0xFF77B4FF),
Color(0xFF4EFF4F),
];
@override
Widget build(BuildContext context) {
return Obx(() {
final histories = controller.histories;
final selectedCategory =
controller.flashcardHomeController.selectedCategory.value;
final activeCategory =
selectedCategory.isEmpty ? 'Semua' : selectedCategory;
return Scaffold(
backgroundColor: _mainBackground,
body: SafeArea(
bottom: false,
child: Column(
children: [
StackedInfoTopBar(
titleText: 'History Flashcard',
infoText: 'Mata Pelajran :\n$activeCategory',
isBackButtonVisible: true,
onBackTap: () => Get.back(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
child: histories.isEmpty
? const Center(
child: Text(
'Belum ada riwayat flashcard.',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: Color(0xFF2B2B2B),
),
),
)
: ListView.separated(
padding: EdgeInsets.zero,
itemCount: histories.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 12),
itemBuilder: (context, index) => _HistoryItem(
item: histories[index],
index: index,
gradientCycle: _gradientCycle,
onTap: () =>
controller.openFromHistory(histories[index]),
),
),
),
),
],
),
),
);
});
}
}
class _HistoryItem extends StatelessWidget {
const _HistoryItem({
required this.item,
required this.index,
required this.gradientCycle,
required this.onTap,
});
final FlashcardHistoryEntity item;
final int index;
final List<Color> gradientCycle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
gradientCycle[index % gradientCycle.length],
const Color(0xFFFFFFFF),
],
stops: const [0.0, 1.0],
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: 82,
height: 82,
color: Colors.white.withOpacity(0.72),
child: Image.asset(
_resolveCategoryImage(item.category, index),
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.black,
height: 1,
),
),
const SizedBox(height: 4),
Text(
item.summary ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_pill(
Icons.collections_bookmark_outlined,
'${item.totalCards ?? 0} kartu',
),
_pill(
Icons.quiz_outlined,
'${item.completedQuiz ?? 0}/${item.totalQuiz ?? (item.isHaveQuiz == true ? 1 : 0)}',
),
_pill(Icons.label_outline, item.category ?? 'Lainnya'),
],
),
],
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(top: 6),
child: Image.asset(
(item.isFinished ?? false)
? 'assets/images/icon-selesai.png'
: 'assets/images/icon-belum-selesai.png',
width: 62,
fit: BoxFit.contain,
),
),
],
),
),
);
}
Widget _pill(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: const Color(0xFF151515)),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFF151515),
),
),
],
),
);
}
String _resolveCategoryImage(String? category, int index) {
final normalized = (category ?? '').toLowerCase();
if (normalized.contains('matematika') || normalized.contains('ipa')) {
return 'assets/images/flashcard-matematika.png';
}
if (normalized.contains('bahasa') || normalized.contains('indo')) {
return 'assets/images/flashcard-bhs-indo.png';
}
return index % 2 == 0
? 'assets/images/flashcard-bhs-indo.png'
: 'assets/images/flashcard-matematika.png';
}
}

View File

@ -0,0 +1,5 @@
enum FlashcardHistoryState{
allHistories,
learnHistories,
quizHistories,
}

View File

@ -0,0 +1,21 @@
import 'package:epic_story_app/data/modules/children_module.dart';
import 'package:epic_story_app/data/modules/flashcard_module.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
import 'package:get/get.dart';
import 'flashcard_home_controller.dart';
class FlashCardHomeBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
FlashCardModule();
Get.lazyPut<FlashCardHomeController>(
() => FlashCardHomeController(
flashCardUsecase: Get.find<FlashCardUsecase>(),
childrenUsecase: Get.find<ChildrenUsecase>(),
),
);
}
}

View File

@ -0,0 +1,155 @@
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:get/get.dart';
class FlashCardHomeController extends GetxController {
final FlashCardUsecase flashCardUsecase;
final ChildrenUsecase childrenUsecase;
FlashCardHomeController({
required this.flashCardUsecase,
required this.childrenUsecase,
});
final flashcards = <FlashcardEntity>[].obs;
final historyFlashcards = <FlashcardHistoryEntity>[].obs;
final categories = <String>[].obs;
final selectedCategory = 'Semua'.obs;
var isLoading = false.obs;
final navController = Get.find<EpicNavigationController>();
List<FlashcardEntity> get filteredRecommendations {
if (selectedCategory.value == 'Semua') return flashcards;
return flashcards
.where((card) => card.category == selectedCategory.value)
.toList();
}
List<FlashcardHistoryEntity> get uncompletedFlashcardHistories {
return historyFlashcards.where((h) {
final total = h.totalCards ?? 0;
final cardsLen = h.cards?.length ?? 0;
return cardsLen < total;
}).toList();
}
List<FlashcardHistoryEntity> get completedFlashcardHistories {
return historyFlashcards.where((h) {
final total = h.totalCards ?? 0;
final cardsLen = h.cards?.length ?? 0;
final isFinished = h.isFinished ?? false;
return cardsLen == total && !isFinished;
}).toList();
}
List<FlashcardHistoryEntity> get filteredHistory {
if (selectedCategory.value == 'Semua') return historyFlashcards;
return historyFlashcards
.where((card) => card.category == selectedCategory.value)
.toList();
}
@override
void onInit() async {
super.onInit();
await fetchFlashcards();
ever(navController.selectedCategory, (value) {
selectedCategory.value = value;
});
}
Future<void> fetchFlashcards() async {
try {
EpicLog.debug(
'FlashCardHomeController - Fetching flashcards and histories : $isLoading');
isLoading.value = true;
final fetchedHistory = await childrenUsecase.getFlashcardHistories();
final fetchedFlashcards = await flashCardUsecase.getFlashcards();
historyFlashcards.assignAll(fetchedHistory);
flashcards.assignAll(fetchedFlashcards);
sortedHistories();
EpicLog.debug(
'FlashCardHomeController - Finished fetching flashcards and histories : $isLoading');
final uniqueCategories = {
'Semua',
...historyFlashcards.map((e) => e.category ?? 'Lainnya'),
...flashcards.map((e) => e.category ?? 'Lainnya'),
};
EpicLog.debug(
'FlashCardHomeController - Unique categories extracted: $uniqueCategories');
categories.assignAll(uniqueCategories.toList());
selectedCategory.value = categories.first;
} catch (ex, s) {
EpicLog.exception(ex, s, 'Failed to fetch flashcards');
} finally {
isLoading.value = false;
EpicLog.debug(
"homeController - Finished fetching flashcards : $isLoading");
}
}
void goToFlashcardRead(FlashcardEntity flashcard) async {
await Get.toNamed(
EpicRoutes.flashCardRead,
arguments: flashcard,
);
var histories = await childrenUsecase.getFlashcardHistories(
refreshLocal: true,
);
historyFlashcards.clear();
historyFlashcards.assignAll(histories);
sortedHistories();
}
void goToFlashcardReadFromHistory(
FlashcardHistoryEntity history,
) async {
final selectedFlashcard = flashcards
.where((element) => element.flashcardId == history.flashcardId)
.firstOrNull;
await Get.toNamed(
EpicRoutes.flashCardRead,
arguments: selectedFlashcard,
);
final histories = await childrenUsecase.getFlashcardHistories(
refreshLocal: true,
);
historyFlashcards
..clear()
..assignAll(histories);
sortedHistories();
}
void goToFlashcardHistories(FlashcardHistoryState state) {
Get.toNamed(
EpicRoutes.flashcardHistories,
arguments: state,
);
}
void sortedHistories() {
historyFlashcards.sort((a, b) =>
b.lastReadAt?.compareTo(
a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ??
0);
}
void selectCategory(String category) {
selectedCategory.value = category;
}
}

View File

@ -0,0 +1,457 @@
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashCardHomePage extends GetView<FlashCardHomeController> {
const FlashCardHomePage({super.key});
static const Color _mainBackground = Color(0xFFE0CEFF);
static const double _historyCardWidth = 200;
static const double _historyCardHeight = 120;
static const List<Color> _gradientCycle = [
Color(0xFFFFE588),
Color(0xFF77B4FF),
Color(0xFF4EFF4F),
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _mainBackground,
body: Obx(
() {
final history = controller.filteredHistory;
final latestHistory = history.take(5).toList();
final recommendations = controller.filteredRecommendations;
final learnCount = controller.uncompletedFlashcardHistories.length;
final quizCount = controller.completedFlashcardHistories.length;
return SingleChildScrollView(
padding: EdgeInsets.only(left: 10, top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Riwayat Flashcard'),
const SizedBox(height: 12),
if (history.isEmpty)
_buildEmptyState('Belum ada riwayat untuk kategori ini.')
else
_buildStackedHistoryList(latestHistory),
const SizedBox(height: 24),
_buildActionButtons(learnCount, quizCount),
const SizedBox(height: 24),
_buildSectionTitle('Flashcard yang dapat kamu pelajari'),
const SizedBox(height: 10),
if (recommendations.isEmpty)
_buildEmptyState(
'Belum ada flashcard yang dapat kamu pelajari.')
else
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: recommendations.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) => _buildRecommendationCard(
recommendations[index],
index,
),
),
SizedBox(height: 24),
],
),
);
},
),
);
}
Widget _buildStackedHistoryList(List<FlashcardHistoryEntity> latestHistory) {
final totalItems = latestHistory.length + 1;
final totalColumns = (totalItems / 2).ceil();
return SizedBox(
height: (_historyCardHeight * 2) + 12,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(totalColumns, (columnIndex) {
final topIndex = columnIndex * 2;
final bottomIndex = topIndex + 1;
return Padding(
padding: EdgeInsets.only(
right: columnIndex == totalColumns - 1 ? 0 : 12,
),
child: Column(
children: [
_buildStackedHistoryItem(topIndex, latestHistory),
const SizedBox(height: 12),
_buildStackedHistoryItem(bottomIndex, latestHistory),
],
),
);
}),
),
),
);
}
Widget _buildStackedHistoryItem(
int index,
List<FlashcardHistoryEntity> latestHistory,
) {
final seeAllIndex = latestHistory.length;
if (index > seeAllIndex) {
return const SizedBox(
width: _historyCardWidth,
height: _historyCardHeight,
);
}
if (index == seeAllIndex) {
return _buildSeeAllHistoryButton(index);
}
return _buildHistoryCard(latestHistory[index], index);
}
Widget _buildSectionTitle(String title) {
return Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: Colors.black,
height: 1,
letterSpacing: 0.2,
),
);
}
Widget _buildHistoryCard(FlashcardHistoryEntity item, int index) {
return GestureDetector(
onTap: () {
controller.goToFlashcardReadFromHistory(item);
},
child: Container(
width: _historyCardWidth,
height: _historyCardHeight,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
decoration: _cardDecoration(index),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: Colors.black,
),
),
const Spacer(),
Row(
children: [
_miniPill(
icon: Icons.collections_bookmark_outlined,
label: '${item.totalCards ?? 0}',
),
const SizedBox(width: 6),
_miniPill(
icon: Icons.quiz_outlined,
label:
'${item.completedQuiz ?? 0}/${item.totalQuiz ?? (item.isHaveQuiz == true ? 1 : 0)}',
),
],
),
],
),
),
);
}
Widget _buildSeeAllHistoryButton(int index) {
return GestureDetector(
onTap: () {
controller.goToFlashcardHistories(
FlashcardHistoryState.allHistories,
);
},
child: Container(
width: _historyCardWidth,
height: _historyCardHeight,
decoration: _cardDecoration(index),
child: const Center(
child: Icon(
Icons.arrow_right_alt_rounded,
size: 32,
color: Colors.black87,
),
),
),
);
}
Widget _buildActionButtons(int learnCount, int quizCount) {
return Row(
children: [
Expanded(
child: _buildActionCapsule(
label: 'Belajar ($learnCount)',
imagePath: 'assets/images/yellow-button.png',
onPressed: () {
controller
.goToFlashcardHistories(FlashcardHistoryState.learnHistories);
},
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCapsule(
label: 'Kuis ($quizCount)',
imagePath: 'assets/images/green-button.png',
onPressed: () {
controller
.goToFlashcardHistories(FlashcardHistoryState.quizHistories);
},
),
),
],
);
}
Widget _buildActionCapsule({
required String label,
required String imagePath,
required VoidCallback onPressed,
}) {
return GestureDetector(
onTap: onPressed,
child: SizedBox(
height: 60,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
imagePath,
fit: BoxFit.fill,
),
),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w900,
height: 1,
shadows: [
Shadow(
color: Color(0x66000000),
offset: Offset(0, 1),
blurRadius: 2,
)
],
),
),
],
),
),
);
}
Widget _buildRecommendationCard(FlashcardEntity item, int index) {
return GestureDetector(
onTap: () {
controller.goToFlashcardRead(item);
},
child: Container(
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
decoration: _cardDecoration(index),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
width: 82,
height: 82,
color: Colors.white.withOpacity(0.72),
child: Image.asset(
_resolveCategoryImage(item.category, index),
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.black,
height: 1,
),
),
const SizedBox(height: 4),
Text(
item.summary ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
_miniPill(
icon: Icons.collections_bookmark_outlined,
label: '${item.totalCards ?? 0} kartu',
),
_miniPill(
icon: Icons.label_outline,
label: item.category ?? 'Lainnya',
),
Text(
'${item.reads ?? 0}x dibaca',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFF232323),
),
),
],
),
// if (totalQuiz > 0)
// Padding(
// padding: const EdgeInsets.only(top: 6),
// child: _miniPill(
// icon: Icons.task_alt_outlined,
// label:
// '${completedQuiz.clamp(0, totalQuiz)}/${totalQuiz.clamp(0, 99)} kuis',
// ),
// ),
],
),
),
],
),
),
);
}
Widget _miniPill({
required IconData icon,
required String label,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.9),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 13, color: const Color(0xFF151515)),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFF151515),
),
),
],
),
);
}
String _resolveCategoryImage(String? category, int index) {
final normalized = (category ?? '').toLowerCase();
if (normalized.contains('matematika') || normalized.contains('ipa')) {
return 'assets/images/flashcard-matematika.png';
}
if (normalized.contains('bahasa') || normalized.contains('indo')) {
return 'assets/images/flashcard-bhs-indo.png';
}
return index % 2 == 0
? 'assets/images/flashcard-bhs-indo.png'
: 'assets/images/flashcard-matematika.png';
}
BoxDecoration _cardDecoration(int index) => BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_gradientCycle[index % _gradientCycle.length],
const Color(0xFFFFFFFF),
],
stops: const [0.0, 1.0],
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.16),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
);
Widget _buildEmptyState(String message) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF77B4FF),
Color(0xFFFFFFFF),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 1.2),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Color(0xFF151515), size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF151515),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,265 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
const List<int> _fallbackGradientIndexes = [2, 7, 6];
const List<String> _thumbAssets = [
'assets/images/flashcard-bhs-indo.png',
'assets/images/flashcard-matematika.png',
'assets/images/tipe-flashcard.png',
];
void showAddFlashcardCollectionSheet(
BuildContext context,
FlashcardReadController controller,
FlashcardEntity flashcard,
) {
Get.bottomSheet(
SafeArea(
top: false,
child: Obx(
() {
final items = controller.collections;
return Container(
decoration: BoxDecoration(
color: const Color(0xFFC6B2E6),
borderRadius: BorderRadius.vertical(
top: Radius.circular(EpicSize.calculatedSize(34)),
),
),
padding: EdgeInsets.fromLTRB(
EpicSize.calculatedSize(16),
EpicSize.calculatedSize(14),
EpicSize.calculatedSize(16),
EpicSize.calculatedSize(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Collection',
style: EpicSize.titleLarge.copyWith(
fontSize: EpicSize.calculatedSize(28),
fontWeight: FontWeight.w800,
color: const Color(0xFF111111),
),
),
SizedBox(height: EpicSize.calculatedSize(20)),
if (items.isEmpty)
Padding(
padding: EdgeInsets.symmetric(
vertical: EpicSize.calculatedSize(24),
),
child: Text(
'Belum ada koleksi flashcard.',
style: EpicSize.bodyMedium.copyWith(
fontSize: EpicSize.calculatedSize(16),
color: const Color(0xFF333333),
),
),
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.6,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, __) =>
SizedBox(height: EpicSize.calculatedSize(12)),
itemBuilder: (_, index) {
final item = items[index];
final colorIndex = item.bgColor ??
_fallbackGradientIndexes[
index % _fallbackGradientIndexes.length];
final gradient = collectionColorAt(colorIndex).gradient;
final thumbAsset =
_thumbAssets[index % _thumbAssets.length];
final category = (item.categories != null &&
item.categories!.isNotEmpty)
? item.categories!.first
: '-';
return _FlashcardCollectionSheetTile(
item: item,
category: category,
thumbnailAsset: thumbAsset,
gradient: gradient,
onAdd: () {
controller.addFlashcardToCollection(
collectionId: item.collectionId,
flashcardId: flashcard.flashcardId ?? '',
);
},
);
},
),
),
],
),
);
},
),
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
}
class _FlashcardCollectionSheetTile extends StatelessWidget {
const _FlashcardCollectionSheetTile({
required this.item,
required this.category,
required this.thumbnailAsset,
required this.gradient,
required this.onAdd,
});
final FlashCardCollectionEntity item;
final String category;
final String thumbnailAsset;
final LinearGradient gradient;
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.all(EpicSize.calculatedSize(10)),
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(EpicSize.calculatedSize(20)),
border: Border.all(color: const Color(0x99FFFFFF), width: 1.1),
boxShadow: [
BoxShadow(
color: const Color(0x22000000),
blurRadius: EpicSize.calculatedSize(8),
offset: Offset(0, EpicSize.calculatedSize(2)),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(width: EpicSize.calculatedSize(10)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: EpicSize.titleMedium.copyWith(
fontSize: EpicSize.calculatedSize(20),
fontWeight: FontWeight.w900,
color: const Color(0xFF111111),
),
),
SizedBox(height: EpicSize.calculatedSize(7)),
Wrap(
spacing: EpicSize.calculatedSize(6),
runSpacing: EpicSize.calculatedSize(6),
children: [
_chip(
icon: Icons.folder_open_outlined,
text: '${item.totalFlashcard ?? 0} kartu',
),
_chip(text: category),
],
),
],
),
),
SizedBox(width: EpicSize.calculatedSize(8)),
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: onAdd,
child: Container(
width: EpicSize.calculatedSize(28),
height: EpicSize.calculatedSize(28),
decoration: const BoxDecoration(
color: Color.fromARGB(255, 47, 85, 255),
shape: BoxShape.circle,
),
child: Icon(
Icons.add_rounded,
size: EpicSize.calculatedSize(20),
color: Colors.white,
),
),
),
),
],
),
);
}
Widget _chip({required String text, IconData? icon}) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: EpicSize.calculatedSize(12),
vertical: EpicSize.calculatedSize(9),
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.55),
borderRadius: BorderRadius.circular(EpicSize.calculatedSize(6)),
border: Border.all(color: const Color(0xFF111111), width: 0.9),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon,
size: EpicSize.calculatedSize(13),
color: const Color(0xFF111111)),
SizedBox(width: EpicSize.calculatedSize(4)),
],
Text(
text,
style: EpicSize.bodySmall.copyWith(
fontSize: EpicSize.calculatedSize(12),
fontWeight: FontWeight.w600,
color: const Color(0xFF111111),
),
),
],
),
);
}
}
class _Thumbnail extends StatelessWidget {
const _Thumbnail({required this.assetPath});
final String assetPath;
@override
Widget build(BuildContext context) {
return Container(
width: EpicSize.calculatedSize(64),
height: EpicSize.calculatedSize(64),
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(EpicSize.calculatedSize(14)),
border: Border.all(color: const Color(0x44FFFFFF), width: 1.1),
),
clipBehavior: Clip.antiAlias,
child: Image.asset(
assetPath,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
Icons.style_rounded,
color: const Color(0xFF666666),
size: EpicSize.calculatedSize(24),
),
),
);
}
}

View File

@ -0,0 +1,128 @@
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'dialog_add_flashcard_coll.dart';
class FlashReadBottomContent extends StatelessWidget {
const FlashReadBottomContent({
super.key,
required this.controller,
});
final FlashcardReadController controller;
@override
Widget build(BuildContext context) {
return Obx(
() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
child: Row(
children: [
_navArrowButton(
onTap: controller.goPrev,
enabled: !controller.isFlipping.value &&
controller.currentIndex.value > 0,
isPrevious: true,
),
const SizedBox(width: 12),
_navArrowButton(
onTap: controller.goNext,
enabled: !controller.isFlipping.value,
isPrevious: false,
),
const Spacer(),
Row(
children: [
_actionButton(
icon: controller.isSoundOn.value
? Icons.volume_up_rounded
: Icons.volume_off_rounded,
onTap: controller.toggleSound,
enabled: !controller.isFlipping.value,
backgroundColor: const Color(0xFF999999),
iconColor: controller.isSoundOn.value
? const Color(0xFFF3D86A)
: const Color(0xFFE4E4E4),
),
const SizedBox(width: 12),
_actionButton(
icon: controller.isBookmarked.value
? Icons.bookmark
: Icons.bookmark_border,
onTap: () {
showAddFlashcardCollectionSheet(
context,
controller,
controller.flashcard,
);
},
enabled: true,
backgroundColor: const Color(0xFFFFED8C),
iconColor: const Color(0xFF8D8D8D),
),
],
),
],
),
);
},
);
}
Widget _navArrowButton({
required VoidCallback onTap,
required bool enabled,
required bool isPrevious,
}) {
return _actionButton(
icon: Icons.play_arrow_rounded,
onTap: onTap,
enabled: enabled,
backgroundColor: const Color(0xFF66B0FF),
iconColor: const Color(0xFF151515),
isPrevious: isPrevious,
);
}
Widget _actionButton({
required IconData icon,
required VoidCallback onTap,
required bool enabled,
required Color backgroundColor,
required Color iconColor,
bool isPrevious = false,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onTap : null,
borderRadius: BorderRadius.circular(12),
child: Ink(
width: 62,
height: 62,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF2A2A2A),
width: 1.2,
),
),
child: Center(
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateZ(isPrevious ? 3.14159 : 0),
child: Icon(
icon,
size: 36,
color: iconColor,
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,453 @@
import 'dart:math';
import 'package:epic_story_app/core/styles/epic_box_colors.dart';
import 'package:epic_story_app/core/styles/epic_app_size.dart';
import 'package:epic_story_app/core/widgets/cards/box_shadow.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_card_entity.dart';
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:get/get.dart';
import '../flashcard_read_controller.dart';
class FlashReadMainContent extends StatelessWidget {
const FlashReadMainContent({
super.key,
required this.controller,
});
final FlashcardReadController controller;
@override
Widget build(BuildContext context) {
return Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final cardWidth = EpicAppSize.screenWidth * 0.85;
final cardHeight = constraints.maxHeight * 0.95;
return Center(
child: GestureDetector(
onHorizontalDragUpdate: (details) {
controller.updateDrag(details.delta.dx);
},
onHorizontalDragEnd: (_) {
controller.endDrag();
},
child: Obx(() {
final dragX = controller.dragOffset.value;
final isQuizCard = controller.isQuiz;
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: dragX),
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
builder: (context, dx, child) {
final rotationZ = (dx / 320) * 0.18; // softer tilt
final lift = -(dx.abs() / 320) * 10; // softer lift
return AnimatedBuilder(
animation: controller.flipAnimation,
builder: (context, animatedChild) {
final flipValue = controller.flipAnimation.value;
final direction = controller.flipDirection.value;
final rawAngle = flipValue * pi * direction;
final isSecondHalf = flipValue > 0.5;
final correctedAngle = isSecondHalf
? rawAngle - (pi * direction)
: rawAngle;
final flipCenter = (flipValue - 0.5).abs();
final scale = 0.96 +
(0.04 * (1 - flipCenter * 2).clamp(0.0, 1.0));
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.0022)
..translate(dx, lift)
..rotateZ(rotationZ)
..rotateY(correctedAngle)
..scale(scale),
child: animatedChild,
);
},
child: child,
);
},
child: Stack(
clipBehavior: Clip.none,
children: [
ShadowBox(
key: ValueKey(controller.currentIndex.value),
width: cardWidth,
height: cardHeight,
padding: const EdgeInsets.all(18),
cardColor: BoxColors.cardColorQuiz,
cardGradient: isQuizCard
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF118B99),
Color(0xFF1CE7FF),
],
stops: [0.1, 1.0],
),
shadowGradient: isQuizCard
? null
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF0D6874),
Color(0xFF118B99),
],
),
radius: BorderRadius.circular(
EpicAppSize.calculatedSize(24),
),
child: LayoutBuilder(
builder: (context, boxConstraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: boxConstraints.maxHeight,
),
child: Center(
child: Obx(() {
if (controller.isLoading.value) {
return const CircularProgressIndicator();
}
final image = controller.currentImage.value;
final text = controller.currentText.value;
final hStart =
controller.highlightedStart.value;
final hEnd =
controller.highlightedEnd.value;
const baseStyle = TextStyle(
fontSize: 24,
color: Colors.black,
fontWeight: FontWeight.bold,
height: 1.4,
);
InlineSpan _buildWordSpan(
String word,
int start,
TextStyle style,
int? hStart,
int? hEnd,
) {
final end = start + word.length;
final hasHighlight = hStart != null &&
hEnd != null &&
!(end <= hStart || start >= hEnd);
final wordStyle = hasHighlight
? style.copyWith(
backgroundColor: Colors.yellow
.withOpacity(0.4),
)
: style;
return TextSpan(
text: word,
style: wordStyle,
recognizer: TapGestureRecognizer()
..onTap = () => controller
.speakWordAt(start, end),
);
}
List<InlineSpan> _buildSpans(
String fullText) {
final spans = <InlineSpan>[];
int cursor = 0;
final regex = RegExp(r'\s+');
for (final match
in regex.allMatches(fullText)) {
final word = fullText.substring(
cursor, match.start);
if (word.isNotEmpty) {
spans.add(_buildWordSpan(word, cursor,
baseStyle, hStart, hEnd));
}
final space = fullText.substring(
match.start, match.end);
spans.add(TextSpan(
text: space, style: baseStyle));
cursor = match.end;
}
if (cursor < fullText.length) {
final tail = fullText.substring(cursor);
spans.add(_buildWordSpan(tail, cursor,
baseStyle, hStart, hEnd));
}
return spans;
}
if (controller.isQuiz) {
return controller.getQuizWidget();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Visibility(
visible:
image != null && image.isNotEmpty,
child: Column(
children: [
ClipRRect(
borderRadius:
BorderRadius.circular(16),
child: Image.network(
image ?? '',
width: cardWidth * 0.9,
height: cardHeight * 0.4,
fit: BoxFit.cover,
errorBuilder: (context, error,
stackTrace) {
return Container(
width: cardWidth * 0.9,
height: cardHeight * 0.4,
color: Colors.black12,
alignment:
Alignment.center,
child: const Icon(
Icons
.broken_image_outlined,
size: 48,
color: Colors.black45,
),
);
},
),
),
const SizedBox(height: 12),
],
),
),
RichText(
textAlign: TextAlign.center,
text: TextSpan(
children: _buildSpans(text),
),
),
],
);
}),
),
),
);
},
),
),
Positioned(
top: -20,
right: 16,
child: Obx(() {
if (controller.isLoading.value) {
return const SizedBox();
}
final isCorrect =
controller.quizController.isAnswerCorrect.value;
String? explanation;
if (!controller.isQuiz) {
return const SizedBox();
}
FlashcardCardEntity? currentCard =
controller.currentCard.value;
final quizType = currentCard?.quizType;
if (quizType == 1) {
explanation =
currentCard?.quiz?.multipleChoice?.explanation;
} else if (quizType == 2) {
explanation =
currentCard?.quiz?.fillBlank?.explanation;
} else if (quizType == 3) {
explanation = currentCard?.quiz?.group?.explanation;
}
if (isCorrect) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => controller.quizController
.showExplanationDialog(explanation),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Color(0xFFFFD700),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.black,
width: 2,
),
),
child: Icon(
Icons.help_outline,
size: 22,
color: Colors.black,
),
),
),
);
}
if (isCorrect) {
return const SizedBox();
}
final retries = controller.retryQuestion.value;
if (retries <= 0) {
return Row(
children: [
_PulsingRetryButton(
onPressed: () {
controller.generateQuiz(
forceGenerate: true,
);
},
),
SizedBox(width: 8),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => controller.quizController
.showExplanationDialog(explanation),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Color(0xFFFFD700),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.black,
width: 1.2,
),
),
child: Icon(
Icons.help_outline,
size: 22,
color: Colors.black,
),
),
),
),
],
);
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: BoxColors.purplePrimary.backgroundColor,
width: 1.1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (index) {
final filled = retries > index;
return Padding(
padding:
EdgeInsets.only(left: index == 0 ? 0 : 8),
child: Icon(
filled
? Icons.favorite
: Icons.favorite_border,
size: 30,
color: filled
? Colors.pink
: BoxColors.purplePrimary.textColor,
),
);
}),
),
);
}),
),
],
),
);
}),
),
);
},
),
);
}
}
class _PulsingRetryButton extends StatefulWidget {
const _PulsingRetryButton({required this.onPressed});
final VoidCallback onPressed;
@override
State<_PulsingRetryButton> createState() => _PulsingRetryButtonState();
}
class _PulsingRetryButtonState extends State<_PulsingRetryButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_scaleAnimation = Tween<double>(begin: 0.94, end: 1.06).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scaleAnimation,
child: TextButton(
style: TextButton.styleFrom(
backgroundColor: Color(0xFF0DFF00),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
side: BorderSide(
color: Colors.black,
width: 1.1,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: widget.onPressed,
child: const Text(
'Coba Lagi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
);
}
}

View File

@ -0,0 +1,189 @@
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashReadTopContent extends StatelessWidget {
const FlashReadTopContent({
super.key,
required this.controller,
});
final FlashcardReadController controller;
@override
Widget build(BuildContext context) {
return Obx(() {
final current = controller.currentIndex.value + 1;
final total = controller.totalCards;
final progress = total <= 0 ? 0.0 : (current / total).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 4),
child: Column(
children: [
Row(
children: [
_TopBackButton(onTap: Get.back),
const SizedBox(width: 10),
Expanded(
child: SizedBox(
height: 55,
child: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/images/judul-kartu.png',
fit: BoxFit.fill,
),
),
Positioned.fill(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
),
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
padding: EdgeInsets.only(bottom: 8.0),
child: Text(
controller.flashcard.title ??
'Judul Flashcard',
maxLines: 1,
textAlign: TextAlign.center,
softWrap: false,
style: const TextStyle(
color: Colors.white,
height: 1,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
),
),
),
),
),
],
),
),
),
const SizedBox(width: 10),
],
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: _ReadProgressBar(
progress: progress,
current: current,
total: total,
),
),
],
),
);
});
}
}
class _TopBackButton extends StatelessWidget {
const _TopBackButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Ink(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.18),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
size: 21,
color: Colors.black,
),
),
),
);
}
}
class _ReadProgressBar extends StatelessWidget {
const _ReadProgressBar({
required this.progress,
required this.current,
required this.total,
});
final double progress;
final int current;
final int total;
@override
Widget build(BuildContext context) {
return Container(
height: 16,
decoration: BoxDecoration(
color: const Color(0xFFD7C7EF),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF2B2451),
width: 1,
),
),
child: Stack(
children: [
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: Container(
margin: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
color: const Color(0xFF1CE7A6),
borderRadius: BorderRadius.horizontal(
left: const Radius.circular(10),
right: progress >= 0.99
? const Radius.circular(10)
: Radius.zero,
),
),
),
),
),
Positioned(
right: 20,
top: 0,
bottom: 0,
child: Center(
child: Text(
'$current/$total',
style: const TextStyle(
color: Colors.black,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,87 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:flutter/material.dart';
class CollectionTile extends StatelessWidget {
final String title;
final int totalBooks;
final VoidCallback onAdd;
const CollectionTile({
super.key,
required this.title,
required this.totalBooks,
required this.onAdd,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: EpicSize.radiusSmall,
border: Border.all(color: Colors.black26),
),
padding: EdgeInsets.all(EpicSize.sizeSmall),
child: Row(
children: [
Container(
width: EpicSize.calculatedSize(32),
height: EpicSize.calculatedSize(32),
decoration: BoxDecoration(
color: Colors.black12,
borderRadius: EpicSize.radiusExtraSmall,
),
child: const Icon(Icons.image, size: 18, color: Colors.black45),
),
SizedBox(width: EpicSize.sizeSmall),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style:
EpicSize.titleSmall.copyWith(fontWeight: FontWeight.w700),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: EpicSize.sizeSuperSmall),
Container(
padding: EdgeInsets.symmetric(
horizontal: EpicSize.sizeSmall,
vertical: EpicSize.sizeSuperSmall,
),
decoration: BoxDecoration(
borderRadius: EpicSize.radiusExtraSmall,
border: Border.all(color: Colors.black45),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.book_outlined, size: 14),
SizedBox(width: EpicSize.sizeSuperSmall),
Text('Total Buku : $totalBooks',
style: EpicSize.bodySmall),
],
),
),
],
),
),
SizedBox(width: EpicSize.sizeSmall),
OutlinedButton(
onPressed: onAdd,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(
horizontal: EpicSize.sizeSmall,
vertical: EpicSize.sizeSuperSmall,
),
side: const BorderSide(color: Colors.black45),
),
child: Icon(Icons.add,
color: Colors.black45, size: EpicSize.calculatedSize(20)),
),
],
),
);
}
}

View File

@ -0,0 +1,203 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// Lightweight, reusable confetti burst that fires upward from the bottom
/// center. Trigger via [ConfettiBurstController.fire()].
class ConfettiBurstController extends ChangeNotifier {
void fire() => notifyListeners();
}
class ConfettiBurst extends StatefulWidget {
const ConfettiBurst({
super.key,
required this.controller,
required this.child,
this.colors = const [
Color(0xFF6B42B8),
Color(0xFF2ECC71),
Color(0xFFF39C12),
Color(0xFFE74C3C),
Color(0xFF3498DB),
],
this.particleCount = 140,
this.duration = const Duration(milliseconds: 1600),
});
final ConfettiBurstController controller;
final Widget child;
final List<Color> colors;
final int particleCount;
final Duration duration;
@override
State<ConfettiBurst> createState() => _ConfettiBurstState();
}
class _ConfettiBurstState extends State<ConfettiBurst>
with SingleTickerProviderStateMixin {
late final AnimationController _anim;
final List<_ConfettiParticle> _particles = [];
final math.Random _rand = math.Random();
@override
void initState() {
super.initState();
_anim = AnimationController(vsync: this, duration: widget.duration)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_anim.reset();
}
});
widget.controller.addListener(_onFire);
}
@override
void didUpdateWidget(covariant ConfettiBurst oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onFire);
widget.controller.addListener(_onFire);
}
}
@override
void dispose() {
widget.controller.removeListener(_onFire);
_anim.dispose();
super.dispose();
}
void _onFire() {
_particles
..clear()
..addAll(List.generate(widget.particleCount, (_) => _spawnParticle()));
_anim.forward(from: 0);
}
_ConfettiParticle _spawnParticle() {
final fan = 1.7; // wider spread for big burst
final angle = -math.pi / 2 + (_rand.nextDouble() * fan * 2 - fan);
final speed =
_rand.nextDouble() * 900 + 1100; // px/s equivalent for higher throw
final size = _rand.nextDouble() * 7 + 7;
final sway = _rand.nextDouble() * math.pi * 2;
final spin = _rand.nextDouble() * math.pi * 2.2;
final color = widget.colors[_rand.nextInt(widget.colors.length)];
return _ConfettiParticle(
angle: angle,
speed: speed,
size: size,
sway: sway,
spin: spin,
color: color,
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
child!,
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _ConfettiPainter(
progress: _anim.value,
particles: _particles,
durationSeconds: widget.duration.inMilliseconds / 1000,
),
),
),
),
],
);
},
child: widget.child,
);
}
}
class _ConfettiParticle {
_ConfettiParticle({
required this.angle,
required this.speed,
required this.size,
required this.sway,
required this.spin,
required this.color,
});
final double angle;
final double speed;
final double size;
final double sway;
final double spin;
final Color color;
}
class _ConfettiPainter extends CustomPainter {
_ConfettiPainter({
required this.progress,
required this.particles,
required this.durationSeconds,
});
final double progress;
final List<_ConfettiParticle> particles;
final double durationSeconds;
@override
void paint(Canvas canvas, Size size) {
if (progress <= 0 || particles.isEmpty) return;
final centerX = size.width / 2;
final baseY = size.height;
const gravity = 420.0; // slower pull so pieces climb higher before falling
final paint = Paint();
for (final p in particles) {
final t = progress * durationSeconds;
final vx = math.cos(p.angle) * p.speed;
final vy = math.sin(p.angle) * p.speed;
var x = centerX + (vx * t);
// start from bottom, shoot upward (vy negative), then gravity pulls down
var y = baseY + (vy * t) + 0.5 * gravity * math.pow(t, 2);
// horizontal sway
x += math.sin(t * 8 + p.sway) * 12;
// Cap so we don't paint way off canvas
if (y < -80 || y > size.height + 140) continue;
final rotation = p.spin * t;
paint.color = p.color.withOpacity((1 - progress).clamp(0.0, 1.0));
canvas.save();
canvas.translate(x, y);
canvas.rotate(rotation);
canvas.drawRRect(
RRect.fromRectAndCorners(
Rect.fromCenter(
center: Offset.zero, width: p.size, height: p.size * 2),
topLeft: const Radius.circular(3),
topRight: const Radius.circular(3),
bottomLeft: const Radius.circular(3),
bottomRight: const Radius.circular(3),
),
paint,
);
canvas.restore();
}
}
@override
bool shouldRepaint(covariant _ConfettiPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.particles != particles ||
oldDelegate.durationSeconds != durationSeconds;
}
}

View File

@ -0,0 +1,165 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// Small burst of rays that can be triggered from a controller.
class RayBurstController extends ChangeNotifier {
void fire() => notifyListeners();
}
class RayBurst extends StatefulWidget {
const RayBurst({
super.key,
required this.controller,
required this.child,
this.color = const Color(0xFF6B42B8),
this.duration = const Duration(milliseconds: 380),
this.rayCount = 10,
});
final RayBurstController controller;
final Widget child;
final Color color;
final Duration duration;
final int rayCount;
@override
State<RayBurst> createState() => _RayBurstState();
}
class _RayBurstState extends State<RayBurst>
with SingleTickerProviderStateMixin {
late final AnimationController _anim;
@override
void initState() {
super.initState();
_anim = AnimationController(vsync: this, duration: widget.duration)
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
_anim.reset();
}
});
widget.controller.addListener(_onFire);
}
@override
void didUpdateWidget(covariant RayBurst oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onFire);
widget.controller.addListener(_onFire);
}
}
@override
void dispose() {
widget.controller.removeListener(_onFire);
_anim.dispose();
super.dispose();
}
void _onFire() {
_anim.forward(from: 0);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _anim,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
child!,
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: _RayPainter(
progress: _anim.value,
color: widget.color,
rayCount: widget.rayCount,
),
),
),
),
],
);
},
child: widget.child,
);
}
}
class _RayPainter extends CustomPainter {
_RayPainter({
required this.progress,
required this.color,
required this.rayCount,
});
final double progress;
final Color color;
final int rayCount;
@override
void paint(Canvas canvas, Size size) {
if (progress <= 0) return;
final center = Offset(size.width / 2, size.height / 2);
final maxLen = (math.min(size.width, size.height) / 2) + 32;
final len = maxLen * progress;
final strokeWidth = 3.0 * (1 - progress).clamp(0.3, 1.0);
final paint = Paint()
..color = color.withOpacity(0.7 * (1 - progress))
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
for (var i = 0; i < rayCount; i++) {
final angle = (2 * math.pi / rayCount) * i;
final dx = math.cos(angle) * len;
final dy = math.sin(angle) * len;
canvas.drawLine(center, center.translate(dx, dy), paint);
}
}
@override
bool shouldRepaint(covariant _RayPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.rayCount != rayCount;
}
}
class RayDraggableChip<T extends Object> extends StatefulWidget {
const RayDraggableChip({
required this.data,
required this.child,
required this.feedback,
this.childWhenDragging,
this.color = const Color(0xFF6B42B8),
});
final T data;
final Widget child;
final Widget feedback;
final Widget? childWhenDragging;
final Color color;
@override
State<RayDraggableChip<T>> createState() => _RayDraggableChipState<T>();
}
class _RayDraggableChipState<T extends Object>
extends State<RayDraggableChip<T>> {
@override
Widget build(BuildContext context) {
return Draggable<T>(
data: widget.data,
feedback: widget.feedback,
childWhenDragging: widget.childWhenDragging,
child: widget.child,
);
}
}

View File

@ -0,0 +1,355 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FillBlankQuiz extends StatelessWidget {
const FillBlankQuiz({
super.key,
required this.quizController,
required this.quizData,
});
final FlashcardQuizController quizController;
final QuizFillBlankEntity quizData;
@override
Widget build(BuildContext context) {
final sentences = quizData.sentence ?? [];
final options = quizData.options ?? [];
// limit to 8 options to mirror mock scale
final limitedOptions = options.take(8).toList();
quizController.cleanupAssignments(
limitedOptions.where((o) => o.id != null).map((o) => o.id!).toSet(),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SentenceArea(
sentences: sentences,
quizController: quizController,
),
const SizedBox(height: 40),
Obx(() {
final isLocked = quizController.isAnswerCorrect.value ||
quizController.flashcardReadController.retryQuestion.value == 0;
return _OptionsTray(
options: limitedOptions,
quizController: quizController,
isLocked: isLocked,
);
}),
const SizedBox(height: 14),
],
);
}
}
class _SentenceArea extends StatelessWidget {
const _SentenceArea({
required this.sentences,
required this.quizController,
});
final List<QuizFillBlankSentenceEntity> sentences;
final FlashcardQuizController quizController;
@override
Widget build(BuildContext context) {
return Wrap(
alignment: WrapAlignment.start,
runSpacing: 8,
spacing: 8,
children: sentences.map((part) {
if (part.type == 'blank') {
final blankId = part.id ?? '';
return _BlankTarget(
blankId: blankId,
quizController: quizController,
);
}
return Text(
part.value ?? '',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
height: 1.35,
color: Colors.black,
),
);
}).toList(),
);
}
}
class _BlankTarget extends StatefulWidget {
const _BlankTarget({
required this.blankId,
required this.quizController,
});
final String blankId;
final FlashcardQuizController quizController;
@override
State<_BlankTarget> createState() => _BlankTargetState();
}
class _BlankTargetState extends State<_BlankTarget>
with SingleTickerProviderStateMixin {
late final RayBurstController _rayController;
@override
void initState() {
super.initState();
_rayController = RayBurstController();
}
@override
void dispose() {
_rayController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Obx(() {
widget.quizController.assignedBlankByOption.length;
widget.quizController.isAnswerChecked.value;
QuizFillBlankOptionEntity? assignedOption;
widget.quizController.assignedBlankByOption.forEach((optId, bId) {
if (bId == widget.blankId) {
assignedOption = widget.quizController.flashcardReadController
.currentCard.value?.quiz?.fillBlank?.options
?.firstWhereOrNull((opt) => opt.id == optId);
}
});
final isChecked = widget.quizController.isAnswerChecked.value;
final isCorrect = assignedOption != null &&
assignedOption!.correctBlankId != null &&
assignedOption!.correctBlankId == widget.blankId;
final isLocked = widget.quizController.isAnswerCorrect.value ||
widget.quizController.flashcardReadController.retryQuestion.value ==
0;
Color border = const Color(0xFF7D7D7D);
Color bg = Colors.white;
if (assignedOption != null && isChecked) {
border = isCorrect ? Colors.green : Colors.red;
bg = isCorrect ? const Color(0x102ECC71) : const Color(0x10F44336);
}
return DragTarget<QuizFillBlankOptionEntity>(
onWillAccept: (_) => !isLocked,
onAccept: (incoming) {
if (isLocked) return;
final optId = incoming.id;
if (optId == null) return;
widget.quizController
.assignOptionToBlank(optionId: optId, blankId: widget.blankId);
_rayController.fire();
},
builder: (context, candidate, rejected) {
final hovering = candidate.isNotEmpty;
return RayBurst(
controller: _rayController,
color: const Color(0xFF6B42B8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: hovering ? const Color(0x0F6B42B8) : bg,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: hovering ? const Color(0xFF6B42B8) : border,
width: 1.4,
),
),
child: assignedOption == null
? const SizedBox(width: 56, height: 20)
: (isLocked
? _Chip(label: assignedOption!.value ?? '')
: RayDraggableChip<QuizFillBlankOptionEntity>(
data: assignedOption!,
color: const Color(0xFF6B42B8),
feedback: _Chip(
label: assignedOption!.value ?? '',
isDragging: true,
),
childWhenDragging:
const SizedBox(width: 56, height: 20),
child: _Chip(label: assignedOption!.value ?? ''),
)),
),
);
},
);
});
}
}
class _OptionsTray extends StatefulWidget {
const _OptionsTray({
required this.options,
required this.quizController,
required this.isLocked,
});
final List<QuizFillBlankOptionEntity> options;
final FlashcardQuizController quizController;
final bool isLocked;
@override
State<_OptionsTray> createState() => _OptionsTrayState();
}
class _OptionsTrayState extends State<_OptionsTray> {
final ScrollController _scrollController = ScrollController();
late final RayBurstController _rayController;
late final List<QuizFillBlankOptionEntity> _shuffledOptions;
List<QuizFillBlankOptionEntity> _unassigned() {
return _shuffledOptions.where((opt) {
final id = opt.id;
if (id == null) return true;
return !widget.quizController.assignedBlankByOption.containsKey(id);
}).toList();
}
@override
void dispose() {
_scrollController.dispose();
_rayController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_rayController = RayBurstController();
_shuffledOptions = List.from(widget.options)..shuffle();
}
@override
Widget build(BuildContext context) {
return Obx(() {
widget.quizController.assignedBlankByOption.length;
final pool = _unassigned();
return DragTarget<QuizFillBlankOptionEntity>(
onWillAccept: (_) => !widget.isLocked,
onAccept: (incoming) {
if (widget.isLocked) return;
final optId = incoming.id;
if (optId == null) return;
widget.quizController
.assignOptionToBlank(optionId: optId, blankId: null);
_rayController.fire();
},
builder: (context, candidate, rejected) {
final hovering = candidate.isNotEmpty;
return RayBurst(
controller: _rayController,
color: const Color(0xFF6B42B8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: 96,
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: hovering ? const Color(0x0F6B42B8) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
trackVisibility: true,
radius: const Radius.circular(8),
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
children: pool.map((opt) {
final chip = _Chip(label: opt.value ?? '-');
if (widget.isLocked) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: chip,
);
}
return Padding(
padding: const EdgeInsets.only(right: 10),
child: RayDraggableChip<QuizFillBlankOptionEntity>(
data: opt,
color: const Color(0xFF6B42B8),
feedback:
_Chip(label: opt.value ?? '-', isDragging: true),
childWhenDragging:
const SizedBox(width: 64, height: 32),
child: chip,
),
);
}).toList(),
),
),
),
),
);
},
);
});
}
}
class _Chip extends StatelessWidget {
const _Chip({
required this.label,
this.isDragging = false,
});
final String label;
final bool isDragging;
@override
Widget build(BuildContext context) {
return Material(
elevation: isDragging ? 6 : 0,
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF7D7D7D), width: 1.2),
boxShadow: isDragging
? const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 10,
offset: Offset(0, 4))
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 6,
offset: Offset(0, 3))
],
),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
);
}
}

View File

@ -0,0 +1,482 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class GroupQuiz extends StatelessWidget {
const GroupQuiz({
super.key,
required this.quizController,
required this.quizData,
});
final FlashcardQuizController quizController;
final QuizGroupEntity quizData;
@override
Widget build(BuildContext context) {
final groups = (quizData.groups ?? []).take(2).toList();
final options = (quizData.options ?? []).take(8).toList();
quizController.cleanupAssignments(
options.where((o) => o.id != null).map((o) => o.id!).toSet(),
);
return Column(
children: [
Obx(() {
// observe changes to trigger rebuilds
quizController.assignedGroupByOption.length;
quizController.isAnswerChecked.value;
final isLocked = quizController.isAnswerCorrect.value ||
quizController.flashcardReadController.retryQuestion.value == 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFBFBFBF), width: 1.2),
boxShadow: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 10,
offset: Offset(0, 5)),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(groups.length, (index) {
final group = groups[index];
return Expanded(
child: Padding(
padding: EdgeInsets.only(
left: index == 0 ? 0 : 10,
right: index == groups.length - 1 ? 0 : 10,
),
child: _GroupColumn(
group: group,
allOptions: options,
quizController: quizController,
isLocked: isLocked,
),
),
);
}),
),
);
}),
const SizedBox(height: 24),
Obx(() {
final isLocked = quizController.isAnswerCorrect.value ||
quizController.flashcardReadController.retryQuestion.value == 0;
return _UnassignedOptionsRow(
options: options,
quizController: quizController,
isLocked: isLocked,
);
}),
const SizedBox(height: 16),
],
);
}
}
class _GroupColumn extends StatelessWidget {
const _GroupColumn({
required this.group,
required this.allOptions,
required this.quizController,
required this.isLocked,
});
final QuizGroupItemEntity group;
final List<QuizGroupOptionEntity> allOptions;
final FlashcardQuizController quizController;
final bool isLocked;
List<QuizGroupOptionEntity> _assignedOptions(String groupId) {
return allOptions.where((option) {
final optId = option.id;
if (optId == null) return false;
return quizController.assignedGroupByOption[optId] == groupId;
}).toList();
}
@override
Widget build(BuildContext context) {
final groupId = group.id ?? '';
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Color(0xFFFFF5BE),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFBFBFBF), width: 1.2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ConstrainedBox(
constraints: const BoxConstraints(minHeight: 44),
child: Center(
child: Text(
group.title ?? '-',
softWrap: true,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
),
),
const SizedBox(height: 12),
..._buildDropSlots(groupId),
],
),
);
}
List<Widget> _buildDropSlots(String groupId) {
final assigned = _assignedOptions(groupId);
const slotsCount = 4; // static max 4 slots per group
return List.generate(slotsCount, (index) {
final option = index < assigned.length ? assigned[index] : null;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _GroupDropSlot(
groupId: groupId,
option: option,
quizController: quizController,
isLocked: isLocked,
),
);
});
}
}
class _GroupDropSlot extends StatefulWidget {
const _GroupDropSlot({
required this.groupId,
required this.option,
required this.quizController,
required this.isLocked,
});
final String groupId;
final QuizGroupOptionEntity? option;
final FlashcardQuizController quizController;
final bool isLocked;
@override
State<_GroupDropSlot> createState() => _GroupDropSlotState();
}
class _GroupDropSlotState extends State<_GroupDropSlot> {
late final RayBurstController _rayController;
@override
void initState() {
super.initState();
_rayController = RayBurstController();
}
@override
void dispose() {
_rayController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isChecked = widget.quizController.isAnswerChecked.value;
final isCorrect = widget.option != null &&
widget.option!.correctGroupId != null &&
widget.option!.correctGroupId == widget.groupId;
Color borderColor = const Color(0xFF6B6B6B);
Color fillColor = Colors.white;
if (widget.option != null && isChecked) {
borderColor = isCorrect ? Colors.green : Colors.red;
fillColor = isCorrect ? const Color(0x102ECC71) : const Color(0x10F44336);
}
return DragTarget<QuizGroupOptionEntity>(
onWillAccept: (incoming) {
if (widget.isLocked) return false;
if (incoming?.id == null) return false;
final optId = incoming!.id!;
final existing = widget.quizController.assignedGroupByOption[optId];
if (existing == widget.groupId) return true;
return widget.quizController.countForGroup(widget.groupId) < 4;
},
onAccept: (incoming) {
if (widget.isLocked) return;
final optId = incoming.id;
if (optId == null) return;
widget.quizController
.assignOptionToGroup(optionId: optId, groupId: widget.groupId);
_rayController.fire();
},
builder: (context, candidate, rejected) {
final isHovering = candidate.isNotEmpty;
return RayBurst(
controller: _rayController,
color: const Color(0xFF6B42B8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
decoration: BoxDecoration(
color: isHovering ? const Color(0x0F6B42B8) : fillColor,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isHovering ? const Color(0xFF6B42B8) : borderColor,
width: 1.2,
),
),
child: widget.option == null
? const Center(
child: Text(
'Taruh disini',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF6B6B6B),
),
),
)
: Center(
child: _DraggableOptionCard(
option: widget.option!,
quizController: widget.quizController,
groupId: widget.groupId,
isLocked: widget.isLocked,
),
),
),
);
},
);
}
}
class _DraggableOptionCard extends StatelessWidget {
const _DraggableOptionCard({
required this.option,
required this.quizController,
required this.groupId,
required this.isLocked,
});
final QuizGroupOptionEntity option;
final FlashcardQuizController quizController;
final String groupId;
final bool isLocked;
@override
Widget build(BuildContext context) {
final isChecked = quizController.isAnswerChecked.value;
final isCorrect =
option.correctGroupId != null && option.correctGroupId == groupId;
if (isLocked) {
return _OptionChip(
label: option.value ?? '-',
isCorrect: isChecked ? isCorrect : null,
);
}
return Draggable<QuizGroupOptionEntity>(
data: option,
feedback: _OptionChip(
label: option.value ?? '-',
isDragging: true,
),
childWhenDragging: const SizedBox.shrink(),
child: _OptionChip(
label: option.value ?? '-',
isCorrect: isChecked ? isCorrect : null,
),
);
}
}
class _UnassignedOptionsRow extends StatefulWidget {
const _UnassignedOptionsRow({
required this.options,
required this.quizController,
required this.isLocked,
});
final List<QuizGroupOptionEntity> options;
final FlashcardQuizController quizController;
final bool isLocked;
@override
State<_UnassignedOptionsRow> createState() => _UnassignedOptionsRowState();
}
class _UnassignedOptionsRowState extends State<_UnassignedOptionsRow> {
final ScrollController _scrollController = ScrollController();
late final RayBurstController _rayController;
late final List<QuizGroupOptionEntity> _shuffledOptions;
List<QuizGroupOptionEntity> _unassigned() {
return _shuffledOptions.where((option) {
final id = option.id;
if (id == null) return true;
return !widget.quizController.assignedGroupByOption.containsKey(id);
}).toList();
}
@override
void initState() {
super.initState();
_rayController = RayBurstController();
_shuffledOptions = List.from(widget.options)..shuffle();
}
@override
void dispose() {
_scrollController.dispose();
_rayController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Obx(() {
widget.quizController.assignedGroupByOption.length;
return DragTarget<QuizGroupOptionEntity>(
onWillAccept: (_) => !widget.isLocked,
onAccept: (incoming) {
if (widget.isLocked) return;
final optId = incoming.id;
if (optId == null) return;
widget.quizController
.assignOptionToGroup(optionId: optId, groupId: null);
_rayController.fire();
},
builder: (context, candidate, rejected) {
final items = _unassigned();
return RayBurst(
controller: _rayController,
color: const Color(0xFF6B42B8),
child: AnimatedContainer(
width: double.infinity,
duration: const Duration(milliseconds: 120),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Color(0xFFFFF5BE),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFD8D8D8)),
),
height: 120,
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
trackVisibility: true,
interactive: true,
radius: const Radius.circular(8),
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
children: items.map((option) {
final chip = _OptionChip(
label: option.value ?? '-',
isDragging: false,
);
if (widget.isLocked) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: chip,
);
}
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Draggable<QuizGroupOptionEntity>(
data: option,
feedback: _OptionChip(
label: option.value ?? '-',
isDragging: true,
),
childWhenDragging: const SizedBox.shrink(),
child: chip,
),
);
}).toList(),
),
),
),
),
);
},
);
});
}
}
class _OptionChip extends StatelessWidget {
const _OptionChip({
required this.label,
this.isCorrect,
this.isDragging = false,
});
final String label;
final bool? isCorrect;
final bool isDragging;
@override
Widget build(BuildContext context) {
Color border = const Color(0xFF6B6B6B);
Color bg = Colors.white;
if (isCorrect != null) {
border = isCorrect! ? Colors.green : Colors.red;
bg = isCorrect! ? const Color(0x102ECC71) : const Color(0x10F44336);
}
return Material(
elevation: isDragging ? 6 : 0,
color: Colors.transparent,
child: Container(
height: 60,
width: 105,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: border, width: 1.2),
boxShadow: isDragging
? const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 10,
offset: Offset(0, 4))
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 6,
offset: Offset(0, 3))
],
),
child: Center(
child: FittedBox(
child: Text(
label,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,152 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class MultipleChoiceQuiz extends StatelessWidget {
const MultipleChoiceQuiz({
super.key,
required this.quizController,
required this.quizData,
});
final FlashcardQuizController quizController;
final QuizChoiceEntity quizData;
@override
Widget build(BuildContext context) {
final choices = quizData.choices ?? [];
final correctIndex = quizData.correctedIndex ?? -1;
var image = quizData.image;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (image != null)
Column(
children: [
Image.network(
image,
width: 200,
height: 120,
fit: BoxFit.cover,
),
const SizedBox(height: 16),
],
),
Text(
quizData.question ?? '-',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black,
height: 1.4,
),
),
const SizedBox(height: 18),
...List.generate(choices.length, (index) {
final label = String.fromCharCode(65 + index); // A, B, C, ...
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Obx(() {
final isSelected = quizController.assignedChoice.value == index;
final isChecked = quizController.isAnswerChecked.value;
final retryLeft =
quizController.flashcardReadController.retryQuestion.value;
final isCorrect = index == correctIndex;
final showCorrect = quizController.isAnswerCorrect.value
? (isChecked && isCorrect)
: (isChecked && isCorrect && retryLeft == 0);
final showWrong = isChecked && isSelected && !isCorrect;
final isLocked =
quizController.isAnswerCorrect.value || retryLeft == 0;
const borderColor = Color(0xFF000000);
Color fillColor = const Color(0xFFE0CEFF);
if (isSelected) {
fillColor = const Color(0xFF80A6FF);
}
if (showCorrect) {
fillColor = const Color(0xFF51F279);
} else if (showWrong) {
fillColor = const Color(0xFFFF0000);
}
final textColor =
showWrong ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
final bubbleColor =
showWrong ? const Color(0xFF000000) : const Color(0xFFFFFFFF);
final bubbleTextColor =
showWrong ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
return GestureDetector(
onTap: isLocked
? null
: () {
quizController.selectChoice(index);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: fillColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor,
width: 1.2,
),
boxShadow: const [
BoxShadow(
color: Color(0xFF3B1D73),
blurRadius: 0,
offset: Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: bubbleColor,
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF000000),
width: 1.2,
),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: bubbleTextColor,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
choices[index],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textColor,
),
),
),
],
),
),
);
}),
);
}),
],
);
}
}

View File

@ -0,0 +1,876 @@
import 'dart:convert';
import 'package:confetti/confetti.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_choice_answer_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_fill_blank_answer_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_group_answer_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/quiz_choice_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz_fill_blank_mapper.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_answer_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/history/quiz_choice_answer_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/history/quiz_fill_blank_answer_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/history/quiz_group_answer_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashcardQuizController extends GetxController {
FlashcardQuizController({required this.flashcardReadController});
final FlashcardReadController flashcardReadController;
final navController = Get.find<EpicNavigationController>();
final isAnswerChecked = false.obs;
final isAnswerCorrect = false.obs;
final assignedChoice = (-1).obs;
final assignedGroupByOption = <String, String>{}.obs;
final assignedBlankByOption = <String, String>{}.obs;
final ConfettiController confettiController =
ConfettiController(duration: const Duration(milliseconds: 1500));
final ConfettiController dialogConfettiController =
ConfettiController(duration: const Duration(milliseconds: 2000));
void syncCurrentQuiz() {
EpicLog.debug("_syncCurrentQuiz - Syncing quiz state with current card");
final currentCard = flashcardReadController.currentCard.value;
if (currentCard == null) return;
EpicLog.debug(
"_syncCurrentQuiz - Current card page: ${currentCard.nPage}, isQuiz: ${currentCard.quizType != null}");
if (flashcardReadController.isQuiz) {
final history = flashcardReadController.flashcardHistory.value;
if (history == null) return;
EpicLog.debug(
"_syncCurrentQuiz - Found flashcard history with ${history.cards?.length ?? 0} cards");
final cardHistoryIndex =
history.cards?.indexWhere((h) => h.nPage == currentCard.nPage);
if (cardHistoryIndex == null || cardHistoryIndex < 0) return;
EpicLog.debug(
"_syncCurrentQuiz - Found matching card history at index: $cardHistoryIndex");
final cardHistory = history.cards![cardHistoryIndex];
final historyAnswer = cardHistory.historyAnswer;
EpicLog.debug(
"_syncCurrentQuiz - historyAnswer ${historyAnswer?.answer}");
if (historyAnswer == null) return;
EpicLog.debug(
"_syncCurrentQuiz - Found history answer for current card - quizType: ${historyAnswer.quizType}, retry: ${historyAnswer.retry}");
final quizType = currentCard.quizType ?? 0;
if (quizType == 1) {
assignedChoice.value =
historyAnswer.multipleChoiceAnswer?.selectedAnswer ?? -1;
isAnswerChecked.value = true;
isAnswerCorrect.value = cardHistory.correctAnswer ?? false;
EpicLog.debug(
"_syncCurrentQuiz - Synced multiple choice answer: selected index ${assignedChoice.value}");
} else if (quizType == 2) {
assignedBlankByOption.clear();
assignedBlankByOption
.addAll(historyAnswer.fillBlankAnswer?.assignedBlankByOption ?? {});
isAnswerChecked.value = true;
isAnswerCorrect.value = cardHistory.correctAnswer ?? false;
EpicLog.debug(
"_syncCurrentQuiz - Synced fill blank answer: assigned blanks $assignedBlankByOption");
} else if (quizType == 3) {
assignedGroupByOption.clear();
assignedGroupByOption
.addAll(historyAnswer.groupAnswer?.assignedGroupByOption ?? {});
isAnswerChecked.value = true;
isAnswerCorrect.value = cardHistory.correctAnswer ?? false;
EpicLog.debug(
"_syncCurrentQuiz - Synced group quiz answer: assigned groups $assignedGroupByOption");
}
}
}
void selectChoice(int index) {
assignedChoice.value = index;
isAnswerChecked.value = false;
}
void resetState() {
assignedChoice.value = -1;
isAnswerChecked.value = false;
isAnswerCorrect.value = false;
assignedGroupByOption.clear();
assignedBlankByOption.clear();
}
@override
void onClose() {
confettiController.dispose();
dialogConfettiController.dispose();
super.onClose();
}
void _showCongratsDialog({
String title = "Selamat!",
String message = "Jawaban kamu benar.",
String? explanation,
}) {
dialogConfettiController.play();
Get.dialog(
Stack(
alignment: Alignment.center,
children: [
// Confetti
Align(
alignment: Alignment.center,
child: ConfettiWidget(
confettiController: dialogConfettiController,
blastDirectionality: BlastDirectionality.explosive,
emissionFrequency: 0.18,
numberOfParticles: 90,
gravity: 0.22,
minBlastForce: 8,
maxBlastForce: 16,
minimumSize: const Size(6, 10),
maximumSize: const Size(14, 20),
shouldLoop: true,
colors: const [
Color(0xFF6B42B8),
Color(0xFF2ECC71),
Color(0xFFF39C12),
Color(0xFFE74C3C),
Color(0xFF3498DB),
],
),
),
// Dialog
Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 28),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
// Main card
Container(
margin: const EdgeInsets.only(top: 44),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFFF7D0),
Color(0xFFFFE566),
Color(0xFFFFCF20),
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: const Color(0xFFE8A800),
width: 3,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFE8A800).withOpacity(0.4),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
padding: const EdgeInsets.fromLTRB(22, 58, 22, 22),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('', style: TextStyle(fontSize: 22)),
const SizedBox(width: 8),
Flexible(
child: Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: Color(0xFF4A148C),
letterSpacing: 0.5,
),
),
),
const SizedBox(width: 8),
const Text('', style: TextStyle(fontSize: 22)),
],
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.65),
borderRadius: BorderRadius.circular(14),
),
child: Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF4E342E),
height: 1.4,
),
),
),
const SizedBox(height: 20),
GestureDetector(
onTap: () {
Get.back();
showExplanationDialog(
explanation,
showNextButton: true,
);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: const Color(0xFF6B42B8),
borderRadius: BorderRadius.circular(50),
border: Border.all(
color: Colors.black,
width: 2,
),
boxShadow: const [
BoxShadow(
color: Colors.black,
offset: Offset(0, 4),
blurRadius: 0,
),
],
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Lanjut',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
SizedBox(width: 6),
Text('🚀', style: TextStyle(fontSize: 18)),
],
),
),
),
],
),
),
// Trophy badge
Positioned(
top: 0,
child: Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
border: Border.all(
color: const Color(0xFFE8A800),
width: 3.5,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFE8A800).withOpacity(0.5),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text('🏆', style: TextStyle(fontSize: 46)),
),
),
),
],
),
),
],
),
barrierDismissible: false,
barrierColor: Colors.black.withOpacity(0.5),
);
}
void showExplanationDialog(
String? explanation, {
bool showNextButton = false,
}) {
Get.dialog(
Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 24),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
// Main card
Container(
margin: const EdgeInsets.only(top: 44),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: const Color(0xFF6B42B8),
width: 2.5,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF6B42B8).withOpacity(0.25),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(21.5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Purple gradient header
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF6B42B8), Color(0xFF9C5FD4)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.fromLTRB(18, 14, 18, 14),
child: const Row(
children: [
Text('💡', style: TextStyle(fontSize: 22)),
SizedBox(width: 10),
Text(
'Penjelasan',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Text(
explanation ?? 'Penjelasan belum tersedia.',
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF37474F),
height: 1.6,
),
),
),
),
const SizedBox(height: 20),
if (showNextButton)
GestureDetector(
onTap: () {
Get.back();
flashcardReadController.goToResult();
},
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(vertical: 13),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFF6B42B8),
Color(0xFF9C5FD4),
],
),
borderRadius: BorderRadius.circular(50),
border: Border.all(
color: Colors.black,
width: 1.5,
),
boxShadow: const [
BoxShadow(
color: Colors.black,
offset: Offset(0, 3),
blurRadius: 0,
),
],
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Selanjutnya',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: Colors.white,
),
),
SizedBox(width: 6),
Text(
'',
style: TextStyle(fontSize: 16),
),
],
),
),
)
else
GestureDetector(
onTap: () => Get.back(),
child: Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(vertical: 13),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50),
border: Border.all(
color: const Color(0xFF6B42B8),
width: 2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF6B42B8)
.withOpacity(0.3),
offset: const Offset(0, 3),
blurRadius: 0,
),
],
),
child: const Center(
child: Text(
'Tutup',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: Color(0xFF6B42B8),
),
),
),
),
),
],
),
),
],
),
),
),
// Lightbulb badge
Positioned(
top: -10,
child: Container(
width: 88,
height: 88,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [Color(0xFFFFE566), Color(0xFFFFB300)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
border: Border.all(
color: const Color(0xFF6B42B8),
width: 3,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFFFB300).withOpacity(0.5),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text('💡', style: TextStyle(fontSize: 42)),
),
),
),
],
),
),
barrierDismissible: !showNextButton,
barrierColor: Colors.black.withOpacity(0.4),
);
}
void assignOptionToGroup(
{required String optionId, required String? groupId}) {
// Enforce max 4 items per group (unless reassigning same option to same group)
if (groupId != null && groupId.isNotEmpty) {
final currentGroupId = assignedGroupByOption[optionId];
final currentCount = countForGroup(groupId);
final alreadyInTarget = currentGroupId == groupId;
if (!alreadyInTarget && currentCount >= 4) {
EpicSnackBar.showErrorSnackBar(
"Group Full",
"Each group can only hold 4 items.",
);
return;
}
}
if (optionId.isEmpty) return;
if (groupId == null || groupId.isEmpty) {
assignedGroupByOption.remove(optionId);
} else {
assignedGroupByOption[optionId] = groupId;
}
// reset check state on change
isAnswerChecked.value = false;
}
void assignOptionToBlank(
{required String optionId, required String? blankId}) {
if (optionId.isEmpty) return;
if (blankId == null || blankId.isEmpty) {
assignedBlankByOption.remove(optionId);
} else {
assignedBlankByOption[optionId] = blankId;
}
isAnswerChecked.value = false;
}
int countForGroup(String groupId) {
var count = 0;
assignedGroupByOption.forEach((_, value) {
if (value == groupId) count++;
});
return count;
}
void cleanupAssignments(Set<String> validOptionIds) {
assignedGroupByOption
.removeWhere((key, _) => !validOptionIds.contains(key));
assignedBlankByOption
.removeWhere((key, _) => !validOptionIds.contains(key));
}
Future<void> updateHistoryAnswer({
required int quizType,
required bool isCorrect,
String? question,
String? answer,
QuizChoiceAnswerEntity? multipleChoiceAnswer,
QuizChoiceEntity? multipleChoiceQuestion,
QuizGroupAnswerEntity? groupAnswer,
QuizGroupEntity? groupQuestion,
QuizFillBlankAnswerEntity? fillBlankAnswer,
QuizFillBlankEntity? fillBlankQuestion,
}) async {
final currentCard = flashcardReadController.currentCard.value;
if (currentCard == null) return;
final history = flashcardReadController.flashcardHistory.value;
if (history == null) return;
final cardHistoryIndex =
history.cards?.indexWhere((h) => h.nPage == currentCard.nPage);
if (cardHistoryIndex == null || cardHistoryIndex < 0) return;
final cardHistory = history.cards![cardHistoryIndex];
var updatedAnswer = FlashcardHistoryAnswerEntity(
quizType: quizType,
retry: flashcardReadController.retryQuestion.value,
question: question,
answer: answer,
);
if (quizType == 1) {
updatedAnswer = updatedAnswer.copyWith(
multipleChoiceQuestion: multipleChoiceQuestion,
multipleChoiceAnswer: multipleChoiceAnswer,
);
} else if (quizType == 2) {
updatedAnswer = updatedAnswer.copyWith(
fillBlankQuestion: fillBlankQuestion,
fillBlankAnswer: fillBlankAnswer,
);
} else if (quizType == 3) {
updatedAnswer = updatedAnswer.copyWith(
groupQuestion: groupQuestion,
groupAnswer: groupAnswer,
);
}
final updatedCardHistory = cardHistory.copyWith(
correctAnswer: isCorrect,
completedQuiz:
isCorrect || flashcardReadController.retryQuestion.value <= 0,
historyAnswer: updatedAnswer,
);
EpicLog.debug(
"updateHistoryAnswer - ${updatedAnswer.groupAnswer?.assignedGroupByOption} - isCorrect: $isCorrect, retry left: ${flashcardReadController.retryQuestion.value}");
var updatedCards = [...?history.cards];
updatedCards[cardHistoryIndex] = updatedCardHistory;
var totalCard = history.totalCards ?? 0;
var totalQuiz = history.totalQuiz ?? 0;
var completedQuiz = history.completedQuiz ?? 0;
if (isCorrect) {
if (completedQuiz < totalQuiz) {
completedQuiz += 1;
}
}
completedQuiz = completedQuiz;
var isAllQuizCompleted = (completedQuiz >= totalQuiz);
var readPages = (history.cards?.length ?? 0) + 1;
var isAllRead = readPages >= totalCard;
var isFinished = isAllRead && isAllQuizCompleted;
var updatedHistory = history.copyWith(
cards: updatedCards,
completedQuiz: completedQuiz,
isFinished: isFinished,
);
await flashcardReadController.childrenUsecase.updateFlashcardHistory(
flashcardHistory: updatedHistory,
);
flashcardReadController.flashcardHistory.value = updatedHistory;
}
void onCheckAnswer() {
EpicLog.debug("Checking answer - step 1: validate state");
if (!flashcardReadController.isQuiz) return;
int retryCount = flashcardReadController.retryQuestion.value;
if (retryCount <= 0) {
EpicSnackBar.showErrorSnackBar(
"No Attempts Left",
"You have used all your attempts for this question.",
);
return;
}
final quizType = flashcardReadController.currentCard.value?.quizType ?? 0;
final quizData = flashcardReadController.currentCard.value?.quiz;
EpicLog.debug(
"Checking answer - step 2: determine quiz type and validate answer");
if (quizType == 1) {
EpicLog.debug(
"Checking answer - step 3: processing multiple choice quiz");
var quizDataEntity = quizData?.multipleChoice;
if (quizDataEntity == null) return;
final correctIndex = quizDataEntity.correctedIndex;
if (correctIndex == null) return;
EpicLog.debug(
"Checking answer - step 4: comparing selected index with correct index");
isAnswerCorrect.value = assignedChoice.value == correctIndex;
isAnswerChecked.value = true;
flashcardReadController.retryQuestion.value--;
var answerData = QuizChoiceAnswerEntity(
selectedAnswer: assignedChoice.value,
);
var questionJson =
QuizChoiceMapper.entityToRemote(quizDataEntity).toJson();
var answerJson =
QuizChoiceAnswerMapper.entityToRemote(answerData).toJson();
if (isAnswerCorrect.value) {
_showCongratsDialog(
title: "Selamat!",
message: "Jawaban kamu benar.",
explanation: quizDataEntity.explanation,
);
confettiController.play();
updateHistoryAnswer(
quizType: quizType,
isCorrect: true,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
multipleChoiceQuestion: quizDataEntity,
multipleChoiceAnswer: answerData,
);
} else {
EpicSnackBar.showErrorSnackBar(
"Wrong Answer",
"Please try again.",
);
updateHistoryAnswer(
quizType: quizType,
isCorrect: false,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
multipleChoiceQuestion: quizDataEntity,
multipleChoiceAnswer: answerData,
);
}
return;
}
if (quizType == 3) {
EpicLog.debug("Checking answer - step 3: processing group quiz");
final QuizGroupEntity? quizDataEntity = quizData?.group;
if (quizDataEntity == null) return;
final options = quizDataEntity.options ?? [];
if (options.isEmpty) return;
EpicLog.debug("Checking answer - step 4: validating group assignments");
bool allAssigned = true;
bool allCorrect = true;
for (final option in options) {
final optId = option.id;
final correctGroupId = option.correctGroupId;
if (optId == null) {
continue;
}
final assigned = assignedGroupByOption[optId];
if (assigned == null) {
allAssigned = false;
allCorrect = false;
continue;
}
if (correctGroupId != null && assigned != correctGroupId) {
allCorrect = false;
}
}
isAnswerChecked.value = true;
isAnswerCorrect.value = allAssigned && allCorrect;
flashcardReadController.retryQuestion.value--;
final groupAssignments = Map<String, String>.from(assignedGroupByOption);
var answerData = QuizGroupAnswerEntity(
assignedGroupByOption: groupAssignments,
);
var questionJson =
QuizGroupMapper.entityToRemote(quizDataEntity).toJson();
var answerJson =
QuizGroupAnswerMapper.entityToRemote(answerData).toJson();
if (isAnswerCorrect.value) {
_showCongratsDialog(
title: "Selamat!",
message: "Semua item sudah di kelompok yang benar.",
explanation: quizDataEntity.explanation,
);
confettiController.play();
updateHistoryAnswer(
quizType: quizType,
isCorrect: true,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
groupQuestion: quizDataEntity,
groupAnswer: answerData,
);
} else {
final message = allAssigned
? "Some items are in the wrong group."
: "Please assign all items to a group.";
EpicSnackBar.showErrorSnackBar("Try Again", message);
updateHistoryAnswer(
quizType: quizType,
isCorrect: false,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
groupQuestion: quizDataEntity,
groupAnswer: answerData,
);
}
return;
}
if (quizType == 2) {
EpicLog.debug("Checking answer - step 3: processing fill blank quiz");
final QuizFillBlankEntity? quizDataEntity = quizData?.fillBlank;
if (quizDataEntity == null) return;
final options = quizDataEntity.options ?? [];
if (options.isEmpty) return;
bool allAssigned = true;
bool allCorrect = true;
for (final option in options) {
final optId = option.id;
final correctBlankId = option.correctBlankId;
if (optId == null) continue;
final assigned = assignedBlankByOption[optId];
if (assigned == null) {
allAssigned = false;
allCorrect = false;
continue;
}
if (correctBlankId != null && assigned != correctBlankId) {
allCorrect = false;
}
}
isAnswerChecked.value = true;
isAnswerCorrect.value = allAssigned && allCorrect;
flashcardReadController.retryQuestion.value--;
final fillAssignments = Map<String, String>.from(assignedBlankByOption);
var answerData = QuizFillBlankAnswerEntity(
assignedBlankByOption: fillAssignments,
);
var questionJson =
QuizFillBlankMapper.entityToRemote(quizDataEntity).toJson();
var answerJson =
QuizFillBlankAnswerMapper.entityToRemote(answerData).toJson();
if (isAnswerCorrect.value) {
_showCongratsDialog(
title: "Selamat!",
message: "Semua isian sudah benar.",
explanation: quizDataEntity.explanation,
);
confettiController.play();
updateHistoryAnswer(
quizType: quizType,
isCorrect: true,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
fillBlankQuestion: quizDataEntity,
fillBlankAnswer: answerData,
);
} else {
final message = allAssigned
? "Some blanks are incorrect."
: "Please fill all blanks.";
EpicSnackBar.showErrorSnackBar("Try Again", message);
updateHistoryAnswer(
quizType: quizType,
isCorrect: false,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
fillBlankQuestion: quizDataEntity,
fillBlankAnswer: answerData,
);
}
return;
}
EpicSnackBar.showErrorSnackBar("Error", "Quiz type not supported");
}
}

View File

@ -0,0 +1,24 @@
import 'package:epic_story_app/data/modules/children_module.dart';
import 'package:epic_story_app/data/modules/collection_module.dart';
import 'package:epic_story_app/data/modules/quiz_module.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
import 'package:get/get.dart';
class FlashCardReadBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
CollectionModule();
QuizModule();
Get.lazyPut<FlashcardReadController>(
() => FlashcardReadController(
childrenUsecase: Get.find<ChildrenUsecase>(),
collectionUsecase: Get.find<CollectionUsecase>(),
quizUsecase: Get.find<QuizUsecase>(),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,122 @@
import 'dart:math' as math;
import 'package:confetti/confetti.dart';
import 'package:epic_story_app/core/styles/epic_box_colors.dart';
import 'package:epic_story_app/core/styles/epic_app_size.dart';
import 'package:epic_story_app/core/widgets/cards/box_shadow.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/components/flash_read_bottom_content.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/components/flash_read_main_content.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/components/flash_read_top_content.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashCardReadPage extends GetView<FlashcardReadController> {
const FlashCardReadPage({super.key});
@override
Widget build(BuildContext context) {
EpicAppSize.initialize(context);
return Scaffold(
backgroundColor: const Color(0xFFE0CEFF),
body: SafeArea(
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
children: [
FlashReadTopContent(
controller: controller,
),
FlashReadMainContent(
controller: controller,
),
FlashReadBottomContent(
controller: controller,
),
],
),
// Confetti overlay aligned from bottom center shooting upward
Positioned.fill(
child: IgnorePointer(
child: Align(
alignment: Alignment.bottomCenter,
child: ConfettiWidget(
confettiController:
controller.quizController.confettiController,
blastDirection: -math.pi / 2,
blastDirectionality: BlastDirectionality.explosive,
emissionFrequency: 0.05,
numberOfParticles: 28,
gravity: 0.25,
minBlastForce: 12,
maxBlastForce: 18,
colors: const [
Color(0xFF6B42B8),
Color(0xFF2ECC71),
Color(0xFFF39C12),
Color(0xFFE74C3C),
Color(0xFF3498DB),
],
minimumSize: const Size(6, 8),
maximumSize: const Size(12, 18),
shouldLoop: false,
),
),
),
),
Obx(
() {
var isAnswerAlreadyCorrect =
controller.quizController.isAnswerCorrect.value;
var isCanAnswer = (controller.retryQuestion.value > 0) &&
isAnswerAlreadyCorrect == false;
var bgColor = isCanAnswer
? BoxColors.cekJawaban
: isAnswerAlreadyCorrect
? BoxColors.jawabanBenar
: BoxColors.jawabanSalah;
return controller.isQuiz
? Positioned(
bottom: EpicAppSize.calculatedSize(
105,
),
left: 0,
right: 0,
child: Center(
child: ShadowBox(
cardColor: bgColor,
isCanClick: isCanAnswer,
onTap: controller.quizController.onCheckAnswer,
width: MediaQuery.of(context).size.width * 0.65,
height: EpicAppSize.calculatedSize(50),
borderWidthRatio: EpicAppSize.borderWidthRatio,
radius: BorderRadius.circular(
EpicAppSize.calculatedSize(50),
),
child: Center(
child: Text(
isAnswerAlreadyCorrect
? 'Jawaban sudah benar!'
: 'Cek Jawaban',
style: TextStyle(
color: bgColor.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
)
: const SizedBox.shrink();
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,14 @@
import 'package:epic_story_app/data/modules/children_module.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_result/flashcard_result_controller.dart';
import 'package:get/get.dart';
class FlashCardResultBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
Get.lazyPut(() => FlashCardResultController(
childrenUsecase: Get.find<ChildrenUsecase>(),
));
}
}

View File

@ -0,0 +1,106 @@
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_card_entity.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:get/get.dart';
class FlashCardResultController extends GetxController {
late FlashcardEntity flashcard;
final correctCount = 0.obs;
final wrongCount = 0.obs;
final totalQuiz = 0.obs;
final completedQuiz = 0.obs;
final totalCards = 0.obs;
var isRewardClaimed = false.obs;
final ChildrenUsecase childrenUsecase;
FlashCardResultController({
required this.childrenUsecase,
});
@override
void onInit() {
super.onInit();
_initArgs();
}
List<FlashcardCardEntity> get cards => flashcard.cards ?? [];
var flashcardHistory = Rxn(FlashcardHistoryEntity());
void _initArgs() async {
flashcard = const FlashcardEntity(title: 'Flashcard Title');
final args = Get.arguments;
if (args is FlashcardEntity) {
final argFlashcard = args;
flashcard = argFlashcard;
flashcardHistory.value = await childrenUsecase.getFlashcardHistoryById(
flashcard.flashcardId ?? '',
);
correctCount.value = flashcardHistory.value?.cards
?.where((card) => card.completedQuiz == true)
.length ??
0;
wrongCount.value = flashcardHistory.value?.cards
?.where((card) => card.completedQuiz == false)
.length ??
0;
totalQuiz.value =
flashcard.cards?.where((card) => card.quizType != null).length ?? 0;
totalCards.value = (flashcard.cards?.length ?? 0) - totalQuiz.value;
}
_updateClaimedFlag();
EpicLog.debug(
'Flashcard history loaded with claimed rewards: ${flashcardHistory.value?.claimedRewards}');
EpicLog.debug(
'Initialized FlashCardResultController with flashcard: ${flashcard.title}, total cards: ${flashcard.cards?.length ?? 0}, correct: ${correctCount.value}, wrong: ${wrongCount.value}');
}
void goToClaimReward() async {
if (isRewardClaimed.value) {
EpicSnackBar.showSuccessSnackBar(
"Info",
"Reward already claimed for this flashcard.",
);
return;
}
bool result = await Get.toNamed(
EpicRoutes.flashcardReward,
arguments: flashcard,
);
if (result == true) {
try {
final history = await childrenUsecase.getFlashcardHistoryById(
flashcard.flashcardId ?? '',
);
if (history != null) {
flashcardHistory.value = history;
isRewardClaimed.value =
flashcardHistory.value?.claimedRewards != null &&
flashcardHistory.value!.claimedRewards!.isNotEmpty;
_updateClaimedFlag();
EpicLog.debug('Flashcard history refreshed after claiming rewards.');
} else {
EpicLog.debug(
'No flashcard history found to refresh after claiming rewards.');
}
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'goToClaimReward - refresh history');
}
}
}
void _updateClaimedFlag() {
final claimed = flashcardHistory.value?.claimedRewards;
isRewardClaimed.value = claimed != null && claimed.isNotEmpty;
}
}

View File

@ -0,0 +1,431 @@
import 'package:epic_story_app/domain/entities/flashcards/flashcard_card_entity.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_result/flashcard_result_controller.dart';
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashCardResultPage extends GetView<FlashCardResultController> {
const FlashCardResultPage({super.key});
@override
Widget build(BuildContext context) {
final mainController = Get.find<MainController>();
return Scaffold(
backgroundColor: const Color(0xFFE0CEFF),
body: SafeArea(
child: Obx(() {
final cards = controller.cards;
final claimedRewards =
controller.flashcardHistory.value?.claimedRewards ?? [];
final rewards = controller.flashcard.rewards ?? [];
final isRewardClaimed = controller.isRewardClaimed.value;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Image.asset('assets/images/hasil-flashcard.png'),
const SizedBox(height: 10),
_resultRewardPanel(
correctCount: controller.correctCount.value,
totalQuiz: controller.totalQuiz.value,
isRewardClaimed: isRewardClaimed,
onTapReward: controller.goToClaimReward,
),
const SizedBox(height: 18),
if (cards.isEmpty)
_emptyPlaceholder()
else
Column(
children: [
for (int i = 0; i < cards.length; i += 2) ...[
_cardRow(
left: cards[i],
right: i + 1 < cards.length ? cards[i + 1] : null,
),
const SizedBox(height: 16),
],
],
),
if (claimedRewards.isNotEmpty) ...[
const SizedBox(height: 8),
_claimedRewardSection(
rewards: rewards,
claimed: claimedRewards,
onTapThumb: (urls, index) {
mainController.showRewardPhotoViewer(
imageUrls: urls,
initialIndex: index,
);
},
),
],
],
),
);
}),
),
);
}
Widget _resultRewardPanel({
required int correctCount,
required int totalQuiz,
required bool isRewardClaimed,
required VoidCallback onTapReward,
}) {
return Stack(
clipBehavior: Clip.none,
alignment: Alignment.topCenter,
children: [
Positioned(
top: 6,
child: IgnorePointer(
child: Container(
width: 220,
height: 160,
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment.topCenter,
radius: 0.85,
colors: [Color(0x66FFF3A5), Color(0x00FFF3A5)],
),
),
),
),
),
AspectRatio(
aspectRatio: 1,
child: LayoutBuilder(
builder: (context, constraints) {
final w = constraints.maxWidth;
final h = constraints.maxHeight;
return Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/images/icon-get-reward.png',
fit: BoxFit.contain,
),
),
Positioned(
top: h * 0.55,
left: 0,
right: 0,
child: Column(
children: [
const Text(
'Total Kuis',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w600,
color: Color(0xFFF08B39),
height: 1,
),
),
const SizedBox(height: 6),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/icon-perment.png',
width: 30,
height: 30,
),
const SizedBox(width: 6),
Text(
'$correctCount/$totalQuiz',
style: const TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: Color(0xFFEB6E17),
height: 1,
),
),
],
),
],
),
),
Positioned(
left: w * 0.17,
right: w * 0.17,
bottom: h * 0.1,
child: _RewardPanelButton(
isClaimed: isRewardClaimed,
onTap: onTapReward,
),
),
],
);
},
),
),
],
);
}
Widget _cardRow(
{required FlashcardCardEntity left, FlashcardCardEntity? right}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _cardTile(left)),
const SizedBox(width: 14),
if (right != null)
Expanded(child: _cardTile(right))
else
const Spacer(),
],
);
}
Widget _cardTile(FlashcardCardEntity card) {
final isQuiz = (card.quizType ?? 0) > 0 || card.quiz?.quizData != null;
return isQuiz
// ? _cardBox(
// const Center(
// child: Text(
// '?',
// style: TextStyle(
// fontSize: 34,
// fontWeight: FontWeight.w900,
// color: Colors.black,
// ),
// ),
// ),
// )
? SizedBox.shrink()
: _cardBox(
Center(
child: Text(
card.text ?? 'Show Quiz',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black,
height: 1.35,
),
),
),
);
}
Widget _cardBox(Widget child) {
return AspectRatio(
aspectRatio: 0.74,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Color(0xFFFFFFFF),
Color(0xFF5EA7FF),
],
stops: [0.0, 1.0],
),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Color(0x2E000000),
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: child,
),
);
}
Widget _claimedRewardSection({
required List<String> rewards,
required List<int> claimed,
required void Function(List<String> urls, int index) onTapThumb,
}) {
final urls = claimed
.where((i) => i >= 0 && i < rewards.length)
.map((i) => rewards[i])
.toList();
if (urls.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Hadiah yang sudah kamu dapatkan',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 10,
runSpacing: 10,
children: List.generate(urls.length, (index) {
return GestureDetector(
onTap: () => onTapThumb(urls, index),
child: SizedBox(
width: 96,
child: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black, width: 1.1),
borderRadius: BorderRadius.circular(14),
),
child: _rewardThumb(urls[index]),
),
),
),
),
);
}),
),
],
);
}
Widget _rewardThumb(String url) {
return Image.network(
url,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Container(
color: Colors.grey.shade200,
alignment: Alignment.center,
child: const Icon(
Icons.broken_image_outlined,
size: 22,
color: Colors.black54,
),
),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: Colors.grey.shade100,
alignment: Alignment.center,
child: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
},
);
}
Widget _emptyPlaceholder() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: const Text(
'Belum ada hasil untuk flashcard ini.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: Colors.black87),
),
);
}
}
class _RewardPanelButton extends StatefulWidget {
const _RewardPanelButton({required this.isClaimed, required this.onTap});
final bool isClaimed;
final VoidCallback onTap;
@override
State<_RewardPanelButton> createState() => _RewardPanelButtonState();
}
class _RewardPanelButtonState extends State<_RewardPanelButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
_scale = Tween<double>(begin: 0.96, end: 1.04).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);
_syncAnimation();
}
@override
void didUpdateWidget(covariant _RewardPanelButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isClaimed != widget.isClaimed) {
_syncAnimation();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _syncAnimation() {
if (widget.isClaimed) {
_controller.stop();
_controller.value = 0.5;
} else {
_controller.repeat(reverse: true);
}
}
@override
Widget build(BuildContext context) {
final animation =
widget.isClaimed ? const AlwaysStoppedAnimation<double>(1) : _scale;
return GestureDetector(
onTap: widget.onTap,
child: ScaleTransition(
scale: animation,
child: SizedBox(
height: 58,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset('assets/images/bg-button-get-reward.png',
fit: BoxFit.fill),
Center(
child: Text(
widget.isClaimed ? 'Claimed' : 'Reward!!!',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: Colors.white,
height: 1,
),
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,16 @@
import 'package:epic_story_app/data/modules/children_module.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_reward/flashcard_reward_controller.dart';
import 'package:get/get.dart';
class FlashcardRewardBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
Get.lazyPut<FlashcardRewardController>(
() => FlashcardRewardController(
childrenUsecase: Get.find<ChildrenUsecase>(),
),
);
}
}

View File

@ -0,0 +1,126 @@
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
import 'package:get/get.dart';
class FlashcardRewardController extends GetxController {
late FlashcardEntity flashcard;
final rewards = <String>[].obs;
final selectedQueue = <int>[].obs;
static const int maxSelection = 3;
final mainController = Get.find<MainController>();
String? get selectedReward =>
selectedQueue.isNotEmpty && selectedQueue.last < rewards.length
? rewards[selectedQueue.last]
: null;
List<String> get selectedRewardsOrdered => selectedQueue
.where((index) => index >= 0 && index < rewards.length)
.map((index) => rewards[index])
.toList();
int get remainingSelection =>
(maxSelection - selectedCount).clamp(0, maxSelection);
int get selectedCount => selectedQueue.length;
bool get hasRewards => rewards.isNotEmpty;
int get totalReward => rewards.length;
var flashcardHistory = Rxn(FlashcardHistoryEntity());
final ChildrenUsecase childrenUsecase;
FlashcardRewardController({
required this.childrenUsecase,
});
@override
void onInit() async {
super.onInit();
flashcard = const FlashcardEntity(rewards: []);
final argument = Get.arguments;
if (argument != null && argument is FlashcardEntity) {
flashcard = argument;
flashcardHistory.value = await childrenUsecase.getFlashcardHistoryById(
flashcard.flashcardId ?? '',
);
}
rewards.assignAll(flashcard.rewards ?? []);
}
void selectReward(int index) {
if (index < 0 || index >= rewards.length) return;
// Toggle off if already selected
if (selectedQueue.contains(index)) {
selectedQueue.remove(index);
EpicLog.debug('Selected rewards: ${selectedQueue.join(' - ')}');
return;
}
// Keep only the latest [maxSelection] picks; drop the oldest if over limit.
if (selectedQueue.length >= maxSelection) {
selectedQueue.removeAt(0);
}
selectedQueue.add(index);
EpicLog.debug('Selected rewards: ${selectedQueue.join(' - ')}');
}
void removeSelectionAt(int slotIndex) {
if (slotIndex < 0 || slotIndex >= selectedQueue.length) return;
selectedQueue.removeAt(slotIndex);
EpicLog.debug('Removed selection at slot $slotIndex');
}
void claimReward() async {
try {
if (selectedQueue.length < maxSelection) {
EpicLog.debug('Please select $maxSelection rewards before claiming.');
EpicSnackBar.showWarningSnackBar(
"Pilih Reward",
"Silakan pilih $maxSelection gambar terlebih dahulu.",
);
return;
}
var newHistory = flashcardHistory.value?.copyWith(
claimedRewards: selectedQueue,
);
if (newHistory != null) {
mainController.showLoadingPage();
bool result = await childrenUsecase.updateFlashcardHistory(
flashcardHistory: newHistory,
pushToRemote: true,
);
flashcardHistory.value = newHistory;
if (result) {
mainController.hideLoadingPage();
Get.back(result: true);
EpicSnackBar.showSuccessSnackBar(
"Success", "Rewards claimed successfully!");
} else {
EpicSnackBar.showErrorSnackBar(
"Error", "Failed to claim rewards. Please try again.");
}
} else {
EpicLog.debug('No flashcard history found to update.');
}
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'claimReward');
} finally {
mainController.hideLoadingPage();
}
}
}

View File

@ -0,0 +1,373 @@
import 'package:epic_story_app/feature/flashcards/flashcard_reward/flashcard_reward_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashcardRewardPage extends GetView<FlashcardRewardController> {
const FlashcardRewardPage({super.key});
static const Color _pageBackground = Color(0xFFE0CEFF);
static const Color _contentBackground = Color(0xFFA991D2);
static const Color _scrollbarColor = Color(0xFFCACACA);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _pageBackground,
// appBar: const CustomAppBar(title: 'Dapatkan Reward'),
body: Obx(
() {
final rewards = controller.rewards;
final remaining = controller.remainingSelection;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Column(
children: [
const _RewardBadgeHeader(),
const SizedBox(height: 14),
Text(
'Silahkan pilih 3 gambar\nsebagai reward',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: Color(0xFF1E1532),
height: 1.32,
),
),
const SizedBox(height: 6),
Text(
controller.selectedCount == 0
? 'Kamu belum memilih gambar'
: 'Kamu sudah memilih ${controller.selectedCount} gambar sekarang kurang $remaining lagi',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF2D1F49),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 14),
_SelectedSlotsRow(controller: controller),
const SizedBox(height: 14),
],
),
),
Expanded(
child: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: _contentBackground,
borderRadius: BorderRadius.vertical(
top: Radius.circular(14),
),
),
child: RawScrollbar(
thumbVisibility: true,
trackVisibility: true,
thickness: 6.5,
radius: const Radius.circular(6),
thumbColor: _scrollbarColor,
trackColor: _scrollbarColor.withOpacity(0.45),
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 14),
child: _buildGrid(rewards),
),
),
),
),
_claimButton(),
],
);
},
),
);
}
Widget _buildGrid(List<String> rewards) {
if (rewards.isEmpty) {
return _emptyList();
}
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 18,
crossAxisSpacing: 18,
childAspectRatio: 1,
),
itemCount: rewards.length,
itemBuilder: (context, index) {
final isSelected = controller.selectedQueue.contains(index);
return _rewardTile(
imageUrl: rewards[index],
isSelected: isSelected,
onTap: () => controller.selectReward(index),
);
},
);
}
Widget _rewardTile({
required String imageUrl,
required bool isSelected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
decoration: BoxDecoration(
color: _pageBackground,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? const Color(0xFF33234A)
: Colors.black.withOpacity(0.42),
width: isSelected ? 2.2 : 1.1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.5),
child: _networkImage(imageUrl, key: ValueKey(imageUrl)),
),
),
);
}
Widget _networkImage(String url, {Key? key}) {
return Image.network(
url,
key: key,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _imageFallback(),
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return ColoredBox(
color: _pageBackground,
child: const Center(
child: SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: Color(0xFF6B4F92),
),
),
),
);
},
);
}
Widget _emptyList() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _pageBackground,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.info_outline, size: 18, color: Color(0xFF2F2249)),
const SizedBox(width: 8),
Expanded(
child: Text(
'Belum ada reward untuk flashcard ini.',
style: const TextStyle(
color: Color(0xFF2F2249),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _imageFallback() {
return Container(
color: _pageBackground,
alignment: Alignment.center,
child: const Icon(
Icons.broken_image_outlined,
size: 30,
color: Color(0xFF6A5A83),
),
);
}
Widget _claimButton() {
final canClaim =
controller.selectedCount == FlashcardRewardController.maxSelection;
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(66, 10, 66, 12),
child: Opacity(
opacity: canClaim ? 1 : 0.68,
child: GestureDetector(
onTap: canClaim ? controller.claimReward : null,
child: SizedBox(
width: double.infinity,
child: Image.asset(
'assets/images/buttom-ambil-reward.png',
fit: BoxFit.fitWidth,
),
),
),
),
),
);
}
}
class _SelectedSlotsRow extends StatelessWidget {
const _SelectedSlotsRow({required this.controller});
final FlashcardRewardController controller;
@override
Widget build(BuildContext context) {
final selected = controller.selectedRewardsOrdered;
final maxSlot = FlashcardRewardController.maxSelection;
return Row(
children: List.generate(maxSlot, (slotIndex) {
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: slotIndex == maxSlot - 1 ? 0 : 12),
child: _SlotPreview(
imageUrl:
slotIndex < selected.length ? selected[slotIndex] : null,
slotIndex: slotIndex,
onRemove: () => controller.removeSelectionAt(slotIndex),
),
),
);
}),
);
}
}
class _SlotPreview extends StatelessWidget {
const _SlotPreview({
required this.imageUrl,
required this.slotIndex,
required this.onRemove,
});
final String? imageUrl;
final int slotIndex;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final hasImage = imageUrl != null && imageUrl!.isNotEmpty;
return GestureDetector(
onTap: hasImage ? onRemove : null,
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFE0CEFF),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.black.withOpacity(0.5),
width: 1.1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14.5),
child: hasImage
? Image.network(
imageUrl!,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Container(
color: const Color(0xFFE0CEFF),
alignment: Alignment.center,
child: const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.1,
color: Color(0xFF6B4F92),
),
),
);
},
errorBuilder: (context, error, stackTrace) =>
_slotFallback(),
)
: _slotPlaceholder(slotIndex),
),
),
),
);
}
Widget _slotPlaceholder(int slotIndex) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.image_outlined,
color: Color(0xFF6A5A83),
size: 26,
),
const SizedBox(height: 6),
Text(
'Slot\ngambar\nke ${slotIndex + 1}',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF33254B),
height: 1.2,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _slotFallback() {
return Container(
color: const Color(0xFFE0CEFF),
alignment: Alignment.center,
child: const Icon(
Icons.broken_image_outlined,
size: 22,
color: Color(0xFF6A5A83),
),
);
}
}
class _RewardBadgeHeader extends StatelessWidget {
const _RewardBadgeHeader();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 200,
width: double.infinity,
child: Image.asset(
'assets/images/header-claim-reward.png',
fit: BoxFit.contain,
alignment: Alignment.center,
),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:get/get.dart';
import 'multiple_choice_controller.dart';
class MultipleChoiceBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<MultipleChoiceController>(() => MultipleChoiceController());
}
}

View File

@ -0,0 +1,12 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
import 'package:get/get.dart';
class MultipleChoiceController extends GetxController {
late QuizChoiceEntity quizChoice;
@override
void onInit() {
super.onInit();
quizChoice = Get.arguments as QuizChoiceEntity;
}
}

View File

@ -0,0 +1,264 @@
import 'package:epic_story_app/feature/quizes/multiple_choices/multiple_choice_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter/services.dart';
class MultipleChoicePage extends StatefulWidget {
const MultipleChoicePage({super.key});
@override
State<MultipleChoicePage> createState() => _MultipleChoicePageState();
}
class _MultipleChoicePageState extends State<MultipleChoicePage> {
final MultipleChoiceController _controller =
Get.find<MultipleChoiceController>();
int? _selectedIndex;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(
<DeviceOrientation>[
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown
],
);
}
@override
void dispose() {
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
super.dispose();
}
@override
Widget build(BuildContext context) {
final Color borderColor = Colors.grey.shade800;
final Color panelColor = Colors.grey.shade200;
final Color cardColor = Colors.grey.shade100;
final Color shadowColor = Colors.black.withOpacity(0.12);
final String question = _controller.quizChoice.question ?? '';
final List<String> options = _controller.quizChoice.choices ?? <String>[];
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
title: const Text('Multiple Choice Quiz'),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: panelColor,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: borderColor, width: 2),
boxShadow: <BoxShadow>[
BoxShadow(
color: shadowColor,
blurRadius: 18,
offset: const Offset(10, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor, width: 2),
boxShadow: <BoxShadow>[
BoxShadow(
color: shadowColor,
blurRadius: 12,
offset: const Offset(6, 6),
),
],
),
child: Text(
question,
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.black,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 16),
if (options.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'No quiz data available.',
textAlign: TextAlign.center,
),
)
else
...options
.asMap()
.entries
.map((MapEntry<int, String> entry) {
final bool isSelected = entry.key == _selectedIndex;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _OptionTile(
label: entry.value,
isSelected: isSelected,
borderColor: borderColor,
shadowColor: shadowColor,
cardColor: cardColor,
leadingLabel: _indexToLetter(entry.key),
onTap: () =>
setState(() => _selectedIndex = entry.key),
),
);
}),
const SizedBox(height: 4),
SizedBox(
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: cardColor,
foregroundColor: Colors.black,
elevation: 6,
shadowColor: shadowColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor, width: 2),
),
),
onPressed: _onCheckAnswers,
child: const Text('Check Answers'),
),
),
],
),
),
),
),
),
),
);
}
void _onCheckAnswers() {
if (_selectedIndex == null) {
_showMessage('Please select an option first.');
return;
}
final int? correctIndex = _controller.quizChoice.correctedIndex;
final bool isCorrect =
correctIndex != null && correctIndex == _selectedIndex;
final String message = isCorrect
? 'Correct answer!'
: 'Incorrect. Correct option is ${correctIndex != null ? _indexToLetter(correctIndex) : '-'}.';
_showMessage(message);
}
void _showMessage(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
),
);
}
String _indexToLetter(int index) => String.fromCharCode(65 + index);
}
class _OptionTile extends StatelessWidget {
const _OptionTile({
required this.label,
required this.isSelected,
required this.borderColor,
required this.shadowColor,
required this.cardColor,
required this.leadingLabel,
required this.onTap,
});
final String label;
final bool isSelected;
final Color borderColor;
final Color shadowColor;
final Color cardColor;
final String leadingLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: Ink(
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected ? Colors.black : borderColor,
width: 2,
),
boxShadow: <BoxShadow>[
BoxShadow(
color: shadowColor,
blurRadius: 12,
offset: const Offset(6, 6),
),
],
),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
child: Row(
children: <Widget>[
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? Colors.black : Colors.white,
border: Border.all(color: borderColor, width: 2),
),
alignment: Alignment.center,
child: Text(
leadingLabel,
style: TextStyle(
color: isSelected ? Colors.white : Colors.black,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.black,
fontWeight: FontWeight.w600,
),
),
),
],
),
),
),
);
}
}