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