feat: add flashcards and quizes features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
e2c2936fae
commit
1ab2705f46
|
|
@ -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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
enum FlashcardHistoryState{
|
||||||
|
allHistories,
|
||||||
|
learnHistories,
|
||||||
|
quizHistories,
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue