feat: add books feature (home, read, quiz, reward, histories)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Achmad Baihaqi 2026-06-28 23:06:45 +07:00
parent 17ca14c40c
commit e2c2936fae
27 changed files with 6269 additions and 0 deletions

View File

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

View File

@ -0,0 +1,38 @@
import 'package:epic_story_app/feature/books/presentation/book_histories/state/book_histories_state.dart';
import 'package:get/get.dart';
import '../../../../domain/entities/book_histories_entity.dart';
import '../book_home/book_home_controller.dart';
class BookHistoriesController extends GetxController {
final bookHomeController = Get.find<BookHomeController>();
late BookHistoriesState selectedState;
var histories = <BookHistoryEntity>[].obs;
void openFromHistory(BookHistoryEntity history) {
bookHomeController.gotoBookReadFromHistory(history);
}
var title = ''.obs;
@override
void onInit() {
super.onInit();
selectedState = Get.arguments;
switch (selectedState) {
case BookHistoriesState.allHistories:
histories.value = bookHomeController.histories;
title.value = 'Semua Riwayat';
break;
case BookHistoriesState.learnHistories:
histories.value = bookHomeController.uncompletedHistories;
title.value = 'Buku yang belum kamu selesaikan';
break;
case BookHistoriesState.quizHistories:
histories.value = bookHomeController.completedHistories;
title.value = 'Kuis yang belum kamu kerjakan';
break;
}
}
}

View File

@ -0,0 +1,249 @@
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
import 'package:epic_story_app/domain/entities/book_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 'book_histories_controller.dart';
class BookHistoriesPage extends GetView<BookHistoriesController> {
const BookHistoriesPage({super.key});
static const Color _mainBackground = Color(0xFFE0CEFF);
static const List<Color> _gradientCycle = [
Color(0xFFFFDAA0),
Color(0xFFAAFFC0),
];
@override
Widget build(BuildContext context) {
return Obx(() {
final histories = controller.histories;
final selectedCategory =
controller.bookHomeController.selectedCategory.value;
final activeCategory =
selectedCategory.isEmpty ? 'Semua' : selectedCategory;
final bookById = {
for (final book in controller.bookHomeController.books)
if (book.bookId != null) book.bookId!: book,
};
return Scaffold(
backgroundColor: _mainBackground,
body: SafeArea(
bottom: false,
child: Column(
children: [
StackedInfoTopBar(
titleText: 'Riwayat Buku',
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 buku.',
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) {
final history = histories[index];
final linkedBook = history.bookId == null
? null
: bookById[history.bookId!];
return _HistoryItem(
item: history,
index: index,
linkedBook: linkedBook,
gradientCycle: _gradientCycle,
onTap: () => controller.openFromHistory(history),
);
},
),
),
),
],
),
),
);
});
}
}
class _HistoryItem extends StatelessWidget {
const _HistoryItem({
required this.item,
required this.index,
required this.linkedBook,
required this.gradientCycle,
required this.onTap,
});
final BookHistoryEntity item;
final int index;
final BookEntity? linkedBook;
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: [
_buildBookImage(
coverUrl: item.bookCover,
category: linkedBook?.category ?? item.category,
width: 144,
height: 164,
),
const SizedBox(width: 12),
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: 10),
Text(
'Category : ${item.category ?? linkedBook?.category ?? 'Kategori buku'}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 4),
Text(
'Total Halaman : ${item.totalPages ?? 0}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 10),
Text(
linkedBook?.summary ?? '-',
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Image.asset(
(item.isFinished ?? false)
? 'assets/images/ic-book-selesai.png'
: 'assets/images/ic-book-belum-selesai.png',
width: 78,
fit: BoxFit.contain,
),
],
),
],
),
),
],
),
),
);
}
Widget _buildBookImage({
required String? coverUrl,
required String? category,
required double width,
required double height,
}) {
final fallbackAsset = _resolveFallbackBookAsset(category, index);
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: width,
height: height,
child: coverUrl != null && coverUrl.trim().isNotEmpty
? Image.network(
coverUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Image.asset(
fallbackAsset,
fit: BoxFit.cover,
),
)
: Image.asset(
fallbackAsset,
fit: BoxFit.cover,
),
),
);
}
String _resolveFallbackBookAsset(String? category, int index) {
final normalized = (category ?? '').toLowerCase();
if (normalized.contains('matematika') || normalized.contains('ipa')) {
return 'assets/images/book-matematika.png';
}
if (normalized.contains('bahasa') || normalized.contains('indo')) {
return 'assets/images/book-bhs-indo.png';
}
return index.isEven
? 'assets/images/book-matematika.png'
: 'assets/images/book-bhs-indo.png';
}
}

View File

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

View File

@ -0,0 +1,21 @@
import 'package:epic_story_app/data/modules/book_module.dart';
import 'package:epic_story_app/data/modules/collection_module.dart';
import 'package:epic_story_app/domain/usecases/book_usecase.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/feature/books/presentation/book_home/book_home_controller.dart';
import 'package:get/get.dart';
class BookHomeBinding extends Bindings {
@override
void dependencies() {
BookModule();
CollectionModule();
CollectionModule();
Get.lazyPut(() => BookHomeController(
collectionUsecase: Get.find<CollectionUsecase>(),
bookUsecase: Get.find<BookUsecase>(),
childrenUsecase: Get.find<ChildrenUsecase>(),
));
}
}

View File

@ -0,0 +1,189 @@
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/book_entity.dart';
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
import 'package:epic_story_app/domain/usecases/book_usecase.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/feature/books/presentation/book_histories/state/book_histories_state.dart';
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:get/get.dart';
class BookHomeController extends GetxController {
final BookUsecase bookUsecase;
final CollectionUsecase collectionUsecase;
final ChildrenUsecase childrenUsecase;
final MainController mainController = Get.find<MainController>();
var histories = <BookHistoryEntity>[].obs;
BookHomeController({
required this.bookUsecase,
required this.collectionUsecase,
required this.childrenUsecase,
});
final categories = <String>[].obs;
final selectedCategory = 'Semua'.obs;
var isLoading = false.obs;
final RxList<BookEntity> books = <BookEntity>[].obs;
final navController = Get.find<EpicNavigationController>();
List<BookEntity> get filteredBooks {
if (selectedCategory.value == 'Semua') return books;
return books
.where((book) => (book.category ?? 'Lainnya') == selectedCategory.value)
.toList();
}
List<BookHistoryEntity> get uncompletedHistories {
return histories.where((h) {
final total = (h.totalPages ?? 0)-2;
final pagesRead = h.readPages ?? 0;
return pagesRead < total;
}).toList();
}
List<BookHistoryEntity> get completedHistories {
return histories.where((h) {
final total = (h.totalPages ?? 0)-2;
final pagesRead = h.readPages ?? 0;
final isFinished = h.isFinished ?? false;
return pagesRead == total && !isFinished;
}).toList();
}
List<BookHistoryEntity> get filteredHistory {
if (selectedCategory.value == 'Semua') return histories;
return histories
.where((history) =>
(history.category ?? 'Lainnya') == selectedCategory.value)
.toList();
}
@override
void onInit() async {
super.onInit();
ever(navController.selectedCategory, (value) {
selectedCategory.value = value.isEmpty ? 'Semua' : value;
});
// await loadBooks();
}
Future<void> loadBooks() async {
try {
isLoading.value = true;
final bookData = await bookUsecase.getBooks(limit: 20);
final bookHistories = await childrenUsecase.getBookHistories();
books.clear();
books.addAll(bookData.books);
histories.clear();
histories.addAll(bookHistories);
sortedHistories();
_buildCategories();
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'loadBooks');
} finally {
isLoading.value = false;
EpicLog.debug("homeController - Finished fetching books : $isLoading");
}
}
void sortedHistories() {
histories.sort((a, b) =>
b.lastReadAt?.compareTo(
a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ??
0);
}
void _buildCategories() {
final uniqueCategories = {
'Semua',
...books.map((e) => e.category ?? 'Lainnya'),
};
categories.assignAll(uniqueCategories.toList());
if (categories.isNotEmpty) {
final current = navController.selectedCategory.value;
selectedCategory.value = current.isNotEmpty ? current : categories.first;
}
}
Future<void> addBookToCollection({
required String collectionId,
required String bookId,
}) async {
try {
mainController.showLoadingPage();
await collectionUsecase.addBookToCollection(
collectionId: collectionId,
bookId: bookId,
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'addBookToCollection');
} finally {
mainController.hideLoadingPage();
}
}
Future<void> reactBook({
required String bookId,
required String emoticon,
bool isIncrement = true,
}) async {
try {
mainController.showLoadingPage();
await mainController.childrenUsecase.reactBook(
bookId: bookId,
emoticon: emoticon,
isIncrement: isIncrement,
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'reactBook');
} finally {
mainController.hideLoadingPage();
}
}
void goToBookRead(BookEntity book) async {
await Get.toNamed(
EpicRoutes.bookRead,
arguments: book,
);
final updatedHistories = await childrenUsecase.getBookHistories(
refreshLocal: true,
);
histories.clear();
histories.addAll(updatedHistories);
sortedHistories();
_buildCategories();
}
void gotoBookReadFromHistory(BookHistoryEntity history) async {
final book = books.firstWhereOrNull((b) => b.bookId == history.bookId);
if (book == null) {
EpicLog.debug(
'Book with ID ${history.bookId} not found for history ${history.bookId}');
return;
}
await Get.toNamed(
EpicRoutes.bookRead,
arguments: book,
);
final updatedHistories = await childrenUsecase.getBookHistories(
refreshLocal: true,
);
histories.clear();
histories.addAll(updatedHistories);
sortedHistories();
_buildCategories();
}
void goToBookHistories(BookHistoriesState state) {
Get.toNamed(EpicRoutes.bookHistories, arguments: state);
}
}

View File

@ -0,0 +1,525 @@
import 'package:epic_story_app/domain/entities/book_entity.dart';
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_histories/state/book_histories_state.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class BookHomePage extends StatelessWidget {
const BookHomePage({super.key});
static const Color _mainBackground = Color(0xFFE0CEFF);
static const double _historyCardWidth = 280;
static const double _historyCardHeight = 152;
static const List<Color> _gradientCycle = [
Color(0xFFFFDAA0),
Color(0xFFAAFFC0),
];
@override
Widget build(BuildContext context) {
final BookHomeController controller = Get.find();
return Scaffold(
backgroundColor: _mainBackground,
body: Obx(() {
final bookById = {
for (final book in controller.books)
if (book.bookId != null) book.bookId!: book,
};
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final histories = controller.filteredHistory;
final latestHistories = histories.take(5).toList();
final items = controller.filteredBooks;
final learnCount = controller.uncompletedHistories.length;
final quizCount = controller.completedHistories.length;
return SingleChildScrollView(
padding: EdgeInsets.only(left: 10, top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Riwayat Buku'),
const SizedBox(height: 12),
if (histories.isEmpty)
_buildEmptyState(
'Belum ada riwayat baca di kategori "${controller.selectedCategory.value}"',
)
else
SizedBox(
height: _historyCardHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: latestHistories.length + 1,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
if (index == latestHistories.length) {
return _buildSeeAllHistoriesButton(
index: index,
onTap: () => controller.goToBookHistories(
BookHistoriesState.allHistories,
),
);
}
final history = latestHistories[index];
final linkedBook = history.bookId == null
? null
: bookById[history.bookId!];
return _buildHistoryCard(
history,
linkedBook,
index,
onTap: () {
controller.gotoBookReadFromHistory(history);
},
);
},
),
),
const SizedBox(height: 24),
_buildActionButtons(
learnCount: learnCount,
quizCount: quizCount,
onTapLearn: () {
controller
.goToBookHistories(BookHistoriesState.learnHistories);
},
onTapQuiz: () {
controller
.goToBookHistories(BookHistoriesState.quizHistories);
},
),
const SizedBox(height: 24),
_buildSectionTitle('Buku yang dapat kamu pelajari'),
const SizedBox(height: 10),
if (items.isEmpty)
_buildEmptyState(
'Belum ada buku di kategori "${controller.selectedCategory.value}"',
)
else
ListView.separated(
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
return _buildBookLearningCard(
items[index],
index,
onTap: () {
controller.goToBookRead(items[index]);
},
);
},
),
SizedBox(height: 24),
],
),
);
}),
);
}
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(
BookHistoryEntity history,
BookEntity? linkedBook,
int index, {
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: _historyCardWidth,
height: _historyCardHeight,
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
decoration: _cardDecoration(index),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBookImage(
coverUrl: history.bookCover,
category: linkedBook?.category ?? history.category,
index: index,
width: 96,
height: 132,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
history.title ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
color: Colors.black,
height: 1,
),
),
const SizedBox(height: 6),
Text(
linkedBook?.summary ?? '-',
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF232323),
height: 1.2,
),
),
const Spacer(),
_miniPill(
icon: Icons.label_outline,
label:
history.category ?? linkedBook?.category ?? 'Lainnya',
),
],
),
),
],
),
),
);
}
Widget _buildSeeAllHistoriesButton({
required int index,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: _historyCardHeight,
decoration: _cardDecoration(index),
child: const Center(
child: Icon(
Icons.arrow_right_alt_rounded,
size: 30,
color: Colors.black87,
),
),
),
);
}
Widget _buildActionButtons({
required int learnCount,
required int quizCount,
required VoidCallback onTapLearn,
required VoidCallback onTapQuiz,
}) {
return Row(
children: [
Expanded(
child: _buildActionCapsule(
label: 'Belajar=$learnCount',
imagePath: 'assets/images/yellow-button.png',
onPressed: onTapLearn,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildActionCapsule(
label: 'Kuis=$quizCount',
imagePath: 'assets/images/green-button.png',
onPressed: onTapQuiz,
),
),
],
);
}
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 _buildBookLearningCard(
BookEntity item,
int index, {
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
decoration: _cardDecoration(index),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildBookImage(
coverUrl: item.bookCover,
category: item.category,
index: index,
width: 144,
height: 164,
),
const SizedBox(width: 12),
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: 12),
Text(
'Category : ${item.category ?? '-'}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 4),
Text(
'Total Halaman : ${item.totalPages ?? 0}',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF232323),
height: 1.2,
),
),
const SizedBox(height: 10),
Text(
item.summary ?? '-',
maxLines: 4,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: Color(0xFF232323),
height: 1.2,
),
),
],
),
),
],
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
_buildReadLabel(item.reads),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Color(0xFF232323),
),
),
],
),
],
),
),
);
}
Widget _buildBookImage({
required String? coverUrl,
required String? category,
required int index,
required double width,
required double height,
}) {
final fallbackAsset = _resolveFallbackBookAsset(category, index);
return ClipRRect(
borderRadius: BorderRadius.circular(16),
child: SizedBox(
width: width,
height: height,
child: coverUrl != null && coverUrl.trim().isNotEmpty
? Image.network(
coverUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Image.asset(
fallbackAsset,
fit: BoxFit.cover,
),
)
: Image.asset(
fallbackAsset,
fit: BoxFit.cover,
),
),
);
}
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 _resolveFallbackBookAsset(String? category, int index) {
final normalized = (category ?? '').toLowerCase();
if (normalized.contains('matematika') || normalized.contains('ipa')) {
return 'assets/images/book-matematika.png';
}
if (normalized.contains('bahasa') || normalized.contains('indo')) {
return 'assets/images/book-bhs-indo.png';
}
return index.isEven
? 'assets/images/book-matematika.png'
: 'assets/images/book-bhs-indo.png';
}
String _buildReadLabel(int? reads) {
final value = reads ?? 0;
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(0)} rb x dibaca';
}
return '$value x dibaca';
}
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(0xFFAAFFC0),
Color(0xFFFFFFFF),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white, width: 1.2),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Color(0xFF151515), size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF151515),
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,113 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/domain/entities/book_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/components/book_cover.dart';
import 'package:epic_story_app/feature/books/presentation/book_read/components/dialog_add_book_coll.dart';
import 'package:flutter/material.dart';
class BookCard extends StatelessWidget {
final BookEntity item;
final BookHomeController controller;
const BookCard({super.key, required this.item, required this.controller});
@override
Widget build(BuildContext context) {
final book = item;
return GestureDetector(
onTap: () {
controller.goToBookRead(book);
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF4D4D4D), width: 1),
),
padding: EdgeInsets.all(EpicSize.sizeMedium),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: EpicSize.calculatedSize(90),
height: EpicSize.calculatedSize(122),
child: BookCover(coverUrl: book.bookCover),
),
SizedBox(width: EpicSize.sizeSmall),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
book.title ?? 'Judul belum tersedia',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: Colors.black,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: EpicSize.sizeSuperSmall),
Text(
'Category : ${book.category ?? '-'}',
style: TextStyle(
fontSize: 14,
color: Colors.black,
),
),
SizedBox(height: EpicSize.sizeSuperSmall),
Text(
'Total Halaman : ${book.totalPages ?? 0}',
style: TextStyle(
fontSize: 14,
color: Colors.black,
),
),
SizedBox(height: EpicSize.sizeSuperSmall),
Text(
book.summary ?? '--',
style: TextStyle(
color: Colors.black,
height: 1.2,
fontSize: 12,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
SizedBox(height: EpicSize.sizeSmall),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
_buildReadLabel(book.reads),
style: TextStyle(
fontSize: 14,
color: Colors.black,
),
),
],
),
],
),
),
);
}
String _buildReadLabel(int? reads) {
final value = reads ?? 0;
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(0)} rb x dibaca';
}
return '$value x dibaca';
}
}

View File

@ -0,0 +1,52 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:flutter/material.dart';
class BookCover extends StatelessWidget {
final String? coverUrl;
const BookCover({this.coverUrl});
@override
Widget build(BuildContext context) {
final double side = EpicSize.calculatedSize(110);
return ClipRRect(
borderRadius: EpicSize.radiusSmall,
child: Container(
width: side,
height: side,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.black.withOpacity(0.16)),
),
child: coverUrl != null
? Image.network(
coverUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _placeholder(),
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder(isLoading: true);
},
)
: _placeholder(),
),
);
}
Widget _placeholder({bool isLoading = false}) {
return Center(
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(
'Cover Buku',
style: EpicSize.bodyLarge.copyWith(color: Colors.black54),
textAlign: TextAlign.center,
),
);
}
}

View File

@ -0,0 +1,42 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
import 'package:flutter/material.dart';
class ReactionStat extends StatelessWidget {
final String symbol;
final int count;
final String symbolName;
final BookHomeController bookController;
final String bookId;
const ReactionStat({
super.key,
required this.symbol,
required this.count,
required this.symbolName,
required this.bookController,
required this.bookId,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
bookController.reactBook(
bookId: bookId,
emoticon: symbolName,
);
},
child: Row(
children: [
Text(
symbol,
style: TextStyle(fontSize: EpicSize.sizeMediumLarge),
),
SizedBox(width: EpicSize.sizeSuperSmall),
Text(count.toString(), style: EpicSize.bodyLarge),
],
),
);
}
}

View File

@ -0,0 +1,106 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/components/book_cover.dart';
import 'package:flutter/material.dart';
class HistoryCard extends StatelessWidget {
final BookHistoryEntity item;
final String? category;
final String? summary;
final BookHomeController controller;
const HistoryCard({
super.key,
required this.item,
this.category,
this.summary,
required this.controller,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
controller.gotoBookReadFromHistory(item);
},
child: Container(
width: EpicSize.calculatedSize(300),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF4D4D4D), width: 1),
),
padding: EdgeInsets.all(EpicSize.sizeSmall),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: EpicSize.calculatedSize(70),
height: EpicSize.calculatedSize(92),
child: BookCover(coverUrl: item.bookCover),
),
SizedBox(width: EpicSize.sizeSmall),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? 'Judul belum tersedia',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: Colors.black,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: EpicSize.sizeSuperSmall),
Text(
summary ?? '-',
style: TextStyle(
fontSize: 14,
color: Colors.black,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: EpicSize.sizeSuperSmall),
Text(
'Category : ${category ?? '-'}',
style: TextStyle(
fontSize: 12,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
}
class EmptyHistoryState extends StatelessWidget {
final String category;
const EmptyHistoryState({super.key, required this.category});
@override
Widget build(BuildContext context) {
return Center(
child: Text(
'Belum ada riwayat baca di kategori "$category"',
style: TextStyle(
fontSize: 16,
color: Colors.black,
),
textAlign: TextAlign.center,
),
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:get/get.dart';
import 'package:epic_story_app/data/modules/children_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/quiz_usecase.dart';
import 'book_quiz_controller.dart';
class BookQuizBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
QuizModule();
Get.lazyPut(
() => BookQuizController(
childrenUsecase: Get.find<ChildrenUsecase>(),
quizUsecase: Get.find<QuizUsecase>(),
),
);
}
}

View File

@ -0,0 +1,930 @@
import 'dart:convert';
import 'package:epic_story_app/core/constants/quiz_prompt.dart';
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/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_fill_blank_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
import 'package:epic_story_app/data/models/remotes/quiz/quiz_choice_remote_model.dart';
import 'package:epic_story_app/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart';
import 'package:epic_story_app/data/models/remotes/quiz/quiz_group_remote_model.dart';
import 'package:epic_story_app/domain/entities/book_entity.dart';
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
import 'package:epic_story_app/domain/entities/book_history_quiz_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_fill_blank_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
class BookQuizController extends GetxController {
BookQuizController({
required this.childrenUsecase,
required this.quizUsecase,
});
final ChildrenUsecase childrenUsecase;
final QuizUsecase quizUsecase;
final navController = Get.find<EpicNavigationController>();
final Rxn<BookHistoryEntity> bookHistory = Rxn<BookHistoryEntity>();
final Rxn<BookEntity> currentBook = Rxn<BookEntity>();
final RxBool isLoading = false.obs;
final isAnswerChecked = false.obs;
final isAnswerCorrect = false.obs;
final retryQuestion = 3.obs;
final assignedChoice = (-1).obs;
final assignedGroupByOption = <String, String>{}.obs;
final assignedBlankByOption = <String, String>{}.obs;
String _theoryText = '';
int? _bookQuizType;
var title = ''.obs;
BookHistoryQuizEntity? get quiz => bookHistory.value?.quiz;
int get quizType => quiz?.quizType ?? 0;
QuizChoiceEntity? get multipleChoiceQuiz => quiz?.multipleChoiceQuestion;
QuizFillBlankEntity? get fillBlankQuiz => quiz?.fillBlankQuestion;
QuizGroupEntity? get groupQuiz => quiz?.groupQuestion;
bool get isQuizAvailable {
final type = quizType;
if (type == 1) return multipleChoiceQuiz != null;
if (type == 2) return fillBlankQuiz != null;
if (type == 3) return groupQuiz != null;
return false;
}
String? get explanation {
if (quiz?.explanation?.trim().isNotEmpty == true) {
return quiz?.explanation;
}
if (quizType == 1) return multipleChoiceQuiz?.explanation;
if (quizType == 2) return fillBlankQuiz?.explanation;
if (quizType == 3) return groupQuiz?.explanation;
return quiz?.explanation;
}
@override
void onInit() {
super.onInit();
_initFromArguments();
}
void _initFromArguments() {
final args = Get.arguments;
BookHistoryEntity? history;
if (args is BookHistoryEntity) {
history = args;
} else if (args is Map) {
final rawHistory = args['history'];
if (rawHistory is BookHistoryEntity) {
history = rawHistory;
}
final theoryTextArg = args['theoryText'];
if (theoryTextArg is String) {
_theoryText = theoryTextArg;
}
final bookQuizTypeArg = args['bookQuizType'];
if (bookQuizTypeArg is int) {
_bookQuizType = bookQuizTypeArg;
}
final bookArg = args['book'];
if (bookArg is BookEntity) {
currentBook.value = bookArg;
}
title.value = history?.title ?? 'null';
}
if (history == null) {
EpicLog.debug('BookQuizController - missing book history argument');
return;
}
bookHistory.value = history;
syncCurrentQuiz();
}
void syncCurrentQuiz() {
final currentQuiz = quiz;
retryQuestion.value = currentQuiz?.retry ?? 3;
assignedChoice.value =
currentQuiz?.multipleChoiceAnswer?.selectedAnswer ?? -1;
assignedBlankByOption.clear();
assignedBlankByOption
.addAll(currentQuiz?.fillBlankAnswer?.assignedBlankByOption ?? {});
assignedGroupByOption.clear();
assignedGroupByOption
.addAll(currentQuiz?.groupAnswer?.assignedGroupByOption ?? {});
final hasStoredAnswer = (currentQuiz?.answer?.trim().isNotEmpty ?? false) ||
currentQuiz?.multipleChoiceAnswer != null ||
currentQuiz?.fillBlankAnswer != null ||
currentQuiz?.groupAnswer != null;
isAnswerChecked.value = hasStoredAnswer;
isAnswerCorrect.value = _evaluateCurrentCorrectness();
}
bool _evaluateCurrentCorrectness() {
final type = quizType;
if (type == 1) {
final q = multipleChoiceQuiz;
if (q == null) return false;
return q.correctedIndex == assignedChoice.value &&
assignedChoice.value >= 0;
}
if (type == 2) {
final q = fillBlankQuiz;
if (q == null) return false;
final options = q.options ?? const [];
if (options.isEmpty) return false;
for (final option in options) {
final id = option.id;
final correctBlank = option.correctBlankId;
if (id == null || correctBlank == null) return false;
final assigned = assignedBlankByOption[id];
if (assigned != correctBlank) return false;
}
return true;
}
if (type == 3) {
final q = groupQuiz;
if (q == null) return false;
final options = q.options ?? const [];
if (options.isEmpty) return false;
for (final option in options) {
final id = option.id;
final correctGroup = option.correctGroupId;
if (id == null || correctGroup == null) return false;
final assigned = assignedGroupByOption[id];
if (assigned != correctGroup) return false;
}
return true;
}
return false;
}
void selectChoice(int index) {
assignedChoice.value = index;
isAnswerChecked.value = false;
}
void assignOptionToGroup(
{required String optionId, required String? groupId}) {
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',
'Setiap group maksimal 4 item.',
);
return;
}
}
if (optionId.isEmpty) return;
if (groupId == null || groupId.isEmpty) {
assignedGroupByOption.remove(optionId);
} else {
assignedGroupByOption[optionId] = groupId;
}
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,
String? explanation,
QuizChoiceAnswerEntity? multipleChoiceAnswer,
QuizChoiceEntity? multipleChoiceQuestion,
QuizGroupAnswerEntity? groupAnswer,
QuizGroupEntity? groupQuestion,
QuizFillBlankAnswerEntity? fillBlankAnswer,
QuizFillBlankEntity? fillBlankQuestion,
}) async {
final history = bookHistory.value;
if (history == null) return;
final existingQuiz = history.quiz;
if (existingQuiz == null) return;
final updatedQuiz = existingQuiz.copyWith(
quizType: quizType,
retry: retryQuestion.value,
question: question ?? existingQuiz.question,
answer: answer,
explanation: explanation ?? existingQuiz.explanation,
multipleChoiceQuestion:
multipleChoiceQuestion ?? existingQuiz.multipleChoiceQuestion,
multipleChoiceAnswer: multipleChoiceAnswer,
fillBlankQuestion: fillBlankQuestion ?? existingQuiz.fillBlankQuestion,
fillBlankAnswer: fillBlankAnswer,
groupQuestion: groupQuestion ?? existingQuiz.groupQuestion,
groupAnswer: groupAnswer,
);
final completed = [...?history.completedQuizes];
if (isCorrect && !completed.contains(quizType)) {
completed.add(quizType);
}
final finished = isCorrect;
final updatedHistory = history.copyWith(
quiz: updatedQuiz,
completedQuizes: completed,
lastReadAt: DateTime.now(),
isFinished: finished,
finishedAt: finished
? (history.finishedAt ?? DateTime.now())
: history.finishedAt,
);
// Update local state first so UI/result stays consistent even if user leaves quickly.
bookHistory.value = updatedHistory;
await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
}
Future<void> onCheckAnswer() async {
if (!isQuizAvailable) return;
if (retryQuestion.value <= 0) {
EpicSnackBar.showErrorSnackBar(
'Kesempatan Habis',
'Kesempatan menjawab untuk quiz ini sudah habis.',
);
return;
}
final type = quizType;
if (type == 1) {
final q = multipleChoiceQuiz;
if (q == null) return;
final correctIndex = q.correctedIndex;
if (correctIndex == null || assignedChoice.value < 0) {
EpicSnackBar.showWarningSnackBar(
'Quiz', 'Pilih jawaban terlebih dahulu.');
return;
}
isAnswerCorrect.value = assignedChoice.value == correctIndex;
isAnswerChecked.value = true;
retryQuestion.value--;
final answerData = QuizChoiceAnswerEntity(
selectedAnswer: assignedChoice.value,
);
final questionJson = QuizChoiceMapper.entityToRemote(q).toJson();
final answerJson =
QuizChoiceAnswerMapper.entityToRemote(answerData).toJson();
if (isAnswerCorrect.value) {
EpicSnackBar.showSuccessSnackBar('Benar', 'Jawaban kamu benar.');
} else {
EpicSnackBar.showErrorSnackBar('Salah', 'Coba lagi ya.');
}
await updateHistoryAnswer(
quizType: type,
isCorrect: isAnswerCorrect.value,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
explanation: q.explanation,
multipleChoiceQuestion: q,
multipleChoiceAnswer: answerData,
);
// if (isAnswerCorrect.value) {
// navController.refreshRewardBook.value++;
// }
return;
}
if (type == 2) {
final q = fillBlankQuiz;
if (q == null) return;
final options = q.options ?? const [];
if (options.isEmpty) return;
var allAssigned = true;
var allCorrect = true;
for (final option in options) {
final id = option.id;
final correctBlankId = option.correctBlankId;
if (id == null) continue;
final assigned = assignedBlankByOption[id];
if (assigned == null) {
allAssigned = false;
allCorrect = false;
continue;
}
if (correctBlankId != null && assigned != correctBlankId) {
allCorrect = false;
}
}
isAnswerChecked.value = true;
isAnswerCorrect.value = allAssigned && allCorrect;
retryQuestion.value--;
final answerData = QuizFillBlankAnswerEntity(
assignedBlankByOption: Map<String, String>.from(assignedBlankByOption),
);
final questionJson = QuizFillBlankMapper.entityToRemote(q).toJson();
final answerJson =
QuizFillBlankAnswerMapper.entityToRemote(answerData).toJson();
if (isAnswerCorrect.value) {
EpicSnackBar.showSuccessSnackBar('Benar', 'Semua isian sudah benar.');
} else {
EpicSnackBar.showErrorSnackBar(
'Salah',
allAssigned
? 'Masih ada isian yang salah.'
: 'Lengkapi semua isian dulu.',
);
}
await updateHistoryAnswer(
quizType: type,
isCorrect: isAnswerCorrect.value,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
explanation: q.explanation,
fillBlankQuestion: q,
fillBlankAnswer: answerData,
);
// if (isAnswerCorrect.value) {
// navController.refreshRewardBook.value++;
// }
return;
}
if (type == 3) {
final q = groupQuiz;
if (q == null) return;
final options = q.options ?? const [];
if (options.isEmpty) return;
var allAssigned = true;
var allCorrect = true;
for (final option in options) {
final id = option.id;
final correctGroup = option.correctGroupId;
if (id == null) continue;
final assigned = assignedGroupByOption[id];
if (assigned == null) {
allAssigned = false;
allCorrect = false;
continue;
}
if (correctGroup != null && assigned != correctGroup) {
allCorrect = false;
}
}
isAnswerChecked.value = true;
isAnswerCorrect.value = allAssigned && allCorrect;
retryQuestion.value--;
final answerData = QuizGroupAnswerEntity(
assignedGroupByOption: Map<String, String>.from(assignedGroupByOption),
);
final questionJson = QuizGroupMapper.entityToRemote(q).toJson();
final answerJson =
QuizGroupAnswerMapper.entityToRemote(answerData).toJson();
if (isAnswerCorrect.value) {
EpicSnackBar.showSuccessSnackBar(
'Benar',
'Semua item sudah di kelompok yang tepat.',
);
} else {
EpicSnackBar.showErrorSnackBar(
'Salah',
allAssigned
? 'Masih ada item di kelompok yang salah.'
: 'Masukkan semua item ke group.',
);
}
await updateHistoryAnswer(
quizType: type,
isCorrect: isAnswerCorrect.value,
question: jsonEncode(questionJson),
answer: jsonEncode(answerJson),
explanation: q.explanation,
groupQuestion: q,
groupAnswer: answerData,
);
// if (isAnswerCorrect.value) {
// navController.refreshRewardBook.value++;
// }
return;
}
EpicLog.debug('BookQuizController - unsupported quiz type: $type');
}
void showExplanationDialog(String? explanationText) {
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(
explanationText ?? 'Penjelasan belum tersedia.',
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: Color(0xFF37474F),
height: 1.6,
),
),
),
),
const SizedBox(height: 20),
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: 0,
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: true,
barrierColor: Colors.black.withOpacity(0.4),
);
}
Future<void> forceGenerateQuiz() async {
if (isLoading.value) return;
final history = bookHistory.value;
if (history == null) return;
final text = _theoryText.trim();
if (text.isEmpty) {
EpicSnackBar.showErrorSnackBar(
'Quiz',
'Materi buku kosong, quiz tidak bisa digenerate ulang.',
);
return;
}
final type = _bookQuizType ?? quizType;
if (type < 1 || type > 3) {
EpicSnackBar.showErrorSnackBar(
'Quiz',
'Tipe quiz tidak valid untuk generate ulang.',
);
return;
}
isLoading.value = true;
try {
final previousQuestions = _extractPreviousQuestions(history.quiz);
final generatedQuiz = await _tryGenerateQuizByQuizType(
quizType: type,
text: text,
category: currentBook.value?.category,
previousQuestions: previousQuestions,
);
if (generatedQuiz == null) {
EpicSnackBar.showErrorSnackBar(
'Quiz',
'Gagal generate ulang quiz.',
);
return;
}
final updatedHistory = history.copyWith(
quiz: generatedQuiz,
lastReadAt: DateTime.now(),
);
await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
bookHistory.value = updatedHistory;
syncCurrentQuiz();
EpicSnackBar.showSuccessSnackBar(
'Success',
'Quiz berhasil digenerate ulang.',
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'forceGenerateQuiz');
EpicSnackBar.showErrorSnackBar(
'Quiz',
'Terjadi kesalahan saat generate ulang quiz.',
);
} finally {
isLoading.value = false;
}
}
List<String> _extractPreviousQuestions(BookHistoryQuizEntity? quiz) {
if (quiz == null) return [];
final List<String> previous = [];
if (quiz.multipleChoiceQuestion?.question != null) {
previous.add(quiz.multipleChoiceQuestion!.question!);
}
if (quiz.fillBlankQuestion?.sentence != null) {
final sentenceText = quiz.fillBlankQuestion!.sentence!
.map((s) => s.type == 'blank' ? '___' : (s.value ?? ''))
.join();
if (sentenceText.trim().isNotEmpty) previous.add(sentenceText);
}
if (quiz.groupQuestion?.groups != null) {
final groupTitles = quiz.groupQuestion!.groups!
.map((g) => g.title ?? '')
.where((t) => t.isNotEmpty)
.join(' vs ');
if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles');
}
return previous;
}
Future<BookHistoryQuizEntity?> _tryGenerateQuizByQuizType({
required int quizType,
required String text,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
if (quizType == 1) {
final choiceQuiz = await _tryToGenerateMultipleChoiceQuiz(
fullText: text,
category: category,
previousQuestions: previousQuestions,
);
if (choiceQuiz != null) {
final remote = QuizChoiceMapper.entityToRemote(choiceQuiz);
return BookHistoryQuizEntity(
quizType: 1,
retry: 3,
question: jsonEncode(remote.toJson()),
answer: null,
explanation: choiceQuiz.explanation,
multipleChoiceQuestion: choiceQuiz,
multipleChoiceAnswer: null,
fillBlankQuestion: null,
fillBlankAnswer: null,
groupQuestion: null,
groupAnswer: null,
);
}
} else if (quizType == 2) {
final fillBlankQuiz = await _tryToGenerateFillBlankQuiz(
fullText: text,
category: category,
previousQuestions: previousQuestions,
);
if (fillBlankQuiz != null) {
final remote = QuizFillBlankMapper.entityToRemote(fillBlankQuiz);
return BookHistoryQuizEntity(
quizType: 2,
retry: 3,
question: jsonEncode(remote.toJson()),
answer: null,
explanation: fillBlankQuiz.explanation,
multipleChoiceQuestion: null,
multipleChoiceAnswer: null,
fillBlankQuestion: fillBlankQuiz,
fillBlankAnswer: null,
groupQuestion: null,
groupAnswer: null,
);
}
} else if (quizType == 3) {
final groupQuiz = await _tryToGenerateGroupQuiz(
fullText: text,
category: category,
previousQuestions: previousQuestions,
);
if (groupQuiz != null) {
final remote = QuizGroupMapper.entityToRemote(groupQuiz);
return BookHistoryQuizEntity(
quizType: 3,
retry: 3,
question: jsonEncode(remote.toJson()),
answer: null,
explanation: groupQuiz.explanation,
multipleChoiceQuestion: null,
multipleChoiceAnswer: null,
fillBlankQuestion: null,
fillBlankAnswer: null,
groupQuestion: groupQuiz,
groupAnswer: null,
);
}
}
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType');
}
return null;
}
Future<QuizChoiceEntity?> _tryToGenerateMultipleChoiceQuiz({
required String fullText,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
final quizJson = await quizUsecase.generate(
prompt: QuizPrompt.multipleChoicePrompt(
text: fullText,
category: category,
previousQuestions: previousQuestions,
),
);
if (quizJson == null) return null;
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
final quizModel = QuizChoiceRemoteModel.fromJson(parsed);
// === VALIDASI ===
if (quizModel.question == null ||
quizModel.choices == null ||
quizModel.choices!.length != 4 ||
quizModel.correctedIndex == null ||
quizModel.correctedIndex! < 0 ||
quizModel.correctedIndex! >= quizModel.choices!.length) {
EpicLog.debug(
'_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
return null;
}
final quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
return quizEntity.copyWith(
quizDataHelper: quizModel.toJson().toString(),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
}
return null;
}
Future<QuizFillBlankEntity?> _tryToGenerateFillBlankQuiz({
required String fullText,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
final quizJson = await quizUsecase.generate(
prompt: QuizPrompt.fillBlankPrompt(
text: fullText,
category: category,
previousQuestions: previousQuestions,
),
);
if (quizJson == null) return null;
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
final quizModel = QuizFillBlankRemoteModel.fromJson(parsed);
// Validasi fill blank:
if (quizModel.sentence == null ||
quizModel.options == null ||
quizModel.options!.length != 4 ||
quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
quizModel.options!
.any((o) => o.value == null || o.correctBlankId == null)) {
EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
return null;
}
final quizEntity = QuizFillBlankMapper.remoteToEntity(quizModel);
return quizEntity.copyWith(
quizDataHelper: quizModel.toJson().toString(),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryToGenerateFillBlankQuiz');
}
return null;
}
Future<QuizGroupEntity?> _tryToGenerateGroupQuiz({
required String fullText,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
final quizJson = await quizUsecase.generate(
prompt: QuizPrompt.groupQuizPrompt(
text: fullText,
category: category,
previousQuestions: previousQuestions,
),
);
if (quizJson == null) return null;
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
final quizModel = QuizGroupRemoteModel.fromJson(parsed);
// Validasi group quiz:
final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
if (quizModel.groups == null ||
quizModel.groups!.length != 2 ||
quizModel.options == null ||
quizModel.options!.length != 8 ||
quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
return null;
}
final quizEntity = QuizGroupMapper.remoteToEntity(quizModel);
return quizEntity.copyWith(
quizDataHelper: quizModel.toJson().toString(),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryToGenerateGroupQuiz');
}
return null;
}
Future<void> closePage() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
Get.back(result: bookHistory.value);
}
void openBookRewardClaim() async {
await Get.toNamed(
EpicRoutes.bookReward,
arguments: currentBook.value,
);
}
}

View File

@ -0,0 +1,600 @@
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/books/presentation/book_quiz/book_quiz_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
class BookQuizPage extends StatefulWidget {
const BookQuizPage({super.key});
@override
State<BookQuizPage> createState() => _BookQuizPageState();
}
class _BookQuizPageState extends State<BookQuizPage> {
final BookQuizController controller = Get.find<BookQuizController>();
void _handleBackTap() {
if (controller.isLoading.value) {
Get.snackbar(
'Quiz',
'Tunggu proses generate selesai.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
return;
}
controller.closePage();
}
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
]);
}
@override
void dispose() {
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
super.dispose();
}
@override
Widget build(BuildContext context) {
EpicAppSize.initialize(context);
var width = EpicAppSize.screenWidth;
var height = EpicAppSize.screenHeight;
return Scaffold(
backgroundColor: const Color(0xFFE0CEFF),
appBar: AppBar(
backgroundColor: const Color(0xFFE0CEFF),
elevation: 0,
automaticallyImplyLeading: false,
toolbarHeight: 78,
titleSpacing: 0,
title: Padding(
padding: const EdgeInsets.fromLTRB(14, 8, 14, 4),
child: Obx(
() => _BookQuizTopBar(
title: controller.title.value,
onBackTap: _handleBackTap,
),
),
),
),
body: WillPopScope(
onWillPop: () async {
_handleBackTap();
return false;
},
child: Obx(
() => Center(
child: Stack(
clipBehavior: Clip.none,
children: [
ShadowBox(
width: width * 0.9,
height: height * 0.75,
padding: const EdgeInsets.fromLTRB(18, 18, 18, 26),
cardColor: BoxColors.purplePrimary,
radius: BorderRadius.circular(
EpicAppSize.calculatedSize(24),
),
child: _buildQuizBody(),
),
if (controller.isQuizAvailable)
Positioned(
bottom: EpicAppSize.calculatedSize(-20),
left: 0,
right: 0,
child: Center(
child: _buildCheckButton(context),
),
),
if (controller.isQuizAvailable)
Positioned(
top: -20,
right: 16,
child: _buildOverlayControl(),
),
],
),
),
),
),
);
}
Widget _buildQuizBody() {
if (controller.isLoading.value) {
return const Center(
child: CircularProgressIndicator(
color: Colors.white,
),
);
}
if (!controller.isQuizAvailable) {
return const Center(
child: Text(
'Quiz belum tersedia untuk buku ini.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
);
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(16),
),
child: _buildQuizWidgetByType(),
),
],
),
);
}
Widget _buildQuizWidgetByType() {
if (controller.quizType == 1 && controller.multipleChoiceQuiz != null) {
return BookMultipleChoiceQuiz(
quizController: controller,
quizData: controller.multipleChoiceQuiz!,
);
}
if (controller.quizType == 2 && controller.fillBlankQuiz != null) {
return BookFillBlankQuiz(
quizController: controller,
quizData: controller.fillBlankQuiz!,
);
}
if (controller.quizType == 3 && controller.groupQuiz != null) {
return BookGroupQuiz(
quizController: controller,
quizData: controller.groupQuiz!,
);
}
return const SizedBox.shrink();
}
Widget _buildCheckButton(BuildContext context) {
if (controller.isLoading.value) {
return const SizedBox.shrink();
}
final isRewardUnclaimed =
controller.bookHistory.value?.claimedRewards == null ||
controller.bookHistory.value!.claimedRewards!.isEmpty;
if (controller.isAnswerCorrect.value) {
if (isRewardUnclaimed) {
return _PulsingRewardShadowButton(
onTap: controller.openBookRewardClaim,
width: MediaQuery.of(context).size.width * 0.65,
height: EpicAppSize.calculatedSize(50),
);
}
return ShadowBox(
cardColor: BoxColors.bluePrimary,
isCanClick: false,
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(
'Kuis Sudah Diselesaikan',
style: TextStyle(
color: BoxColors.bluePrimary.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
final isCanAnswer = (controller.retryQuestion.value > 0) &&
controller.isAnswerCorrect.value == false;
final bgColor =
isCanAnswer ? BoxColors.greenSecondary : BoxColors.grayDisabled;
return ShadowBox(
cardColor: bgColor,
isCanClick: isCanAnswer,
onTap: controller.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(
'Cek Jawaban',
style: TextStyle(
color: bgColor.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
Widget _buildOverlayControl() {
return Obx(() {
if (controller.isLoading.value) {
return const SizedBox();
}
final isCorrect = controller.isAnswerCorrect.value;
final explanation = controller.explanation;
if (isCorrect) {
return _buildHelpButton(explanation);
}
final retries = controller.retryQuestion.value;
if (retries <= 0) {
return Row(
children: [
_PulsingRetryButton(
onPressed: () {
controller.forceGenerateQuiz();
},
),
const SizedBox(width: 8),
_buildHelpButton(explanation),
],
);
}
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,
),
);
}),
),
);
});
}
Widget _buildHelpButton(String? explanation) {
return Material(
color: Colors.transparent,
child: Row(
children: [
// Visibility(
// visible: controller.bookHistory.value?.claimedRewards == null ||
// controller.bookHistory.value!.claimedRewards!.isEmpty,
// child: GestureDetector(
// onTap: () {
// controller.openBookRewardClaim();
// },
// child: Container(
// padding: const EdgeInsets.symmetric(
// horizontal: 10,
// vertical: 6,
// ),
// decoration: BoxDecoration(
// color: Colors.white,
// borderRadius: BorderRadius.circular(14),
// border: Border.all(
// color: BoxColors.purplePrimary.backgroundColor,
// width: 1.1,
// ),
// ),
// child: Text(
// 'Dapatkan Hadiah',
// style: TextStyle(
// color: BoxColors.purplePrimary.textColor,
// fontSize: 14,
// ),
// ),
// ),
// ),
// ),
SizedBox(width: 8),
InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => controller.showExplanationDialog(explanation),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Color(0xFFFFD700),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: BoxColors.purplePrimary.backgroundColor,
width: 1.2,
),
),
child: Icon(
Icons.help_outline,
size: 22,
color: Colors.black,
),
),
),
],
),
);
}
}
class _BookQuizTopBar extends StatelessWidget {
const _BookQuizTopBar({
required this.title,
required this.onBackTap,
});
final String title;
final VoidCallback onBackTap;
@override
Widget build(BuildContext context) {
return Row(
children: [
_TopBackButton(onTap: onBackTap),
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: const EdgeInsets.only(bottom: 8),
child: Text(
title.isEmpty ? 'Quiz' : title,
maxLines: 1,
textAlign: TextAlign.center,
softWrap: false,
style: const TextStyle(
color: Colors.white,
height: 1,
fontSize: 22,
fontWeight: FontWeight.w800,
),
),
),
),
),
),
),
],
),
),
),
const SizedBox(width: 10),
],
);
}
}
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 _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: Colors.white,
foregroundColor: BoxColors.purplePrimary.textColor,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
side: BorderSide(
color: BoxColors.purplePrimary.backgroundColor,
width: 1.1,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: widget.onPressed,
child: const Text(
'Coba Lagi',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
);
}
}
class _PulsingRewardShadowButton extends StatefulWidget {
const _PulsingRewardShadowButton({
required this.onTap,
required this.width,
required this.height,
});
final VoidCallback onTap;
final double width;
final double height;
@override
State<_PulsingRewardShadowButton> createState() =>
_PulsingRewardShadowButtonState();
}
class _PulsingRewardShadowButtonState extends State<_PulsingRewardShadowButton>
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.95, end: 1.05).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleTransition(
scale: _scaleAnimation,
child: ShadowBox(
cardColor: BoxColors.yellowPrimary,
isCanClick: true,
onTap: widget.onTap,
width: widget.width,
height: widget.height,
borderWidthRatio: EpicAppSize.borderWidthRatio,
radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
child: Center(
child: Text(
'Dapatkan Hadiah',
style: TextStyle(
color: BoxColors.yellowPrimary.textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}

View File

@ -0,0 +1,164 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
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({
super.key,
required this.data,
required this.child,
required this.feedback,
this.childWhenDragging,
this.color = const Color(0xFF6B42B8),
});
final T data;
final Widget child;
final Widget feedback;
final Widget? childWhenDragging;
final Color color;
@override
State<RayDraggableChip<T>> createState() => _RayDraggableChipState<T>();
}
class _RayDraggableChipState<T extends Object>
extends State<RayDraggableChip<T>> {
@override
Widget build(BuildContext context) {
return Draggable<T>(
data: widget.data,
feedback: widget.feedback,
childWhenDragging: widget.childWhenDragging,
child: widget.child,
);
}
}

View File

@ -0,0 +1,353 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class BookFillBlankQuiz extends StatelessWidget {
const BookFillBlankQuiz({
super.key,
required this.quizController,
required this.quizData,
});
final BookQuizController 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.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 BookQuizController 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 BookQuizController 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.fillBlankQuiz?.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.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 BookQuizController quizController;
final bool isLocked;
@override
State<_OptionsTray> createState() => _OptionsTrayState();
}
class _OptionsTrayState extends State<_OptionsTray> {
final ScrollController _scrollController = ScrollController();
late final RayBurstController _rayController;
List<QuizFillBlankOptionEntity> _unassigned() {
return widget.options.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();
}
@override
Widget build(BuildContext context) {
return Obx(() {
widget.quizController.assignedBlankByOption.length;
final pool = _unassigned();
return DragTarget<QuizFillBlankOptionEntity>(
onWillAccept: (_) => !widget.isLocked,
onAccept: (incoming) {
if (widget.isLocked) return;
final optId = incoming.id;
if (optId == null) return;
widget.quizController
.assignOptionToBlank(optionId: optId, blankId: null);
_rayController.fire();
},
builder: (context, candidate, rejected) {
final hovering = candidate.isNotEmpty;
return RayBurst(
controller: _rayController,
color: const Color(0xFF6B42B8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 120),
height: 96,
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: hovering ? const Color(0x0F6B42B8) : Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE0E0E0)),
),
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
trackVisibility: true,
radius: const Radius.circular(8),
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Row(
children: pool.map((opt) {
final chip = _Chip(label: opt.value ?? '-');
if (widget.isLocked) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: chip,
);
}
return Padding(
padding: const EdgeInsets.only(right: 10),
child: RayDraggableChip<QuizFillBlankOptionEntity>(
data: opt,
color: const Color(0xFF6B42B8),
feedback:
_Chip(label: opt.value ?? '-', isDragging: true),
childWhenDragging:
const SizedBox(width: 64, height: 32),
child: chip,
),
);
}).toList(),
),
),
),
),
);
},
);
});
}
}
class _Chip extends StatelessWidget {
const _Chip({
required this.label,
this.isDragging = false,
});
final String label;
final bool isDragging;
@override
Widget build(BuildContext context) {
return Material(
elevation: isDragging ? 6 : 0,
color: Colors.transparent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF7D7D7D), width: 1.2),
boxShadow: isDragging
? const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 10,
offset: Offset(0, 4),
)
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 6,
offset: Offset(0, 3),
)
],
),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
);
}
}

View File

@ -0,0 +1,481 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class BookGroupQuiz extends StatelessWidget {
const BookGroupQuiz({
super.key,
required this.quizController,
required this.quizData,
});
final BookQuizController 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.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.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 BookQuizController 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 BookQuizController 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 BookQuizController 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 BookQuizController quizController;
final bool isLocked;
@override
State<_UnassignedOptionsRow> createState() => _UnassignedOptionsRowState();
}
class _UnassignedOptionsRowState extends State<_UnassignedOptionsRow> {
final ScrollController _scrollController = ScrollController();
late final RayBurstController _rayController;
List<QuizGroupOptionEntity> _unassigned() {
return widget.options.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();
}
@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: 90,
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: items.map((option) {
final chip = _OptionChip(
label: option.value ?? '-',
isDragging: false,
);
if (widget.isLocked) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: chip,
);
}
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Draggable<QuizGroupOptionEntity>(
data: option,
feedback: _OptionChip(
label: option.value ?? '-',
isDragging: true,
),
childWhenDragging: const SizedBox.shrink(),
child: chip,
),
);
}).toList(),
),
),
),
),
);
},
);
});
}
}
class _OptionChip extends StatelessWidget {
const _OptionChip({
required this.label,
this.isCorrect,
this.isDragging = false,
});
final String label;
final bool? isCorrect;
final bool isDragging;
@override
Widget build(BuildContext context) {
Color border = const Color(0xFF6B6B6B);
Color bg = Colors.white;
if (isCorrect != null) {
border = isCorrect! ? Colors.green : Colors.red;
bg = isCorrect! ? const Color(0x102ECC71) : const Color(0x10F44336);
}
return Material(
elevation: isDragging ? 6 : 0,
color: Colors.transparent,
child: Container(
height: 60,
width: 105,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: border, width: 1.2),
boxShadow: isDragging
? const [
BoxShadow(
color: Color(0x22000000),
blurRadius: 10,
offset: Offset(0, 4),
)
]
: const [
BoxShadow(
color: Color(0x14000000),
blurRadius: 6,
offset: Offset(0, 3),
)
],
),
child: Center(
child: FittedBox(
child: Text(
label,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,152 @@
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class BookMultipleChoiceQuiz extends StatelessWidget {
const BookMultipleChoiceQuiz({
super.key,
required this.quizController,
required this.quizData,
});
final BookQuizController 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);
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Obx(() {
final isSelected = quizController.assignedChoice.value == index;
final isChecked = quizController.isAnswerChecked.value;
final retryLeft = quizController.retryQuestion.value;
final isCorrect = index == correctIndex;
final showCorrect = quizController.isAnswerCorrect.value
? (isChecked && isCorrect)
: (isChecked && isCorrect && retryLeft == 0);
final showWrong = isChecked && isSelected && !isCorrect;
final isLocked =
quizController.isAnswerCorrect.value || retryLeft == 0;
const borderColor = Color(0xFF000000);
Color fillColor = const Color(0xFFE0CEFF);
if (isSelected) {
fillColor = const Color(0xFF80A6FF);
}
if (showCorrect) {
fillColor = const Color(0xFF51F279);
} else if (showWrong) {
fillColor = const Color(0xFFFF0000);
}
final textColor =
showWrong ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
final bubbleColor =
showWrong ? const Color(0xFF000000) : const Color(0xFFFFFFFF);
final bubbleTextColor =
showWrong ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
return GestureDetector(
onTap: isLocked
? null
: () {
quizController.selectChoice(index);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: fillColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: borderColor,
width: 1.2,
),
boxShadow: const [
BoxShadow(
color: Color(0xFF3B1D73),
blurRadius: 0,
offset: Offset(0, 4),
),
],
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: bubbleColor,
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF000000),
width: 1.2,
),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: bubbleTextColor,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
choices[index],
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: textColor,
),
),
),
],
),
),
);
}),
);
}),
],
);
}
}

View File

@ -0,0 +1,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/books/presentation/book_read/book_read_controller.dart';
import 'package:get/get.dart';
class BookReadBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
QuizModule();
CollectionModule();
Get.lazyPut<BookReadController>(
() => BookReadController(
childrenUsecase: Get.find<ChildrenUsecase>(),
quizUsecase: Get.find<QuizUsecase>(),
collectionUsecase: Get.find<CollectionUsecase>(),
),
);
}
}

View File

@ -0,0 +1,973 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:epic_story_app/core/constants/quiz_prompt.dart';
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/data/models/mappers/quiz/quiz_choice_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/quiz_fill_blank_mapper.dart';
import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
import 'package:epic_story_app/data/models/remotes/quiz/quiz_choice_remote_model.dart';
import 'package:epic_story_app/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart';
import 'package:epic_story_app/data/models/remotes/quiz/quiz_group_remote_model.dart';
import 'package:epic_story_app/domain/entities/book_entity.dart';
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
import 'package:epic_story_app/domain/entities/book_history_quiz_entity.dart';
import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.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/others/main_controller/main_controller.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'package:flutter/services.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:path_provider/path_provider.dart';
import 'package:get/get.dart';
class BookReadController extends GetxController {
late BookEntity book;
final Rxn<BookHistoryEntity> bookHistory = Rxn<BookHistoryEntity>();
final collections = <ChildrenCollectionEntity>[].obs;
final RxInt currentIndex = 0.obs;
final RxInt totalPages = 0.obs;
final RxBool isLoading = false.obs;
final RxBool isPdfLoading = false.obs;
final RxnString pdfPath = RxnString();
final RxnString error = RxnString();
final Rxn<PDFViewController> pdfController = Rxn<PDFViewController>();
final RxBool isSoundOn = true.obs;
final Stopwatch _readingTimer = Stopwatch();
final FlutterTts _tts = FlutterTts();
final List<int> _visiblePdfPages =
<int>[]; // PDF page indexes that are allowed to show
final Map<int, int> _pdfToVisibleIndex =
<int, int>{}; // PDF index -> visible index
final Map<int, String> _pageTextByPdfIndex = <int, String>{};
int _pdfTotalPages = 0;
int _lastPdfPage = -1;
DateTime _sessionStartedAt = DateTime.now();
int _initialReadingSeconds = 0;
bool _isBookInitialized = false;
bool _isBootstrapping = false;
bool _isDisposing = false;
bool _isPersistingHistory = false;
bool _pendingPersistHistory = false;
int _pageChangeTicket = 0;
Timer? _persistDebounce;
var allTextTheory = ''.obs; // berisi gabungan semua teks dari kartu
static const Duration _historyTimeout = Duration(seconds: 8);
static const Duration _collectionRemoteTimeout = Duration(seconds: 10);
static const Duration _collectionMutationTimeout = Duration(seconds: 12);
static const Duration _quizGenerationTimeout = Duration(seconds: 14);
final navController = Get.find<EpicNavigationController>();
final mainController = Get.find<MainController>();
final ChildrenUsecase childrenUsecase;
final QuizUsecase quizUsecase;
final CollectionUsecase collectionUsecase;
BookReadController({
required this.childrenUsecase,
required this.quizUsecase,
required this.collectionUsecase,
});
@override
void onClose() {
_isDisposing = true;
_persistDebounce?.cancel();
_readingTimer.stop();
pdfController.value = null;
_visiblePdfPages.clear();
_pdfToVisibleIndex.clear();
_pageTextByPdfIndex.clear();
unawaited(_stopTts(force: true));
unawaited(pushHistoryToFirestore());
super.onClose();
}
@override
void onInit() {
super.onInit();
_sessionStartedAt = DateTime.now();
_readingTimer.start();
loadBook();
}
Future<void> loadBook() async {
if (_isBootstrapping) return;
_isBootstrapping = true;
try {
var arg = Get.arguments;
if (arg is BookEntity) {
book = arg;
_isBookInitialized = true;
} else {
throw ArgumentError('Expected BookEntity as argument');
}
isLoading.value = true;
error.value = null;
// Keep the screen responsive: PDF is loaded in background.
isPdfLoading.value = true;
unawaited(_loadPdf().whenComplete(() {
if (!isClosed) {
isPdfLoading.value = false;
}
}));
await Future.wait([
initBookHistory().timeout(
_historyTimeout,
onTimeout: () {
EpicLog.warn(
'initBookHistory timeout, continue with default state');
},
),
_loadCollectionsFromLocal(),
]);
// Refresh collections from remote in background; UI should not block for this.
unawaited(_refreshCollectionsFromRemote());
_buildAllTextTheory();
// Quiz generation is optional; run in background with timeout.
unawaited(_generateQuizSafely());
_initTts();
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'loadBook');
if (!isClosed && error.value == null) {
error.value = 'Gagal memuat buku';
}
} finally {
if (!isClosed) {
isLoading.value = false;
}
_isBootstrapping = false;
}
}
Future<void> _loadCollectionsFromLocal() async {
try {
final children = await childrenUsecase
.getCurrentChildren()
.timeout(const Duration(seconds: 3));
if (!isClosed) {
collections.assignAll(children?.collections ?? []);
}
} on TimeoutException {
EpicLog.warn('_loadCollectionsFromLocal timeout');
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_loadCollectionsFromLocal');
}
}
Future<void> _refreshCollectionsFromRemote() async {
try {
final children = await mainController.childrenUsecase
.getChildrenRemoteModel()
.timeout(_collectionRemoteTimeout);
if (!isClosed) {
collections.assignAll(children?.collections ?? []);
}
} on TimeoutException {
EpicLog.warn('_refreshCollectionsFromRemote timeout');
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_refreshCollectionsFromRemote');
}
}
Future<void> _generateQuizSafely() async {
try {
await generateQuiz().timeout(
_quizGenerationTimeout,
onTimeout: () {
EpicLog.warn('generateQuiz timeout, skipped for this session');
},
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_generateQuizSafely');
}
}
Future<void> initBookHistory() async {
try {
final bookId = book.bookId;
if (bookId == null || bookId.isEmpty) return;
final existing = await childrenUsecase.getBookHistoryById(bookId);
if (existing != null) {
bookHistory.value = existing;
_initialReadingSeconds = existing.totalReadingSeconds ?? 0;
}
await _persistBookHistory();
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_initBookHistory');
}
}
void setInitialSound(bool value) {
isSoundOn.value = value;
}
Future<void> onPageChanged(int index) async {
if (_isDisposing || isClosed) return;
final ticket = ++_pageChangeTicket;
await _stopTts();
if (_isDisposing || isClosed || ticket != _pageChangeTicket) return;
if (_visiblePdfPages.isEmpty && _pdfTotalPages > 0) {
_rebuildVisiblePages(_pdfTotalPages);
}
final visibleIndex = _pdfToVisibleIndex[index];
if (visibleIndex != null) {
_lastPdfPage = index;
currentIndex.value = visibleIndex;
_debouncedPersistHistory();
if (_isDisposing || isClosed || ticket != _pageChangeTicket) return;
await _speakCurrentVisibleText();
return;
}
final direction = index >= _lastPdfPage ? 1 : -1;
final target = _nextVisiblePdf(index, direction);
if (target != null && target != index) {
_lastPdfPage = target;
pdfController.value?.setPage(target);
return;
}
}
void _debouncedPersistHistory() {
_persistDebounce?.cancel();
_persistDebounce = Timer(const Duration(seconds: 2), () {
if (!_isDisposing && !isClosed) {
_persistBookHistory();
}
});
}
void setTotalPages(int total) {
if (_isDisposing || isClosed) return;
final sanitizedTotal = total.clamp(0, 10000).toInt();
if (_pdfTotalPages == sanitizedTotal && _visiblePdfPages.isNotEmpty) {
return;
}
_pdfTotalPages = sanitizedTotal;
_rebuildVisiblePages(_pdfTotalPages);
}
void toggleSound() {
isSoundOn.toggle();
if (isSoundOn.value) {
_speakCurrentVisibleText();
} else {
_stopTts();
}
}
Future<void> goTo(int index) async {
final controller = pdfController.value;
if (controller == null) return;
if (_visiblePdfPages.isEmpty) return;
if (index < 0 || index >= _visiblePdfPages.length) return;
await _stopTts();
final pdfPage = _visiblePdfPages[index];
await controller.setPage(pdfPage);
}
void onPdfViewCreated(PDFViewController controller) {
if (_isDisposing || isClosed) return;
pdfController.value = controller;
}
Future<void> _loadPdf() async {
if (isClosed) return;
error.value = null;
pdfPath.value = null;
currentIndex.value = 0;
try {
final url = book.bookAsset;
if (url == null || url.isEmpty) {
error.value = 'Book asset tidak tersedia';
return;
}
// Check cache first avoid re-downloading if PDF already on disk.
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/book_${book.bookId ?? 'temp'}.pdf');
if (await file.exists()) {
final fileSize = await file.length();
if (fileSize > 0) {
if (!isClosed) {
pdfPath.value = file.path;
}
return;
}
}
final uri = Uri.parse(url);
final client = HttpClient()
..connectionTimeout = const Duration(seconds: 20);
try {
final request = await client.getUrl(uri);
final response = await request.close().timeout(
const Duration(seconds: 30),
);
if (response.statusCode != HttpStatus.ok) {
error.value = 'Gagal mengunduh PDF (${response.statusCode})';
return;
}
final bytes =
await consolidateHttpClientResponseBytes(response).timeout(
const Duration(seconds: 40),
);
await file.writeAsBytes(bytes, flush: true);
if (!isClosed) {
pdfPath.value = file.path;
}
} finally {
client.close(force: true);
}
} on TimeoutException {
if (!isClosed) {
error.value = 'Waktu memuat PDF habis. Coba buka ulang buku.';
}
} catch (_) {
if (!isClosed) {
error.value = 'Gagal memuat PDF';
}
}
}
void _rebuildVisiblePages(int pdfTotal) {
if (_isDisposing || isClosed) return;
_visiblePdfPages.clear();
_pdfToVisibleIndex.clear();
_pageTextByPdfIndex.clear();
if (pdfTotal <= 0) {
totalPages.value = 0;
currentIndex.value = 0;
return;
}
final skipped = <int>{};
final pages = book.pages;
if (pages != null && pages.isNotEmpty) {
for (var i = 0; i < pages.length; i++) {
final page = pages[i];
if (page.skipped == true) {
final rawIndex =
page.nPage != null && page.nPage! > 0 ? page.nPage! - 1 : i;
if (rawIndex >= 0 && rawIndex < pdfTotal) {
skipped.add(rawIndex);
}
}
final text = page.text?.trim();
if (text != null && text.isNotEmpty) {
final rawIndex =
page.nPage != null && page.nPage! > 0 ? page.nPage! - 1 : i;
if (rawIndex >= 0 && rawIndex < pdfTotal) {
_pageTextByPdfIndex[rawIndex] = text;
}
}
}
}
for (var i = 0; i < pdfTotal; i++) {
if (!skipped.contains(i)) {
_visiblePdfPages.add(i);
}
}
// Fallback: if all pages were marked skipped, show all pages to avoid empty viewer.
if (_visiblePdfPages.isEmpty) {
for (var i = 0; i < pdfTotal; i++) {
_visiblePdfPages.add(i);
}
}
for (var i = 0; i < _visiblePdfPages.length; i++) {
_pdfToVisibleIndex[_visiblePdfPages[i]] = i;
}
totalPages.value = _visiblePdfPages.length;
if (currentIndex.value >= totalPages.value) {
currentIndex.value = totalPages.value > 0 ? totalPages.value - 1 : 0;
}
if (_visiblePdfPages.isNotEmpty) {
final firstVisible = _visiblePdfPages.first;
if (_lastPdfPage == -1 || !_pdfToVisibleIndex.containsKey(_lastPdfPage)) {
_lastPdfPage = firstVisible;
currentIndex.value = 0;
pdfController.value?.setPage(firstVisible);
}
}
_speakCurrentVisibleText();
}
int? _nextVisiblePdf(int currentPdf, int direction) {
if (_visiblePdfPages.isEmpty) return null;
if (direction >= 0) {
for (final page in _visiblePdfPages) {
if (page > currentPdf) return page;
}
for (var i = _visiblePdfPages.length - 1; i >= 0; i--) {
final page = _visiblePdfPages[i];
if (page < currentPdf) return page;
}
} else {
for (var i = _visiblePdfPages.length - 1; i >= 0; i--) {
final page = _visiblePdfPages[i];
if (page < currentPdf) return page;
}
for (final page in _visiblePdfPages) {
if (page > currentPdf) return page;
}
}
return null;
}
void _initTts() {
_tts.setLanguage('id-ID');
_tts.setPitch(1.0);
_tts.setSpeechRate(0.5);
}
Future<void> _speakCurrentVisibleText() async {
if (_isDisposing || isClosed) return;
if (!isSoundOn.value) {
await _stopTts();
return;
}
if (_visiblePdfPages.isEmpty) {
await _stopTts();
return;
}
final visibleIdx = currentIndex.value;
if (visibleIdx < 0 || visibleIdx >= _visiblePdfPages.length) {
await _stopTts();
return;
}
final pdfIdx = _visiblePdfPages[visibleIdx];
final text = _pageTextByPdfIndex[pdfIdx];
if (text == null || text.trim().isEmpty) {
await _stopTts();
return;
}
await _tts.stop();
await _tts.speak(text);
}
Future<void> _stopTts({bool force = false}) async {
if (!force && (_isDisposing || isClosed)) return;
await _tts.stop();
}
Future<void> _persistBookHistory() async {
if (!_isBookInitialized || _isDisposing || isClosed) return;
if (_isPersistingHistory) {
_pendingPersistHistory = true;
return;
}
_isPersistingHistory = true;
try {
do {
_pendingPersistHistory = false;
if (_isDisposing || isClosed) return;
final now = DateTime.now();
_readingTimer.stop();
final existing = bookHistory.value;
final total =
totalPages.value > 0 ? totalPages.value : (book.totalPages ?? 0);
int read = currentIndex.value + 1;
if (total > 0 && read > total) {
read = total;
}
late final BookHistoryEntity history;
if (existing != null) {
// UPDATE: preserve existing values that should not regress.
final mergedRead = (existing.readPages ?? 0) > read
? (existing.readPages ?? 0)
: read;
final readingSeconds =
_initialReadingSeconds + _readingTimer.elapsed.inSeconds;
history = existing.copyWith(
lastReadAt: now,
readPages: mergedRead,
totalReadingSeconds: readingSeconds,
);
} else {
history = BookHistoryEntity(
bookId: book.bookId,
title: book.title,
bookCover: book.bookCover,
totalPages: total == 0 ? book.totalPages : total,
readPages: read,
isFinished: false,
startedAt: _sessionStartedAt,
lastReadAt: now,
finishedAt: null,
totalReadingSeconds: _readingTimer.elapsed.inSeconds,
isHaveQuiz: book.isHaveQuiz,
totalQuizes: book.totalQuizes,
completedQuizes: null,
category: book.category,
);
}
await childrenUsecase.updateBookHistory(bookHistory: history);
if (_isDisposing || isClosed) return;
bookHistory.value = history;
} while (_pendingPersistHistory && !_isDisposing && !isClosed);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_persistBookHistory');
} finally {
_isPersistingHistory = false;
if (!_readingTimer.isRunning && !_isDisposing && !isClosed) {
_readingTimer.start();
}
}
}
Future<void> pushHistoryToFirestore() async {
if (!_isBookInitialized) return;
try {
var history = bookHistory.value;
if (history != null) {
await childrenUsecase.updateBookHistory(
bookHistory: history,
pushToRemote: true,
);
// navController.refreshRewardBook.value++;
EpicLog.debug(
'pushHistoryToFirestore - Book history pushed to Firestore for bookId ${book.bookId}');
bookHistory.value = history;
if (history.isFinished == true &&
history.claimedRewards != null &&
history.claimedRewards!.isEmpty) {
// navController.refreshRewardBook.value++;
}
} else {
EpicLog.debug(
'pushHistoryToFirestore - No book history to push for bookId ${book.bookId}');
}
} catch (ex, s) {
EpicLog.exception(ex, s,
'pushHistoryToFirestore - Error pushing book history to Firestore');
}
}
Future<void> addBookToCollection({
required String collectionId,
required String bookId,
}) async {
try {
Get.back();
mainController.showLoadingPage();
await collectionUsecase
.addBookToCollection(
collectionId: collectionId,
bookId: bookId,
)
.timeout(_collectionMutationTimeout);
Get.back<void>();
navController.refreshCollectionHome.value++;
Get.snackbar(
'Berhasil',
'Buku ditambahkan ke koleksi.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
} on TimeoutException {
Get.snackbar(
'Timeout',
'Proses tambah koleksi terlalu lama. Coba lagi.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'addBookToCollection');
Get.snackbar(
'Gagal',
'Tidak bisa menambah buku ke koleksi.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
} finally {
mainController.hideLoadingPage();
}
}
void _buildAllTextTheory() {
final texts = book.pages
?.where((p) => p.skipped != true)
.map((p) => p.text?.trim())
.where((t) => t != null && t.isNotEmpty)
.cast<String>()
.toList();
allTextTheory.value =
texts != null && texts.isNotEmpty ? texts.join('\n') : '';
}
Future<void> generateQuiz({
bool forceGenerate = false,
}) async {
try {
if (book.isGenerateQuizSupported != true || book.isHaveQuiz != true) {
EpicLog.debug('generateQuiz - skipped, quiz is not supported by book');
return;
}
final quizType = book.quizType ?? 0;
if (quizType < 1 || quizType > 3) {
EpicLog.debug('generateQuiz - skipped, invalid quizType: $quizType');
return;
}
final text = allTextTheory.value.trim();
if (text.isEmpty) {
EpicLog.debug('generateQuiz - skipped, theory text is empty');
return;
}
if (bookHistory.value == null) {
await _persistBookHistory();
}
var history = bookHistory.value;
if (history == null) {
EpicLog.debug('generateQuiz - failed generate, book history is null');
return;
}
var quizHistory = history.quiz;
var isQuizAlreadyGenerated =
(quizHistory?.question?.trim().isNotEmpty ?? false);
var isNotCompleted = (quizHistory?.answer?.trim().isEmpty ?? true);
var totalRetry = quizHistory?.retry ?? 3;
var firstAnswer = totalRetry == 3;
var isCanGenerate = quizHistory == null ||
(isNotCompleted && firstAnswer && !isQuizAlreadyGenerated);
if (isCanGenerate || forceGenerate) {
EpicLog.debug('generateQuiz - ready for generate');
final previousQuestions = _extractPreviousQuestions(quizHistory);
final generatedQuiz = await _tryGenerateQuizByQuizType(
quizType: quizType,
text: text,
category: book.category,
previousQuestions: previousQuestions,
);
if (generatedQuiz == null) {
EpicLog.debug('generateQuiz - failed to generate quiz data');
return;
}
final updatedHistory = history.copyWith(quiz: generatedQuiz);
await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
bookHistory.value = updatedHistory;
EpicLog.debug('generateQuiz - quiz generated successfully');
} else {
EpicLog.debug('generateQuiz - rejected for generate');
}
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'generateQuiz');
}
}
List<String> _extractPreviousQuestions(BookHistoryQuizEntity? quiz) {
if (quiz == null) return [];
final List<String> previous = [];
if (quiz.multipleChoiceQuestion?.question != null) {
previous.add(quiz.multipleChoiceQuestion!.question!);
}
if (quiz.fillBlankQuestion?.sentence != null) {
final sentenceText = quiz.fillBlankQuestion!.sentence!
.map((s) => s.type == 'blank' ? '___' : (s.value ?? ''))
.join();
if (sentenceText.trim().isNotEmpty) previous.add(sentenceText);
}
if (quiz.groupQuestion?.groups != null) {
final groupTitles = quiz.groupQuestion!.groups!
.map((g) => g.title ?? '')
.where((t) => t.isNotEmpty)
.join(' vs ');
if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles');
}
return previous;
}
Future<BookHistoryQuizEntity?> _tryGenerateQuizByQuizType({
required int quizType,
required String text,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
if (quizType == 1) {
final choiceQuiz = await _tryToGenerateMultipleChoiceQuiz(
fullText: text,
category: category,
previousQuestions: previousQuestions,
);
if (choiceQuiz != null) {
final remote = QuizChoiceMapper.entityToRemote(choiceQuiz);
return BookHistoryQuizEntity(
quizType: 1,
retry: 3,
question: jsonEncode(remote.toJson()),
answer: null,
explanation: choiceQuiz.explanation,
multipleChoiceQuestion: choiceQuiz,
multipleChoiceAnswer: null,
fillBlankQuestion: null,
fillBlankAnswer: null,
groupQuestion: null,
groupAnswer: null,
);
}
} else if (quizType == 2) {
final fillBlankQuiz = await _tryToGenerateFillBlankQuiz(
fullText: text,
category: category,
previousQuestions: previousQuestions,
);
if (fillBlankQuiz != null) {
final remote = QuizFillBlankMapper.entityToRemote(fillBlankQuiz);
return BookHistoryQuizEntity(
quizType: 2,
retry: 3,
question: jsonEncode(remote.toJson()),
answer: null,
explanation: fillBlankQuiz.explanation,
multipleChoiceQuestion: null,
multipleChoiceAnswer: null,
fillBlankQuestion: fillBlankQuiz,
fillBlankAnswer: null,
groupQuestion: null,
groupAnswer: null,
);
}
} else if (quizType == 3) {
final groupQuiz = await _tryToGenerateGroupQuiz(
fullText: text,
category: category,
previousQuestions: previousQuestions,
);
if (groupQuiz != null) {
final remote = QuizGroupMapper.entityToRemote(groupQuiz);
return BookHistoryQuizEntity(
quizType: 3,
retry: 3,
question: jsonEncode(remote.toJson()),
answer: null,
explanation: groupQuiz.explanation,
multipleChoiceQuestion: null,
multipleChoiceAnswer: null,
fillBlankQuestion: null,
fillBlankAnswer: null,
groupQuestion: groupQuiz,
groupAnswer: null,
);
}
}
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType');
}
return null;
}
Future<QuizChoiceEntity?> _tryToGenerateMultipleChoiceQuiz({
required String fullText,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
final quizJson = await quizUsecase.generate(
prompt: QuizPrompt.multipleChoicePrompt(
text: fullText,
category: category,
previousQuestions: previousQuestions,
),
);
if (quizJson == null) {
EpicLog.debug('_tryToGenerateMultipleChoiceQuiz - quizJson is null');
return null;
}
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
final quizModel = QuizChoiceRemoteModel.fromJson(parsed);
// === VALIDASI ===
if (quizModel.question == null ||
quizModel.choices == null ||
quizModel.choices!.length != 4 ||
quizModel.correctedIndex == null ||
quizModel.correctedIndex! < 0 ||
quizModel.correctedIndex! >= quizModel.choices!.length) {
EpicLog.debug(
'_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
return null;
}
final quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
EpicLog.debug('generateQuiz - quizJson: $quizJson');
return quizEntity.copyWith(
quizDataHelper: quizModel.toJson().toString(),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
}
return null;
}
Future<QuizFillBlankEntity?> _tryToGenerateFillBlankQuiz({
required String fullText,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
final quizJson = await quizUsecase.generate(
prompt: QuizPrompt.fillBlankPrompt(
text: fullText,
category: category,
previousQuestions: previousQuestions,
),
);
if (quizJson == null) {
EpicLog.debug('_tryToGenerateFillBlankQuiz - quizJson is null');
return null;
}
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
final quizModel = QuizFillBlankRemoteModel.fromJson(parsed);
// === VALIDASI ===
if (quizModel.sentence == null ||
quizModel.options == null ||
quizModel.options!.length != 4 ||
quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
quizModel.options!
.any((o) => o.value == null || o.correctBlankId == null)) {
EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
return null;
}
final quizEntity = QuizFillBlankMapper.remoteToEntity(quizModel);
EpicLog.debug('generateQuiz - quizJson: $quizJson');
return quizEntity.copyWith(
quizDataHelper: quizModel.toJson().toString(),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryToGenerateFillBlankQuiz');
}
return null;
}
Future<QuizGroupEntity?> _tryToGenerateGroupQuiz({
required String fullText,
String? category,
List<String> previousQuestions = const [],
}) async {
try {
final quizJson = await quizUsecase.generate(
prompt: QuizPrompt.groupQuizPrompt(
text: fullText,
category: category,
previousQuestions: previousQuestions,
),
);
if (quizJson == null) {
EpicLog.debug('_tryToGenerateGroupQuiz - quizJson is null');
return null;
}
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
final quizModel = QuizGroupRemoteModel.fromJson(parsed);
// Validasi group quiz:
final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
if (quizModel.groups == null ||
quizModel.groups!.length != 2 ||
quizModel.options == null ||
quizModel.options!.length != 8 ||
quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
return null;
}
final quizEntity = QuizGroupMapper.remoteToEntity(quizModel);
EpicLog.debug('generateQuiz - quizJson: $quizJson');
return quizEntity.copyWith(
quizDataHelper: quizModel.toJson().toString(),
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, '_tryToGenerateGroupQuiz');
}
return null;
}
Future<void> goToQuizPage() async {
final currentHistory = bookHistory.value;
if (currentHistory?.quiz == null) return;
await _stopTts(force: true);
try {
final result = await Get.toNamed(
EpicRoutes.bookQuiz,
arguments: {
'history': currentHistory,
'theoryText': allTextTheory.value,
'bookQuizType': book.quizType,
'book': book,
},
);
if (result is BookHistoryEntity) {
bookHistory.value = result;
}
} finally {
await _forceLandscapeOrientation();
}
}
Future<void> _forceLandscapeOrientation() async {
const landscape = [
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
];
await SystemChrome.setPreferredOrientations(landscape);
}
}

View File

@ -0,0 +1,340 @@
import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_read/components/dialog_add_book_coll.dart';
import 'package:epic_story_app/feature/books/presentation/book_read/components/dot_indicator.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'package:get/get.dart';
/// PDF reader page driven by controller; no widget parameters (clean architecture).
class BookReadPage extends StatefulWidget {
const BookReadPage({super.key});
@override
State<BookReadPage> createState() => _BookReadPageState();
}
class _BookReadPageState extends State<BookReadPage> {
final BookReadController controller = Get.find<BookReadController>();
static const Color _mainBackground = Color(0xFFE0CEFF);
static const double _topBarHeight = 84;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations(
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight],
);
}
@override
void dispose() {
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _mainBackground,
body: SafeArea(
child: Stack(
children: [
// PDF content area
Positioned.fill(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
}
final path = controller.pdfPath.value;
if (path == null) {
if (controller.isPdfLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error.value != null) {
return Center(
child: Text(
controller.error.value!,
style: const TextStyle(color: Colors.black87),
),
);
}
return const Center(
child: Text(
'PDF tidak tersedia',
style: TextStyle(color: Colors.black87),
),
);
}
return _PdfContent(
key: ValueKey(path),
path: path,
controller: controller,
);
}),
),
// Top bar: back + actions
Positioned(
top: 0,
left: 0,
right: 0,
child: Obx(() {
final bool isBookVisible = !controller.isLoading.value &&
!controller.isPdfLoading.value &&
controller.error.value == null &&
controller.pdfPath.value != null;
return Container(
height: _topBarHeight,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
decoration: BoxDecoration(
gradient: isBookVisible
? const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.transparent,
],
)
: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF7E32AB),
Color(0xFFE0CEFF),
],
stops: [0.0, 1.0],
),
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(18),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
onTap: () => Get.back<void>(),
borderRadius: BorderRadius.circular(20),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.42),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: const Icon(
Icons.reply_rounded,
size: 46,
color: Colors.white,
),
),
),
Row(
children: [
_buildTopActionButton(
icon: Icons.bookmark,
backgroundColor: const Color(0xFFFFED8C),
iconColor: const Color(0xFF8D8D8D),
onTap: () {
if (controller.isLoading.value) {
Get.snackbar(
'Collection',
'Tunggu sampai buku selesai dimuat.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
return;
}
showAddBookCollectionSheet(
context,
controller,
controller.book,
);
},
),
const SizedBox(width: 10),
_buildTopActionButton(
icon: controller.isSoundOn.value
? Icons.volume_up_rounded
: Icons.volume_off_rounded,
backgroundColor: const Color(0xFF999999),
iconColor: controller.isSoundOn.value
? const Color(0xFFF3D86A)
: const Color(0xFFE4E4E4),
onTap: controller.toggleSound,
),
],
),
],
),
);
}),
),
// Bottom controls: prev, indicator, next
Positioned(
left: 16,
right: 16,
bottom: 24,
child: Obx(
() {
final idx = controller.currentIndex.value;
final length = controller.totalPages.value;
final canGoPrev = idx > 0;
final bool isLastPage = length > 0 && idx >= length - 1;
final bool hasQuiz =
controller.bookHistory.value?.quiz != null;
final bool canGoNextPage = length > 0 && !isLastPage;
final bool allowQuizNav = isLastPage;
final VoidCallback? nextAction = canGoNextPage
? () => controller.goTo(idx + 1)
: allowQuizNav
? () {
if (hasQuiz) {
controller.goToQuizPage();
} else {
Get.snackbar(
'Quiz',
'Quiz belum tersedia.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
}
}
: null;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildNavArrowButton(
icon: Icons.play_arrow_rounded,
onTap:
canGoPrev ? () => controller.goTo(idx - 1) : null,
isPrevious: true,
),
DotsIndicator(
length: length,
index: idx,
activeColor: const Color(0xFFD9D9D9),
inactiveColor: const Color(0xFFD9D9D9),
activeBorderColor: const Color(0xFF2A2A2A),
),
_buildNavArrowButton(
icon: Icons.play_arrow_rounded,
onTap: nextAction,
isPrevious: false,
),
],
);
},
),
),
],
),
),
);
}
Widget _buildTopActionButton({
required IconData icon,
required Color backgroundColor,
required Color iconColor,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Ink(
width: 52,
height: 52,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.black.withOpacity(0.5), width: 1),
),
child: Icon(icon, size: 32, color: iconColor),
),
),
);
}
Widget _buildNavArrowButton({
required IconData icon,
required VoidCallback? onTap,
required bool isPrevious,
}) {
final disabled = onTap == null;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Ink(
width: 62,
height: 62,
decoration: BoxDecoration(
color: disabled ? const Color(0xFF9CC8F2) : const Color(0xFF66B0FF),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF2A2A2A), width: 1),
),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateZ(isPrevious ? 3.14159 : 0),
child: Icon(
icon,
color:
disabled ? const Color(0xFF2F2F2F) : const Color(0xFF161616),
size: 36,
),
),
),
),
);
}
}
/// Extracted PDF widget to avoid rebuild when parent Obx re-evaluates.
class _PdfContent extends StatelessWidget {
const _PdfContent({
super.key,
required this.path,
required this.controller,
});
final String path;
final BookReadController controller;
@override
Widget build(BuildContext context) {
return ClipRect(
child: SizedBox.expand(
child: PDFView(
filePath: path,
enableSwipe: true,
swipeHorizontal: true,
autoSpacing: true,
pageFling: true,
pageSnap: true,
fitPolicy: FitPolicy.HEIGHT,
preventLinkNavigation: true,
onViewCreated: controller.onPdfViewCreated,
onRender: (pages) =>
controller.setTotalPages((pages ?? 0).clamp(0, 10000)),
onPageChanged: (page, total) {
if (page != null) controller.onPageChanged(page);
if (total != null) controller.setTotalPages(total);
},
),
),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class CircleButton extends StatelessWidget {
const CircleButton({
super.key,
required this.icon,
this.onTap,
this.disabled = false,
});
final IconData icon;
final VoidCallback? onTap;
final bool disabled;
@override
Widget build(BuildContext context) {
final color = disabled ? Colors.grey.shade300 : Colors.white;
final iconColor = disabled ? Colors.grey : Colors.black87;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: disabled ? null : onTap,
customBorder: const CircleBorder(),
child: Ink(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Icon(icon, color: iconColor, size: 22),
),
),
);
}
}

View File

@ -0,0 +1,270 @@
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/book_entity.dart';
import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
import 'package:epic_story_app/feature/books/presentation/book_read/book_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/book-bhs-indo.png',
'assets/images/book-matematika.png',
'assets/images/tipe-book.png',
];
void showAddBookCollectionSheet(
BuildContext context,
BookReadController controller,
BookEntity book,
) {
Get.bottomSheet(
SafeArea(
top: false,
child: Obx(
() {
final items = controller.collections;
final media = MediaQuery.of(context);
final isLandscape = media.orientation == Orientation.landscape;
final sheetMaxWidth = isLandscape
? (media.size.width * 0.52).clamp(300.0, 430.0).toDouble()
: media.size.width;
final listMaxHeight = media.size.height * (isLandscape ? 0.34 : 0.50);
return Align(
alignment: Alignment.bottomCenter,
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: sheetMaxWidth),
child: Container(
decoration: const BoxDecoration(
color: Color(0xFFC6B2E6),
borderRadius: BorderRadius.vertical(
top: Radius.circular(32),
),
),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Collection',
style: EpicSize.titleLarge.copyWith(
fontSize: 22,
fontWeight: FontWeight.w800,
color: const Color(0xFF111111),
),
),
const SizedBox(height: 8),
if (items.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Text(
'Belum ada koleksi buku.',
style: EpicSize.bodyMedium.copyWith(
fontSize: 14,
color: const Color(0xFF333333),
),
),
)
else
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: listMaxHeight,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: items.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 8),
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];
return _BookCollectionSheetTile(
item: item,
thumbnailAsset: thumbAsset,
gradient: gradient,
onAdd: () {
controller.addBookToCollection(
collectionId: item.id,
bookId: book.bookId ?? '',
);
},
);
},
),
),
],
),
),
),
);
},
),
),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
}
class _BookCollectionSheetTile extends StatelessWidget {
const _BookCollectionSheetTile({
required this.item,
required this.thumbnailAsset,
required this.gradient,
required this.onAdd,
});
final ChildrenCollectionEntity item;
final String thumbnailAsset;
final LinearGradient gradient;
final VoidCallback onAdd;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0x99FFFFFF), width: 1.1),
boxShadow: [
BoxShadow(
color: const Color(0x22000000),
blurRadius: 6,
offset: const Offset(0, 1.5),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_BookThumbnail(
coverUrl: item.bookCovers?.isNotEmpty == true
? item.bookCovers!.first
: null,
fallbackAssetPath: thumbnailAsset,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: EpicSize.titleMedium.copyWith(
fontSize: 15,
fontWeight: FontWeight.w900,
color: const Color(0xFF111111),
),
),
const SizedBox(height: 4),
_chip(
icon: Icons.menu_book_rounded,
text: 'Total Buku : ${item.nBooks ?? 0}',
),
],
),
),
const SizedBox(width: 6),
GestureDetector(
onTap: onAdd,
child: Container(
width: 34,
height: 34,
decoration: const BoxDecoration(
color: Color.fromARGB(255, 47, 85, 255),
shape: BoxShape.circle,
),
child: const Icon(
Icons.add_rounded,
size: 22,
color: Colors.white,
),
),
),
],
),
);
}
Widget _chip({required String text, IconData? icon}) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.55),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: const Color(0xFF111111), width: 0.9),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(
icon,
size: 12,
color: const Color(0xFF111111),
),
const SizedBox(width: 4),
],
Text(
text,
style: EpicSize.bodySmall.copyWith(
fontSize: 11,
fontWeight: FontWeight.w600,
color: const Color(0xFF111111),
),
),
],
),
);
}
}
class _BookThumbnail extends StatelessWidget {
const _BookThumbnail({
required this.coverUrl,
required this.fallbackAssetPath,
});
final String? coverUrl;
final String fallbackAssetPath;
@override
Widget build(BuildContext context) {
return Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: const Color(0xFFFFFFFF),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0x44FFFFFF), width: 1.1),
),
clipBehavior: Clip.antiAlias,
child: (coverUrl != null && coverUrl!.trim().isNotEmpty)
? Image.network(
coverUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Image.asset(
fallbackAssetPath,
fit: BoxFit.cover,
),
)
: Image.asset(
fallbackAssetPath,
fit: BoxFit.cover,
),
);
}
}

View File

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
class DotsIndicator extends StatelessWidget {
const DotsIndicator({
super.key,
required this.length,
required this.index,
this.activeColor = const Color(0xFF1F1F1F),
this.inactiveColor = const Color(0xFFD9D9D9),
this.activeBorderColor,
});
final int length;
final int index;
final Color activeColor;
final Color inactiveColor;
final Color? activeBorderColor;
@override
Widget build(BuildContext context) {
if (length == 0) return const SizedBox.shrink();
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(length, (i) {
final isActive = i == index;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: isActive ? 12 : 8,
height: isActive ? 12 : 8,
decoration: BoxDecoration(
color: isActive ? activeColor : inactiveColor,
shape: BoxShape.circle,
border: isActive && activeBorderColor != null
? Border.all(color: activeBorderColor!, width: 1)
: null,
boxShadow: isActive
? [
BoxShadow(
color: Colors.black.withOpacity(0.12),
blurRadius: 8,
)
]
: null,
),
);
}),
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:get/get.dart';
import '../../../../data/modules/children_module.dart';
import '../../../../domain/usecases/children_usecase.dart';
import 'book_reward_controller.dart';
class BookRewardBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
Get.lazyPut<BookRewardController>(
() => BookRewardController(
childrenUsecase: Get.find<ChildrenUsecase>(),
),
);
}
}

View File

@ -0,0 +1,124 @@
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/book_entity.dart';
import 'package:epic_story_app/domain/entities/book_histories_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 BookRewardController extends GetxController {
BookRewardController({
required this.childrenUsecase,
});
static const int maxSelection = 3;
final ChildrenUsecase childrenUsecase;
final mainController = Get.find<MainController>();
late BookEntity book;
final rewards = <String>[].obs;
final selectedQueue = <int>[].obs;
final bookHistory = Rxn<BookHistoryEntity>();
int get selectedCount => selectedQueue.length;
int get remainingSelection =>
(maxSelection - selectedCount).clamp(0, maxSelection);
bool get hasRewards => rewards.isNotEmpty;
List<String> get selectedRewardsOrdered => selectedQueue
.where((index) => index >= 0 && index < rewards.length)
.map((index) => rewards[index])
.toList();
@override
void onInit() async {
super.onInit();
book = const BookEntity();
final argument = Get.arguments;
if (argument != null && argument is BookEntity) {
book = argument;
final bookId = book.bookId;
if (bookId != null && bookId.isNotEmpty) {
bookHistory.value = await childrenUsecase.getBookHistoryById(bookId);
}
}
_initRewards();
}
void _initRewards() {
var rewardList = book.rewards ?? [];
rewards.clear();
rewards.addAll(rewardList);
}
void selectReward(int index) {
if (index < 0 || index >= rewards.length) return;
if (selectedQueue.contains(index)) {
selectedQueue.remove(index);
return;
}
if (selectedQueue.length >= maxSelection) {
selectedQueue.removeAt(0);
}
selectedQueue.add(index);
}
void removeSelectionAt(int slotIndex) {
if (slotIndex < 0 || slotIndex >= selectedQueue.length) return;
selectedQueue.removeAt(slotIndex);
}
Future<void> claimReward() async {
try {
if (selectedQueue.length < maxSelection) {
EpicSnackBar.showWarningSnackBar(
'Pilih Reward',
'Silakan pilih $maxSelection gambar terlebih dahulu.',
);
return;
}
final history = bookHistory.value;
if (history == null) {
EpicSnackBar.showErrorSnackBar(
'Error',
'Riwayat buku tidak ditemukan.',
);
return;
}
final newHistory = history.copyWith(
claimedRewards: List<int>.from(selectedQueue),
);
mainController.showLoadingPage();
await childrenUsecase.updateBookHistory(
bookHistory: newHistory,
pushToRemote: true,
);
bookHistory.value = newHistory;
mainController.hideLoadingPage();
Get.back(result: true);
EpicSnackBar.showSuccessSnackBar(
'Success',
'Rewards claimed successfully!',
);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'claimReward');
EpicSnackBar.showErrorSnackBar(
'Error',
'Failed to claim rewards. Please try again.',
);
} finally {
mainController.hideLoadingPage();
}
}
}

View File

@ -0,0 +1,372 @@
import 'package:epic_story_app/feature/books/presentation/book_reward/book_reward_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class BookRewardPage extends GetView<BookRewardController> {
const BookRewardPage({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,
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),
const Text(
'Silahkan pilih 3 gambar\nsebagai reward buku',
textAlign: TextAlign.center,
style: 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: const Row(
children: [
Icon(Icons.info_outline, size: 18, color: Color(0xFF2F2249)),
SizedBox(width: 8),
Expanded(
child: Text(
'Belum ada reward untuk buku ini.',
style: 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 == BookRewardController.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 BookRewardController controller;
@override
Widget build(BuildContext context) {
final selected = controller.selectedRewardsOrdered;
final maxSlot = BookRewardController.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,
),
);
}
}