From e2c2936fae9c0e30df157c9f304250b006b486f4 Mon Sep 17 00:00:00 2001 From: Achmad Baihaqi Date: Sun, 28 Jun 2026 23:06:45 +0700 Subject: [PATCH] feat: add books feature (home, read, quiz, reward, histories) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../book_histories_binding.dart | 12 + .../book_histories_controller.dart | 38 + .../book_histories/book_histories_page.dart | 249 +++++ .../state/book_histories_state.dart | 5 + .../book_home/book_home_binding.dart | 21 + .../book_home/book_home_controller.dart | 189 ++++ .../book_home/book_home_page.dart | 525 ++++++++++ .../book_home/components/book_card.dart | 113 ++ .../book_home/components/book_cover.dart | 52 + .../components/book_react_stats.dart | 42 + .../book_home/components/history_card.dart | 106 ++ .../book_quiz/book_quiz_binding.dart | 21 + .../book_quiz/book_quiz_controller.dart | 930 +++++++++++++++++ .../book_quiz/book_quiz_page.dart | 600 +++++++++++ .../components/quiz/components/ray_burst.dart | 164 +++ .../components/quiz/fill_blank_quiz.dart | 353 +++++++ .../book_quiz/components/quiz/group_quiz.dart | 481 +++++++++ .../components/quiz/multiple_choice_quiz.dart | 152 +++ .../book_read/book_read_binding.dart | 24 + .../book_read/book_read_controller.dart | 973 ++++++++++++++++++ .../book_read/book_read_page.dart | 340 ++++++ .../book_read/components/circle_button.dart | 44 + .../components/dialog_add_book_coll.dart | 270 +++++ .../book_read/components/dot_indicator.dart | 51 + .../book_reward/book_reward_binding.dart | 18 + .../book_reward/book_reward_controller.dart | 124 +++ .../book_reward/book_reward_page.dart | 372 +++++++ 27 files changed, 6269 insertions(+) create mode 100644 lib/feature/books/presentation/book_histories/book_histories_binding.dart create mode 100644 lib/feature/books/presentation/book_histories/book_histories_controller.dart create mode 100644 lib/feature/books/presentation/book_histories/book_histories_page.dart create mode 100644 lib/feature/books/presentation/book_histories/state/book_histories_state.dart create mode 100644 lib/feature/books/presentation/book_home/book_home_binding.dart create mode 100644 lib/feature/books/presentation/book_home/book_home_controller.dart create mode 100644 lib/feature/books/presentation/book_home/book_home_page.dart create mode 100644 lib/feature/books/presentation/book_home/components/book_card.dart create mode 100644 lib/feature/books/presentation/book_home/components/book_cover.dart create mode 100644 lib/feature/books/presentation/book_home/components/book_react_stats.dart create mode 100644 lib/feature/books/presentation/book_home/components/history_card.dart create mode 100644 lib/feature/books/presentation/book_quiz/book_quiz_binding.dart create mode 100644 lib/feature/books/presentation/book_quiz/book_quiz_controller.dart create mode 100644 lib/feature/books/presentation/book_quiz/book_quiz_page.dart create mode 100644 lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart create mode 100644 lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart create mode 100644 lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart create mode 100644 lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart create mode 100644 lib/feature/books/presentation/book_read/book_read_binding.dart create mode 100644 lib/feature/books/presentation/book_read/book_read_controller.dart create mode 100644 lib/feature/books/presentation/book_read/book_read_page.dart create mode 100644 lib/feature/books/presentation/book_read/components/circle_button.dart create mode 100644 lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart create mode 100644 lib/feature/books/presentation/book_read/components/dot_indicator.dart create mode 100644 lib/feature/books/presentation/book_reward/book_reward_binding.dart create mode 100644 lib/feature/books/presentation/book_reward/book_reward_controller.dart create mode 100644 lib/feature/books/presentation/book_reward/book_reward_page.dart diff --git a/lib/feature/books/presentation/book_histories/book_histories_binding.dart b/lib/feature/books/presentation/book_histories/book_histories_binding.dart new file mode 100644 index 0000000..0242f25 --- /dev/null +++ b/lib/feature/books/presentation/book_histories/book_histories_binding.dart @@ -0,0 +1,12 @@ +import 'package:get/get.dart'; + +import 'book_histories_controller.dart'; + +class BookHistoriesBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut( + () => BookHistoriesController(), + ); + } +} \ No newline at end of file diff --git a/lib/feature/books/presentation/book_histories/book_histories_controller.dart b/lib/feature/books/presentation/book_histories/book_histories_controller.dart new file mode 100644 index 0000000..acff7a4 --- /dev/null +++ b/lib/feature/books/presentation/book_histories/book_histories_controller.dart @@ -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(); + + late BookHistoriesState selectedState; + var histories = [].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; + } + } +} diff --git a/lib/feature/books/presentation/book_histories/book_histories_page.dart b/lib/feature/books/presentation/book_histories/book_histories_page.dart new file mode 100644 index 0000000..34902e6 --- /dev/null +++ b/lib/feature/books/presentation/book_histories/book_histories_page.dart @@ -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 { + const BookHistoriesPage({super.key}); + + static const Color _mainBackground = Color(0xFFE0CEFF); + + static const List _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 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'; + } +} diff --git a/lib/feature/books/presentation/book_histories/state/book_histories_state.dart b/lib/feature/books/presentation/book_histories/state/book_histories_state.dart new file mode 100644 index 0000000..5becee3 --- /dev/null +++ b/lib/feature/books/presentation/book_histories/state/book_histories_state.dart @@ -0,0 +1,5 @@ +enum BookHistoriesState { + allHistories, + learnHistories, + quizHistories, +} \ No newline at end of file diff --git a/lib/feature/books/presentation/book_home/book_home_binding.dart b/lib/feature/books/presentation/book_home/book_home_binding.dart new file mode 100644 index 0000000..6e393c8 --- /dev/null +++ b/lib/feature/books/presentation/book_home/book_home_binding.dart @@ -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(), + bookUsecase: Get.find(), + childrenUsecase: Get.find(), + )); + } +} diff --git a/lib/feature/books/presentation/book_home/book_home_controller.dart b/lib/feature/books/presentation/book_home/book_home_controller.dart new file mode 100644 index 0000000..5901080 --- /dev/null +++ b/lib/feature/books/presentation/book_home/book_home_controller.dart @@ -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(); + var histories = [].obs; + + BookHomeController({ + required this.bookUsecase, + required this.collectionUsecase, + required this.childrenUsecase, + }); + + final categories = [].obs; + final selectedCategory = 'Semua'.obs; + + var isLoading = false.obs; + final RxList books = [].obs; + + final navController = Get.find(); + + List get filteredBooks { + if (selectedCategory.value == 'Semua') return books; + return books + .where((book) => (book.category ?? 'Lainnya') == selectedCategory.value) + .toList(); + } + + List get uncompletedHistories { + return histories.where((h) { + final total = (h.totalPages ?? 0)-2; + final pagesRead = h.readPages ?? 0; + return pagesRead < total; + }).toList(); + } + + List 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 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 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 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 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); + } +} diff --git a/lib/feature/books/presentation/book_home/book_home_page.dart b/lib/feature/books/presentation/book_home/book_home_page.dart new file mode 100644 index 0000000..043a9c8 --- /dev/null +++ b/lib/feature/books/presentation/book_home/book_home_page.dart @@ -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 _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), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_home/components/book_card.dart b/lib/feature/books/presentation/book_home/components/book_card.dart new file mode 100644 index 0000000..663eeca --- /dev/null +++ b/lib/feature/books/presentation/book_home/components/book_card.dart @@ -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'; + } +} diff --git a/lib/feature/books/presentation/book_home/components/book_cover.dart b/lib/feature/books/presentation/book_home/components/book_cover.dart new file mode 100644 index 0000000..398f949 --- /dev/null +++ b/lib/feature/books/presentation/book_home/components/book_cover.dart @@ -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, + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_home/components/book_react_stats.dart b/lib/feature/books/presentation/book_home/components/book_react_stats.dart new file mode 100644 index 0000000..4263e72 --- /dev/null +++ b/lib/feature/books/presentation/book_home/components/book_react_stats.dart @@ -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), + ], + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_home/components/history_card.dart b/lib/feature/books/presentation/book_home/components/history_card.dart new file mode 100644 index 0000000..af98551 --- /dev/null +++ b/lib/feature/books/presentation/book_home/components/history_card.dart @@ -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, + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/book_quiz_binding.dart b/lib/feature/books/presentation/book_quiz/book_quiz_binding.dart new file mode 100644 index 0000000..9c23b73 --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/book_quiz_binding.dart @@ -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(), + quizUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/book_quiz_controller.dart b/lib/feature/books/presentation/book_quiz/book_quiz_controller.dart new file mode 100644 index 0000000..25c0b7a --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/book_quiz_controller.dart @@ -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(); + final Rxn bookHistory = Rxn(); + final Rxn currentBook = Rxn(); + final RxBool isLoading = false.obs; + + final isAnswerChecked = false.obs; + final isAnswerCorrect = false.obs; + final retryQuestion = 3.obs; + + final assignedChoice = (-1).obs; + final assignedGroupByOption = {}.obs; + final assignedBlankByOption = {}.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 validOptionIds) { + assignedGroupByOption + .removeWhere((key, _) => !validOptionIds.contains(key)); + assignedBlankByOption + .removeWhere((key, _) => !validOptionIds.contains(key)); + } + + Future 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 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.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.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 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 _extractPreviousQuestions(BookHistoryQuizEntity? quiz) { + if (quiz == null) return []; + final List 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 _tryGenerateQuizByQuizType({ + required int quizType, + required String text, + String? category, + List 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 _tryToGenerateMultipleChoiceQuiz({ + required String fullText, + String? category, + List 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; + 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 _tryToGenerateFillBlankQuiz({ + required String fullText, + String? category, + List 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; + 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 _tryToGenerateGroupQuiz({ + required String fullText, + String? category, + List 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; + 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 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, + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/book_quiz_page.dart b/lib/feature/books/presentation/book_quiz/book_quiz_page.dart new file mode 100644 index 0000000..23a5fdc --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/book_quiz_page.dart @@ -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 createState() => _BookQuizPageState(); +} + +class _BookQuizPageState extends State { + final BookQuizController controller = Get.find(); + + 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 _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + _scaleAnimation = Tween(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 _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + )..repeat(reverse: true); + + _scaleAnimation = Tween(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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart b/lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart new file mode 100644 index 0000000..c869779 --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart @@ -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 createState() => _RayBurstState(); +} + +class _RayBurstState extends State + 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 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> createState() => _RayDraggableChipState(); +} + +class _RayDraggableChipState + extends State> { + @override + Widget build(BuildContext context) { + return Draggable( + data: widget.data, + feedback: widget.feedback, + childWhenDragging: widget.childWhenDragging, + child: widget.child, + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart b/lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart new file mode 100644 index 0000000..63a125f --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart @@ -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 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( + 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( + 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 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 _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( + 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( + 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, + ), + ), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart b/lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart new file mode 100644 index 0000000..c04632a --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart @@ -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 allOptions; + final BookQuizController quizController; + final bool isLocked; + + List _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 _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( + 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( + 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 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 _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( + 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( + 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, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart b/lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart new file mode 100644 index 0000000..4ffefa6 --- /dev/null +++ b/lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart @@ -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, + ), + ), + ), + ], + ), + ), + ); + }), + ); + }), + ], + ); + } +} diff --git a/lib/feature/books/presentation/book_read/book_read_binding.dart b/lib/feature/books/presentation/book_read/book_read_binding.dart new file mode 100644 index 0000000..c5a0824 --- /dev/null +++ b/lib/feature/books/presentation/book_read/book_read_binding.dart @@ -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( + childrenUsecase: Get.find(), + quizUsecase: Get.find(), + collectionUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_read/book_read_controller.dart b/lib/feature/books/presentation/book_read/book_read_controller.dart new file mode 100644 index 0000000..a7ef754 --- /dev/null +++ b/lib/feature/books/presentation/book_read/book_read_controller.dart @@ -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 bookHistory = Rxn(); + final collections = [].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 pdfController = Rxn(); + final RxBool isSoundOn = true.obs; + final Stopwatch _readingTimer = Stopwatch(); + final FlutterTts _tts = FlutterTts(); + final List _visiblePdfPages = + []; // PDF page indexes that are allowed to show + final Map _pdfToVisibleIndex = + {}; // PDF index -> visible index + final Map _pageTextByPdfIndex = {}; + 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(); + final mainController = Get.find(); + + 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 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 _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 _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 _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 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 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 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 _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 = {}; + 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 _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 _stopTts({bool force = false}) async { + if (!force && (_isDisposing || isClosed)) return; + await _tts.stop(); + } + + Future _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 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 addBookToCollection({ + required String collectionId, + required String bookId, + }) async { + try { + Get.back(); + mainController.showLoadingPage(); + await collectionUsecase + .addBookToCollection( + collectionId: collectionId, + bookId: bookId, + ) + .timeout(_collectionMutationTimeout); + Get.back(); + 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() + .toList(); + allTextTheory.value = + texts != null && texts.isNotEmpty ? texts.join('\n') : ''; + } + + Future 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 _extractPreviousQuestions(BookHistoryQuizEntity? quiz) { + if (quiz == null) return []; + final List 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 _tryGenerateQuizByQuizType({ + required int quizType, + required String text, + String? category, + List 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 _tryToGenerateMultipleChoiceQuiz({ + required String fullText, + String? category, + List 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; + 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 _tryToGenerateFillBlankQuiz({ + required String fullText, + String? category, + List 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; + 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 _tryToGenerateGroupQuiz({ + required String fullText, + String? category, + List 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; + 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 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 _forceLandscapeOrientation() async { + const landscape = [ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]; + + await SystemChrome.setPreferredOrientations(landscape); + } +} diff --git a/lib/feature/books/presentation/book_read/book_read_page.dart b/lib/feature/books/presentation/book_read/book_read_page.dart new file mode 100644 index 0000000..6439746 --- /dev/null +++ b/lib/feature/books/presentation/book_read/book_read_page.dart @@ -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 createState() => _BookReadPageState(); +} + +class _BookReadPageState extends State { + final BookReadController controller = Get.find(); + + 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(), + 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); + }, + ), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_read/components/circle_button.dart b/lib/feature/books/presentation/book_read/components/circle_button.dart new file mode 100644 index 0000000..93ca40e --- /dev/null +++ b/lib/feature/books/presentation/book_read/components/circle_button.dart @@ -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), + ), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart b/lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart new file mode 100644 index 0000000..fbd7502 --- /dev/null +++ b/lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart @@ -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 _fallbackGradientIndexes = [2, 7, 6]; +const List _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, + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_read/components/dot_indicator.dart b/lib/feature/books/presentation/book_read/components/dot_indicator.dart new file mode 100644 index 0000000..d8fc99f --- /dev/null +++ b/lib/feature/books/presentation/book_read/components/dot_indicator.dart @@ -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, + ), + ); + }), + ); + } +} diff --git a/lib/feature/books/presentation/book_reward/book_reward_binding.dart b/lib/feature/books/presentation/book_reward/book_reward_binding.dart new file mode 100644 index 0000000..c436398 --- /dev/null +++ b/lib/feature/books/presentation/book_reward/book_reward_binding.dart @@ -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( + childrenUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/books/presentation/book_reward/book_reward_controller.dart b/lib/feature/books/presentation/book_reward/book_reward_controller.dart new file mode 100644 index 0000000..42874a9 --- /dev/null +++ b/lib/feature/books/presentation/book_reward/book_reward_controller.dart @@ -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(); + + late BookEntity book; + + final rewards = [].obs; + final selectedQueue = [].obs; + final bookHistory = Rxn(); + + int get selectedCount => selectedQueue.length; + int get remainingSelection => + (maxSelection - selectedCount).clamp(0, maxSelection); + bool get hasRewards => rewards.isNotEmpty; + + List 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 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.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(); + } + } +} diff --git a/lib/feature/books/presentation/book_reward/book_reward_page.dart b/lib/feature/books/presentation/book_reward/book_reward_page.dart new file mode 100644 index 0000000..7ca603f --- /dev/null +++ b/lib/feature/books/presentation/book_reward/book_reward_page.dart @@ -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 { + 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 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, + ), + ); + } +}