feat: add books feature (home, read, quiz, reward, histories)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
17ca14c40c
commit
e2c2936fae
|
|
@ -0,0 +1,12 @@
|
|||
import 'package:get/get.dart';
|
||||
|
||||
import 'book_histories_controller.dart';
|
||||
|
||||
class BookHistoriesBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
Get.lazyPut<BookHistoriesController>(
|
||||
() => BookHistoriesController(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:epic_story_app/feature/books/presentation/book_histories/state/book_histories_state.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../../domain/entities/book_histories_entity.dart';
|
||||
import '../book_home/book_home_controller.dart';
|
||||
|
||||
class BookHistoriesController extends GetxController {
|
||||
final bookHomeController = Get.find<BookHomeController>();
|
||||
|
||||
late BookHistoriesState selectedState;
|
||||
var histories = <BookHistoryEntity>[].obs;
|
||||
|
||||
void openFromHistory(BookHistoryEntity history) {
|
||||
bookHomeController.gotoBookReadFromHistory(history);
|
||||
}
|
||||
|
||||
var title = ''.obs;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
selectedState = Get.arguments;
|
||||
switch (selectedState) {
|
||||
case BookHistoriesState.allHistories:
|
||||
histories.value = bookHomeController.histories;
|
||||
title.value = 'Semua Riwayat';
|
||||
break;
|
||||
case BookHistoriesState.learnHistories:
|
||||
histories.value = bookHomeController.uncompletedHistories;
|
||||
title.value = 'Buku yang belum kamu selesaikan';
|
||||
break;
|
||||
case BookHistoriesState.quizHistories:
|
||||
histories.value = bookHomeController.completedHistories;
|
||||
title.value = 'Kuis yang belum kamu kerjakan';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'book_histories_controller.dart';
|
||||
|
||||
class BookHistoriesPage extends GetView<BookHistoriesController> {
|
||||
const BookHistoriesPage({super.key});
|
||||
|
||||
static const Color _mainBackground = Color(0xFFE0CEFF);
|
||||
|
||||
static const List<Color> _gradientCycle = [
|
||||
Color(0xFFFFDAA0),
|
||||
Color(0xFFAAFFC0),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final histories = controller.histories;
|
||||
final selectedCategory =
|
||||
controller.bookHomeController.selectedCategory.value;
|
||||
final activeCategory =
|
||||
selectedCategory.isEmpty ? 'Semua' : selectedCategory;
|
||||
final bookById = {
|
||||
for (final book in controller.bookHomeController.books)
|
||||
if (book.bookId != null) book.bookId!: book,
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _mainBackground,
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
StackedInfoTopBar(
|
||||
titleText: 'Riwayat Buku',
|
||||
infoText: 'Mata Pelajran :\n$activeCategory',
|
||||
isBackButtonVisible: true,
|
||||
onBackTap: () => Get.back(),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
|
||||
child: histories.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Belum ada riwayat buku.',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF2B2B2B),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: histories.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final history = histories[index];
|
||||
final linkedBook = history.bookId == null
|
||||
? null
|
||||
: bookById[history.bookId!];
|
||||
|
||||
return _HistoryItem(
|
||||
item: history,
|
||||
index: index,
|
||||
linkedBook: linkedBook,
|
||||
gradientCycle: _gradientCycle,
|
||||
onTap: () => controller.openFromHistory(history),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryItem extends StatelessWidget {
|
||||
const _HistoryItem({
|
||||
required this.item,
|
||||
required this.index,
|
||||
required this.linkedBook,
|
||||
required this.gradientCycle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final BookHistoryEntity item;
|
||||
final int index;
|
||||
final BookEntity? linkedBook;
|
||||
final List<Color> gradientCycle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
gradientCycle[index % gradientCycle.length],
|
||||
const Color(0xFFFFFFFF),
|
||||
],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white, width: 1.2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBookImage(
|
||||
coverUrl: item.bookCover,
|
||||
category: linkedBook?.category ?? item.category,
|
||||
width: 144,
|
||||
height: 164,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title ?? '-',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'Category : ${item.category ?? linkedBook?.category ?? 'Kategori buku'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Total Halaman : ${item.totalPages ?? 0}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
linkedBook?.summary ?? '-',
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Image.asset(
|
||||
(item.isFinished ?? false)
|
||||
? 'assets/images/ic-book-selesai.png'
|
||||
: 'assets/images/ic-book-belum-selesai.png',
|
||||
width: 78,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookImage({
|
||||
required String? coverUrl,
|
||||
required String? category,
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
final fallbackAsset = _resolveFallbackBookAsset(category, index);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: coverUrl != null && coverUrl.trim().isNotEmpty
|
||||
? Image.network(
|
||||
coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Image.asset(
|
||||
fallbackAsset,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
fallbackAsset,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveFallbackBookAsset(String? category, int index) {
|
||||
final normalized = (category ?? '').toLowerCase();
|
||||
|
||||
if (normalized.contains('matematika') || normalized.contains('ipa')) {
|
||||
return 'assets/images/book-matematika.png';
|
||||
}
|
||||
if (normalized.contains('bahasa') || normalized.contains('indo')) {
|
||||
return 'assets/images/book-bhs-indo.png';
|
||||
}
|
||||
|
||||
return index.isEven
|
||||
? 'assets/images/book-matematika.png'
|
||||
: 'assets/images/book-bhs-indo.png';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
enum BookHistoriesState {
|
||||
allHistories,
|
||||
learnHistories,
|
||||
quizHistories,
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:epic_story_app/data/modules/book_module.dart';
|
||||
import 'package:epic_story_app/data/modules/collection_module.dart';
|
||||
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookHomeBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
BookModule();
|
||||
CollectionModule();
|
||||
CollectionModule();
|
||||
Get.lazyPut(() => BookHomeController(
|
||||
collectionUsecase: Get.find<CollectionUsecase>(),
|
||||
bookUsecase: Get.find<BookUsecase>(),
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import 'package:epic_story_app/core/routes/epic_routes.dart';
|
||||
import 'package:epic_story_app/core/utils/epic_log.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_histories/state/book_histories_state.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookHomeController extends GetxController {
|
||||
final BookUsecase bookUsecase;
|
||||
final CollectionUsecase collectionUsecase;
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
|
||||
final MainController mainController = Get.find<MainController>();
|
||||
var histories = <BookHistoryEntity>[].obs;
|
||||
|
||||
BookHomeController({
|
||||
required this.bookUsecase,
|
||||
required this.collectionUsecase,
|
||||
required this.childrenUsecase,
|
||||
});
|
||||
|
||||
final categories = <String>[].obs;
|
||||
final selectedCategory = 'Semua'.obs;
|
||||
|
||||
var isLoading = false.obs;
|
||||
final RxList<BookEntity> books = <BookEntity>[].obs;
|
||||
|
||||
final navController = Get.find<EpicNavigationController>();
|
||||
|
||||
List<BookEntity> get filteredBooks {
|
||||
if (selectedCategory.value == 'Semua') return books;
|
||||
return books
|
||||
.where((book) => (book.category ?? 'Lainnya') == selectedCategory.value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<BookHistoryEntity> get uncompletedHistories {
|
||||
return histories.where((h) {
|
||||
final total = (h.totalPages ?? 0)-2;
|
||||
final pagesRead = h.readPages ?? 0;
|
||||
return pagesRead < total;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<BookHistoryEntity> get completedHistories {
|
||||
return histories.where((h) {
|
||||
final total = (h.totalPages ?? 0)-2;
|
||||
final pagesRead = h.readPages ?? 0;
|
||||
final isFinished = h.isFinished ?? false;
|
||||
return pagesRead == total && !isFinished;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<BookHistoryEntity> get filteredHistory {
|
||||
if (selectedCategory.value == 'Semua') return histories;
|
||||
return histories
|
||||
.where((history) =>
|
||||
(history.category ?? 'Lainnya') == selectedCategory.value)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() async {
|
||||
super.onInit();
|
||||
ever(navController.selectedCategory, (value) {
|
||||
selectedCategory.value = value.isEmpty ? 'Semua' : value;
|
||||
});
|
||||
// await loadBooks();
|
||||
}
|
||||
|
||||
Future<void> loadBooks() async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final bookData = await bookUsecase.getBooks(limit: 20);
|
||||
final bookHistories = await childrenUsecase.getBookHistories();
|
||||
books.clear();
|
||||
books.addAll(bookData.books);
|
||||
histories.clear();
|
||||
histories.addAll(bookHistories);
|
||||
sortedHistories();
|
||||
_buildCategories();
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'loadBooks');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
EpicLog.debug("homeController - Finished fetching books : $isLoading");
|
||||
}
|
||||
}
|
||||
|
||||
void sortedHistories() {
|
||||
histories.sort((a, b) =>
|
||||
b.lastReadAt?.compareTo(
|
||||
a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ??
|
||||
0);
|
||||
}
|
||||
|
||||
void _buildCategories() {
|
||||
final uniqueCategories = {
|
||||
'Semua',
|
||||
...books.map((e) => e.category ?? 'Lainnya'),
|
||||
};
|
||||
|
||||
categories.assignAll(uniqueCategories.toList());
|
||||
|
||||
if (categories.isNotEmpty) {
|
||||
final current = navController.selectedCategory.value;
|
||||
selectedCategory.value = current.isNotEmpty ? current : categories.first;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addBookToCollection({
|
||||
required String collectionId,
|
||||
required String bookId,
|
||||
}) async {
|
||||
try {
|
||||
mainController.showLoadingPage();
|
||||
await collectionUsecase.addBookToCollection(
|
||||
collectionId: collectionId,
|
||||
bookId: bookId,
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'addBookToCollection');
|
||||
} finally {
|
||||
mainController.hideLoadingPage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reactBook({
|
||||
required String bookId,
|
||||
required String emoticon,
|
||||
bool isIncrement = true,
|
||||
}) async {
|
||||
try {
|
||||
mainController.showLoadingPage();
|
||||
await mainController.childrenUsecase.reactBook(
|
||||
bookId: bookId,
|
||||
emoticon: emoticon,
|
||||
isIncrement: isIncrement,
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'reactBook');
|
||||
} finally {
|
||||
mainController.hideLoadingPage();
|
||||
}
|
||||
}
|
||||
|
||||
void goToBookRead(BookEntity book) async {
|
||||
await Get.toNamed(
|
||||
EpicRoutes.bookRead,
|
||||
arguments: book,
|
||||
);
|
||||
final updatedHistories = await childrenUsecase.getBookHistories(
|
||||
refreshLocal: true,
|
||||
);
|
||||
histories.clear();
|
||||
histories.addAll(updatedHistories);
|
||||
sortedHistories();
|
||||
_buildCategories();
|
||||
}
|
||||
|
||||
void gotoBookReadFromHistory(BookHistoryEntity history) async {
|
||||
final book = books.firstWhereOrNull((b) => b.bookId == history.bookId);
|
||||
if (book == null) {
|
||||
EpicLog.debug(
|
||||
'Book with ID ${history.bookId} not found for history ${history.bookId}');
|
||||
return;
|
||||
}
|
||||
await Get.toNamed(
|
||||
EpicRoutes.bookRead,
|
||||
arguments: book,
|
||||
);
|
||||
final updatedHistories = await childrenUsecase.getBookHistories(
|
||||
refreshLocal: true,
|
||||
);
|
||||
histories.clear();
|
||||
histories.addAll(updatedHistories);
|
||||
sortedHistories();
|
||||
_buildCategories();
|
||||
}
|
||||
|
||||
void goToBookHistories(BookHistoriesState state) {
|
||||
Get.toNamed(EpicRoutes.bookHistories, arguments: state);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_histories/state/book_histories_state.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookHomePage extends StatelessWidget {
|
||||
const BookHomePage({super.key});
|
||||
|
||||
static const Color _mainBackground = Color(0xFFE0CEFF);
|
||||
static const double _historyCardWidth = 280;
|
||||
static const double _historyCardHeight = 152;
|
||||
static const List<Color> _gradientCycle = [
|
||||
Color(0xFFFFDAA0),
|
||||
Color(0xFFAAFFC0),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final BookHomeController controller = Get.find();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _mainBackground,
|
||||
body: Obx(() {
|
||||
final bookById = {
|
||||
for (final book in controller.books)
|
||||
if (book.bookId != null) book.bookId!: book,
|
||||
};
|
||||
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final histories = controller.filteredHistory;
|
||||
final latestHistories = histories.take(5).toList();
|
||||
final items = controller.filteredBooks;
|
||||
final learnCount = controller.uncompletedHistories.length;
|
||||
final quizCount = controller.completedHistories.length;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(left: 10, top: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSectionTitle('Riwayat Buku'),
|
||||
const SizedBox(height: 12),
|
||||
if (histories.isEmpty)
|
||||
_buildEmptyState(
|
||||
'Belum ada riwayat baca di kategori "${controller.selectedCategory.value}"',
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: _historyCardHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: latestHistories.length + 1,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == latestHistories.length) {
|
||||
return _buildSeeAllHistoriesButton(
|
||||
index: index,
|
||||
onTap: () => controller.goToBookHistories(
|
||||
BookHistoriesState.allHistories,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final history = latestHistories[index];
|
||||
final linkedBook = history.bookId == null
|
||||
? null
|
||||
: bookById[history.bookId!];
|
||||
|
||||
return _buildHistoryCard(
|
||||
history,
|
||||
linkedBook,
|
||||
index,
|
||||
onTap: () {
|
||||
controller.gotoBookReadFromHistory(history);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildActionButtons(
|
||||
learnCount: learnCount,
|
||||
quizCount: quizCount,
|
||||
onTapLearn: () {
|
||||
controller
|
||||
.goToBookHistories(BookHistoriesState.learnHistories);
|
||||
},
|
||||
onTapQuiz: () {
|
||||
controller
|
||||
.goToBookHistories(BookHistoriesState.quizHistories);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildSectionTitle('Buku yang dapat kamu pelajari'),
|
||||
const SizedBox(height: 10),
|
||||
if (items.isEmpty)
|
||||
_buildEmptyState(
|
||||
'Belum ada buku di kategori "${controller.selectedCategory.value}"',
|
||||
)
|
||||
else
|
||||
ListView.separated(
|
||||
itemCount: items.length,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildBookLearningCard(
|
||||
items[index],
|
||||
index,
|
||||
onTap: () {
|
||||
controller.goToBookRead(items[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title) {
|
||||
return Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryCard(
|
||||
BookHistoryEntity history,
|
||||
BookEntity? linkedBook,
|
||||
int index, {
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: _historyCardWidth,
|
||||
height: _historyCardHeight,
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
|
||||
decoration: _cardDecoration(index),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBookImage(
|
||||
coverUrl: history.bookCover,
|
||||
category: linkedBook?.category ?? history.category,
|
||||
index: index,
|
||||
width: 96,
|
||||
height: 132,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
history.title ?? '-',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
linkedBook?.summary ?? '-',
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_miniPill(
|
||||
icon: Icons.label_outline,
|
||||
label:
|
||||
history.category ?? linkedBook?.category ?? 'Lainnya',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeeAllHistoriesButton({
|
||||
required int index,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: _historyCardHeight,
|
||||
decoration: _cardDecoration(index),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.arrow_right_alt_rounded,
|
||||
size: 30,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButtons({
|
||||
required int learnCount,
|
||||
required int quizCount,
|
||||
required VoidCallback onTapLearn,
|
||||
required VoidCallback onTapQuiz,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildActionCapsule(
|
||||
label: 'Belajar=$learnCount',
|
||||
imagePath: 'assets/images/yellow-button.png',
|
||||
onPressed: onTapLearn,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildActionCapsule(
|
||||
label: 'Kuis=$quizCount',
|
||||
imagePath: 'assets/images/green-button.png',
|
||||
onPressed: onTapQuiz,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionCapsule({
|
||||
required String label,
|
||||
required String imagePath,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onPressed,
|
||||
child: SizedBox(
|
||||
height: 60,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
imagePath,
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0x66000000),
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookLearningCard(
|
||||
BookEntity item,
|
||||
int index, {
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
|
||||
decoration: _cardDecoration(index),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildBookImage(
|
||||
coverUrl: item.bookCover,
|
||||
category: item.category,
|
||||
index: index,
|
||||
width: 144,
|
||||
height: 164,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title ?? '-',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Category : ${item.category ?? '-'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Total Halaman : ${item.totalPages ?? 0}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
item.summary ?? '-',
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF232323),
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
_buildReadLabel(item.reads),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF232323),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookImage({
|
||||
required String? coverUrl,
|
||||
required String? category,
|
||||
required int index,
|
||||
required double width,
|
||||
required double height,
|
||||
}) {
|
||||
final fallbackAsset = _resolveFallbackBookAsset(category, index);
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: SizedBox(
|
||||
width: width,
|
||||
height: height,
|
||||
child: coverUrl != null && coverUrl.trim().isNotEmpty
|
||||
? Image.network(
|
||||
coverUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Image.asset(
|
||||
fallbackAsset,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
fallbackAsset,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _miniPill({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 13, color: const Color(0xFF151515)),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF151515),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveFallbackBookAsset(String? category, int index) {
|
||||
final normalized = (category ?? '').toLowerCase();
|
||||
|
||||
if (normalized.contains('matematika') || normalized.contains('ipa')) {
|
||||
return 'assets/images/book-matematika.png';
|
||||
}
|
||||
if (normalized.contains('bahasa') || normalized.contains('indo')) {
|
||||
return 'assets/images/book-bhs-indo.png';
|
||||
}
|
||||
|
||||
return index.isEven
|
||||
? 'assets/images/book-matematika.png'
|
||||
: 'assets/images/book-bhs-indo.png';
|
||||
}
|
||||
|
||||
String _buildReadLabel(int? reads) {
|
||||
final value = reads ?? 0;
|
||||
if (value >= 1000) {
|
||||
return '${(value / 1000).toStringAsFixed(0)} rb x dibaca';
|
||||
}
|
||||
return '$value x dibaca';
|
||||
}
|
||||
|
||||
BoxDecoration _cardDecoration(int index) => BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_gradientCycle[index % _gradientCycle.length],
|
||||
const Color(0xFFFFFFFF),
|
||||
],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white, width: 1.2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildEmptyState(String message) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFAAFFC0),
|
||||
Color(0xFFFFFFFF),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white, width: 1.2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Color(0xFF151515), size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF151515),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import 'package:get/get.dart';
|
||||
import 'package:epic_story_app/data/modules/children_module.dart';
|
||||
import 'package:epic_story_app/data/modules/quiz_module.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
|
||||
|
||||
import 'book_quiz_controller.dart';
|
||||
|
||||
class BookQuizBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
ChildrenModule();
|
||||
QuizModule();
|
||||
Get.lazyPut(
|
||||
() => BookQuizController(
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
quizUsecase: Get.find<QuizUsecase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,930 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:epic_story_app/core/constants/quiz_prompt.dart';
|
||||
import 'package:epic_story_app/core/routes/epic_routes.dart';
|
||||
import 'package:epic_story_app/core/utils/epic_log.dart';
|
||||
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_choice_answer_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_fill_blank_answer_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_group_answer_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/quiz_choice_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/quiz_fill_blank_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/quiz/quiz_choice_remote_model.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/quiz/quiz_group_remote_model.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_history_quiz_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/history/quiz_choice_answer_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/history/quiz_fill_blank_answer_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/history/quiz_group_answer_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
|
||||
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookQuizController extends GetxController {
|
||||
BookQuizController({
|
||||
required this.childrenUsecase,
|
||||
required this.quizUsecase,
|
||||
});
|
||||
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
final QuizUsecase quizUsecase;
|
||||
|
||||
final navController = Get.find<EpicNavigationController>();
|
||||
final Rxn<BookHistoryEntity> bookHistory = Rxn<BookHistoryEntity>();
|
||||
final Rxn<BookEntity> currentBook = Rxn<BookEntity>();
|
||||
final RxBool isLoading = false.obs;
|
||||
|
||||
final isAnswerChecked = false.obs;
|
||||
final isAnswerCorrect = false.obs;
|
||||
final retryQuestion = 3.obs;
|
||||
|
||||
final assignedChoice = (-1).obs;
|
||||
final assignedGroupByOption = <String, String>{}.obs;
|
||||
final assignedBlankByOption = <String, String>{}.obs;
|
||||
String _theoryText = '';
|
||||
int? _bookQuizType;
|
||||
var title = ''.obs;
|
||||
|
||||
BookHistoryQuizEntity? get quiz => bookHistory.value?.quiz;
|
||||
int get quizType => quiz?.quizType ?? 0;
|
||||
|
||||
QuizChoiceEntity? get multipleChoiceQuiz => quiz?.multipleChoiceQuestion;
|
||||
QuizFillBlankEntity? get fillBlankQuiz => quiz?.fillBlankQuestion;
|
||||
QuizGroupEntity? get groupQuiz => quiz?.groupQuestion;
|
||||
|
||||
bool get isQuizAvailable {
|
||||
final type = quizType;
|
||||
if (type == 1) return multipleChoiceQuiz != null;
|
||||
if (type == 2) return fillBlankQuiz != null;
|
||||
if (type == 3) return groupQuiz != null;
|
||||
return false;
|
||||
}
|
||||
|
||||
String? get explanation {
|
||||
if (quiz?.explanation?.trim().isNotEmpty == true) {
|
||||
return quiz?.explanation;
|
||||
}
|
||||
if (quizType == 1) return multipleChoiceQuiz?.explanation;
|
||||
if (quizType == 2) return fillBlankQuiz?.explanation;
|
||||
if (quizType == 3) return groupQuiz?.explanation;
|
||||
return quiz?.explanation;
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_initFromArguments();
|
||||
}
|
||||
|
||||
void _initFromArguments() {
|
||||
final args = Get.arguments;
|
||||
BookHistoryEntity? history;
|
||||
|
||||
if (args is BookHistoryEntity) {
|
||||
history = args;
|
||||
} else if (args is Map) {
|
||||
final rawHistory = args['history'];
|
||||
if (rawHistory is BookHistoryEntity) {
|
||||
history = rawHistory;
|
||||
}
|
||||
final theoryTextArg = args['theoryText'];
|
||||
if (theoryTextArg is String) {
|
||||
_theoryText = theoryTextArg;
|
||||
}
|
||||
final bookQuizTypeArg = args['bookQuizType'];
|
||||
if (bookQuizTypeArg is int) {
|
||||
_bookQuizType = bookQuizTypeArg;
|
||||
}
|
||||
final bookArg = args['book'];
|
||||
if (bookArg is BookEntity) {
|
||||
currentBook.value = bookArg;
|
||||
}
|
||||
title.value = history?.title ?? 'null';
|
||||
}
|
||||
|
||||
if (history == null) {
|
||||
EpicLog.debug('BookQuizController - missing book history argument');
|
||||
return;
|
||||
}
|
||||
|
||||
bookHistory.value = history;
|
||||
syncCurrentQuiz();
|
||||
}
|
||||
|
||||
void syncCurrentQuiz() {
|
||||
final currentQuiz = quiz;
|
||||
retryQuestion.value = currentQuiz?.retry ?? 3;
|
||||
|
||||
assignedChoice.value =
|
||||
currentQuiz?.multipleChoiceAnswer?.selectedAnswer ?? -1;
|
||||
|
||||
assignedBlankByOption.clear();
|
||||
assignedBlankByOption
|
||||
.addAll(currentQuiz?.fillBlankAnswer?.assignedBlankByOption ?? {});
|
||||
|
||||
assignedGroupByOption.clear();
|
||||
assignedGroupByOption
|
||||
.addAll(currentQuiz?.groupAnswer?.assignedGroupByOption ?? {});
|
||||
|
||||
final hasStoredAnswer = (currentQuiz?.answer?.trim().isNotEmpty ?? false) ||
|
||||
currentQuiz?.multipleChoiceAnswer != null ||
|
||||
currentQuiz?.fillBlankAnswer != null ||
|
||||
currentQuiz?.groupAnswer != null;
|
||||
|
||||
isAnswerChecked.value = hasStoredAnswer;
|
||||
isAnswerCorrect.value = _evaluateCurrentCorrectness();
|
||||
}
|
||||
|
||||
bool _evaluateCurrentCorrectness() {
|
||||
final type = quizType;
|
||||
if (type == 1) {
|
||||
final q = multipleChoiceQuiz;
|
||||
if (q == null) return false;
|
||||
return q.correctedIndex == assignedChoice.value &&
|
||||
assignedChoice.value >= 0;
|
||||
}
|
||||
if (type == 2) {
|
||||
final q = fillBlankQuiz;
|
||||
if (q == null) return false;
|
||||
final options = q.options ?? const [];
|
||||
if (options.isEmpty) return false;
|
||||
for (final option in options) {
|
||||
final id = option.id;
|
||||
final correctBlank = option.correctBlankId;
|
||||
if (id == null || correctBlank == null) return false;
|
||||
final assigned = assignedBlankByOption[id];
|
||||
if (assigned != correctBlank) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (type == 3) {
|
||||
final q = groupQuiz;
|
||||
if (q == null) return false;
|
||||
final options = q.options ?? const [];
|
||||
if (options.isEmpty) return false;
|
||||
for (final option in options) {
|
||||
final id = option.id;
|
||||
final correctGroup = option.correctGroupId;
|
||||
if (id == null || correctGroup == null) return false;
|
||||
final assigned = assignedGroupByOption[id];
|
||||
if (assigned != correctGroup) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void selectChoice(int index) {
|
||||
assignedChoice.value = index;
|
||||
isAnswerChecked.value = false;
|
||||
}
|
||||
|
||||
void assignOptionToGroup(
|
||||
{required String optionId, required String? groupId}) {
|
||||
if (groupId != null && groupId.isNotEmpty) {
|
||||
final currentGroupId = assignedGroupByOption[optionId];
|
||||
final currentCount = countForGroup(groupId);
|
||||
final alreadyInTarget = currentGroupId == groupId;
|
||||
if (!alreadyInTarget && currentCount >= 4) {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Group Full',
|
||||
'Setiap group maksimal 4 item.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (optionId.isEmpty) return;
|
||||
if (groupId == null || groupId.isEmpty) {
|
||||
assignedGroupByOption.remove(optionId);
|
||||
} else {
|
||||
assignedGroupByOption[optionId] = groupId;
|
||||
}
|
||||
isAnswerChecked.value = false;
|
||||
}
|
||||
|
||||
void assignOptionToBlank(
|
||||
{required String optionId, required String? blankId}) {
|
||||
if (optionId.isEmpty) return;
|
||||
if (blankId == null || blankId.isEmpty) {
|
||||
assignedBlankByOption.remove(optionId);
|
||||
} else {
|
||||
assignedBlankByOption[optionId] = blankId;
|
||||
}
|
||||
isAnswerChecked.value = false;
|
||||
}
|
||||
|
||||
int countForGroup(String groupId) {
|
||||
var count = 0;
|
||||
assignedGroupByOption.forEach((_, value) {
|
||||
if (value == groupId) count++;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
void cleanupAssignments(Set<String> validOptionIds) {
|
||||
assignedGroupByOption
|
||||
.removeWhere((key, _) => !validOptionIds.contains(key));
|
||||
assignedBlankByOption
|
||||
.removeWhere((key, _) => !validOptionIds.contains(key));
|
||||
}
|
||||
|
||||
Future<void> updateHistoryAnswer({
|
||||
required int quizType,
|
||||
required bool isCorrect,
|
||||
String? question,
|
||||
String? answer,
|
||||
String? explanation,
|
||||
QuizChoiceAnswerEntity? multipleChoiceAnswer,
|
||||
QuizChoiceEntity? multipleChoiceQuestion,
|
||||
QuizGroupAnswerEntity? groupAnswer,
|
||||
QuizGroupEntity? groupQuestion,
|
||||
QuizFillBlankAnswerEntity? fillBlankAnswer,
|
||||
QuizFillBlankEntity? fillBlankQuestion,
|
||||
}) async {
|
||||
final history = bookHistory.value;
|
||||
if (history == null) return;
|
||||
|
||||
final existingQuiz = history.quiz;
|
||||
if (existingQuiz == null) return;
|
||||
|
||||
final updatedQuiz = existingQuiz.copyWith(
|
||||
quizType: quizType,
|
||||
retry: retryQuestion.value,
|
||||
question: question ?? existingQuiz.question,
|
||||
answer: answer,
|
||||
explanation: explanation ?? existingQuiz.explanation,
|
||||
multipleChoiceQuestion:
|
||||
multipleChoiceQuestion ?? existingQuiz.multipleChoiceQuestion,
|
||||
multipleChoiceAnswer: multipleChoiceAnswer,
|
||||
fillBlankQuestion: fillBlankQuestion ?? existingQuiz.fillBlankQuestion,
|
||||
fillBlankAnswer: fillBlankAnswer,
|
||||
groupQuestion: groupQuestion ?? existingQuiz.groupQuestion,
|
||||
groupAnswer: groupAnswer,
|
||||
);
|
||||
|
||||
final completed = [...?history.completedQuizes];
|
||||
if (isCorrect && !completed.contains(quizType)) {
|
||||
completed.add(quizType);
|
||||
}
|
||||
|
||||
final finished = isCorrect;
|
||||
|
||||
final updatedHistory = history.copyWith(
|
||||
quiz: updatedQuiz,
|
||||
completedQuizes: completed,
|
||||
lastReadAt: DateTime.now(),
|
||||
isFinished: finished,
|
||||
finishedAt: finished
|
||||
? (history.finishedAt ?? DateTime.now())
|
||||
: history.finishedAt,
|
||||
);
|
||||
|
||||
// Update local state first so UI/result stays consistent even if user leaves quickly.
|
||||
bookHistory.value = updatedHistory;
|
||||
await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
|
||||
}
|
||||
|
||||
Future<void> onCheckAnswer() async {
|
||||
if (!isQuizAvailable) return;
|
||||
|
||||
if (retryQuestion.value <= 0) {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Kesempatan Habis',
|
||||
'Kesempatan menjawab untuk quiz ini sudah habis.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final type = quizType;
|
||||
if (type == 1) {
|
||||
final q = multipleChoiceQuiz;
|
||||
if (q == null) return;
|
||||
|
||||
final correctIndex = q.correctedIndex;
|
||||
if (correctIndex == null || assignedChoice.value < 0) {
|
||||
EpicSnackBar.showWarningSnackBar(
|
||||
'Quiz', 'Pilih jawaban terlebih dahulu.');
|
||||
return;
|
||||
}
|
||||
|
||||
isAnswerCorrect.value = assignedChoice.value == correctIndex;
|
||||
isAnswerChecked.value = true;
|
||||
retryQuestion.value--;
|
||||
|
||||
final answerData = QuizChoiceAnswerEntity(
|
||||
selectedAnswer: assignedChoice.value,
|
||||
);
|
||||
final questionJson = QuizChoiceMapper.entityToRemote(q).toJson();
|
||||
final answerJson =
|
||||
QuizChoiceAnswerMapper.entityToRemote(answerData).toJson();
|
||||
|
||||
if (isAnswerCorrect.value) {
|
||||
EpicSnackBar.showSuccessSnackBar('Benar', 'Jawaban kamu benar.');
|
||||
} else {
|
||||
EpicSnackBar.showErrorSnackBar('Salah', 'Coba lagi ya.');
|
||||
}
|
||||
|
||||
await updateHistoryAnswer(
|
||||
quizType: type,
|
||||
isCorrect: isAnswerCorrect.value,
|
||||
question: jsonEncode(questionJson),
|
||||
answer: jsonEncode(answerJson),
|
||||
explanation: q.explanation,
|
||||
multipleChoiceQuestion: q,
|
||||
multipleChoiceAnswer: answerData,
|
||||
);
|
||||
// if (isAnswerCorrect.value) {
|
||||
// navController.refreshRewardBook.value++;
|
||||
// }
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 2) {
|
||||
final q = fillBlankQuiz;
|
||||
if (q == null) return;
|
||||
final options = q.options ?? const [];
|
||||
if (options.isEmpty) return;
|
||||
|
||||
var allAssigned = true;
|
||||
var allCorrect = true;
|
||||
|
||||
for (final option in options) {
|
||||
final id = option.id;
|
||||
final correctBlankId = option.correctBlankId;
|
||||
if (id == null) continue;
|
||||
final assigned = assignedBlankByOption[id];
|
||||
if (assigned == null) {
|
||||
allAssigned = false;
|
||||
allCorrect = false;
|
||||
continue;
|
||||
}
|
||||
if (correctBlankId != null && assigned != correctBlankId) {
|
||||
allCorrect = false;
|
||||
}
|
||||
}
|
||||
|
||||
isAnswerChecked.value = true;
|
||||
isAnswerCorrect.value = allAssigned && allCorrect;
|
||||
retryQuestion.value--;
|
||||
|
||||
final answerData = QuizFillBlankAnswerEntity(
|
||||
assignedBlankByOption: Map<String, String>.from(assignedBlankByOption),
|
||||
);
|
||||
final questionJson = QuizFillBlankMapper.entityToRemote(q).toJson();
|
||||
final answerJson =
|
||||
QuizFillBlankAnswerMapper.entityToRemote(answerData).toJson();
|
||||
|
||||
if (isAnswerCorrect.value) {
|
||||
EpicSnackBar.showSuccessSnackBar('Benar', 'Semua isian sudah benar.');
|
||||
} else {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Salah',
|
||||
allAssigned
|
||||
? 'Masih ada isian yang salah.'
|
||||
: 'Lengkapi semua isian dulu.',
|
||||
);
|
||||
}
|
||||
|
||||
await updateHistoryAnswer(
|
||||
quizType: type,
|
||||
isCorrect: isAnswerCorrect.value,
|
||||
question: jsonEncode(questionJson),
|
||||
answer: jsonEncode(answerJson),
|
||||
explanation: q.explanation,
|
||||
fillBlankQuestion: q,
|
||||
fillBlankAnswer: answerData,
|
||||
);
|
||||
// if (isAnswerCorrect.value) {
|
||||
// navController.refreshRewardBook.value++;
|
||||
// }
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 3) {
|
||||
final q = groupQuiz;
|
||||
if (q == null) return;
|
||||
final options = q.options ?? const [];
|
||||
if (options.isEmpty) return;
|
||||
|
||||
var allAssigned = true;
|
||||
var allCorrect = true;
|
||||
|
||||
for (final option in options) {
|
||||
final id = option.id;
|
||||
final correctGroup = option.correctGroupId;
|
||||
if (id == null) continue;
|
||||
final assigned = assignedGroupByOption[id];
|
||||
if (assigned == null) {
|
||||
allAssigned = false;
|
||||
allCorrect = false;
|
||||
continue;
|
||||
}
|
||||
if (correctGroup != null && assigned != correctGroup) {
|
||||
allCorrect = false;
|
||||
}
|
||||
}
|
||||
|
||||
isAnswerChecked.value = true;
|
||||
isAnswerCorrect.value = allAssigned && allCorrect;
|
||||
retryQuestion.value--;
|
||||
|
||||
final answerData = QuizGroupAnswerEntity(
|
||||
assignedGroupByOption: Map<String, String>.from(assignedGroupByOption),
|
||||
);
|
||||
final questionJson = QuizGroupMapper.entityToRemote(q).toJson();
|
||||
final answerJson =
|
||||
QuizGroupAnswerMapper.entityToRemote(answerData).toJson();
|
||||
|
||||
if (isAnswerCorrect.value) {
|
||||
EpicSnackBar.showSuccessSnackBar(
|
||||
'Benar',
|
||||
'Semua item sudah di kelompok yang tepat.',
|
||||
);
|
||||
} else {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Salah',
|
||||
allAssigned
|
||||
? 'Masih ada item di kelompok yang salah.'
|
||||
: 'Masukkan semua item ke group.',
|
||||
);
|
||||
}
|
||||
|
||||
await updateHistoryAnswer(
|
||||
quizType: type,
|
||||
isCorrect: isAnswerCorrect.value,
|
||||
question: jsonEncode(questionJson),
|
||||
answer: jsonEncode(answerJson),
|
||||
explanation: q.explanation,
|
||||
groupQuestion: q,
|
||||
groupAnswer: answerData,
|
||||
);
|
||||
// if (isAnswerCorrect.value) {
|
||||
// navController.refreshRewardBook.value++;
|
||||
// }
|
||||
return;
|
||||
}
|
||||
|
||||
EpicLog.debug('BookQuizController - unsupported quiz type: $type');
|
||||
}
|
||||
|
||||
void showExplanationDialog(String? explanationText) {
|
||||
Get.dialog(
|
||||
Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.topCenter,
|
||||
children: [
|
||||
// Main card
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 44),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6B42B8),
|
||||
width: 2.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6B42B8).withOpacity(0.25),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(21.5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Purple gradient header
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Color(0xFF6B42B8), Color(0xFF9C5FD4)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 18, 14),
|
||||
child: const Row(
|
||||
children: [
|
||||
Text('💡', style: TextStyle(fontSize: 22)),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Penjelasan',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
explanationText ?? 'Penjelasan belum tersedia.',
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF37474F),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6B42B8),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF6B42B8)
|
||||
.withOpacity(0.3),
|
||||
offset: const Offset(0, 3),
|
||||
blurRadius: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'Tutup',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF6B42B8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Lightbulb badge
|
||||
Positioned(
|
||||
top: 0,
|
||||
child: Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFFFE566), Color(0xFFFFB300)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(
|
||||
color: const Color(0xFF6B42B8),
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFFFFB300).withOpacity(0.5),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Center(
|
||||
child: Text('💡', style: TextStyle(fontSize: 42)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
barrierDismissible: true,
|
||||
barrierColor: Colors.black.withOpacity(0.4),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> forceGenerateQuiz() async {
|
||||
if (isLoading.value) return;
|
||||
|
||||
final history = bookHistory.value;
|
||||
if (history == null) return;
|
||||
|
||||
final text = _theoryText.trim();
|
||||
if (text.isEmpty) {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Quiz',
|
||||
'Materi buku kosong, quiz tidak bisa digenerate ulang.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final type = _bookQuizType ?? quizType;
|
||||
if (type < 1 || type > 3) {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Quiz',
|
||||
'Tipe quiz tidak valid untuk generate ulang.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final previousQuestions = _extractPreviousQuestions(history.quiz);
|
||||
final generatedQuiz = await _tryGenerateQuizByQuizType(
|
||||
quizType: type,
|
||||
text: text,
|
||||
category: currentBook.value?.category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (generatedQuiz == null) {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Quiz',
|
||||
'Gagal generate ulang quiz.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedHistory = history.copyWith(
|
||||
quiz: generatedQuiz,
|
||||
lastReadAt: DateTime.now(),
|
||||
);
|
||||
await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
|
||||
bookHistory.value = updatedHistory;
|
||||
syncCurrentQuiz();
|
||||
|
||||
EpicSnackBar.showSuccessSnackBar(
|
||||
'Success',
|
||||
'Quiz berhasil digenerate ulang.',
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'forceGenerateQuiz');
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Quiz',
|
||||
'Terjadi kesalahan saat generate ulang quiz.',
|
||||
);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _extractPreviousQuestions(BookHistoryQuizEntity? quiz) {
|
||||
if (quiz == null) return [];
|
||||
final List<String> previous = [];
|
||||
if (quiz.multipleChoiceQuestion?.question != null) {
|
||||
previous.add(quiz.multipleChoiceQuestion!.question!);
|
||||
}
|
||||
if (quiz.fillBlankQuestion?.sentence != null) {
|
||||
final sentenceText = quiz.fillBlankQuestion!.sentence!
|
||||
.map((s) => s.type == 'blank' ? '___' : (s.value ?? ''))
|
||||
.join();
|
||||
if (sentenceText.trim().isNotEmpty) previous.add(sentenceText);
|
||||
}
|
||||
if (quiz.groupQuestion?.groups != null) {
|
||||
final groupTitles = quiz.groupQuestion!.groups!
|
||||
.map((g) => g.title ?? '')
|
||||
.where((t) => t.isNotEmpty)
|
||||
.join(' vs ');
|
||||
if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles');
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
Future<BookHistoryQuizEntity?> _tryGenerateQuizByQuizType({
|
||||
required int quizType,
|
||||
required String text,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
if (quizType == 1) {
|
||||
final choiceQuiz = await _tryToGenerateMultipleChoiceQuiz(
|
||||
fullText: text,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (choiceQuiz != null) {
|
||||
final remote = QuizChoiceMapper.entityToRemote(choiceQuiz);
|
||||
return BookHistoryQuizEntity(
|
||||
quizType: 1,
|
||||
retry: 3,
|
||||
question: jsonEncode(remote.toJson()),
|
||||
answer: null,
|
||||
explanation: choiceQuiz.explanation,
|
||||
multipleChoiceQuestion: choiceQuiz,
|
||||
multipleChoiceAnswer: null,
|
||||
fillBlankQuestion: null,
|
||||
fillBlankAnswer: null,
|
||||
groupQuestion: null,
|
||||
groupAnswer: null,
|
||||
);
|
||||
}
|
||||
} else if (quizType == 2) {
|
||||
final fillBlankQuiz = await _tryToGenerateFillBlankQuiz(
|
||||
fullText: text,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (fillBlankQuiz != null) {
|
||||
final remote = QuizFillBlankMapper.entityToRemote(fillBlankQuiz);
|
||||
return BookHistoryQuizEntity(
|
||||
quizType: 2,
|
||||
retry: 3,
|
||||
question: jsonEncode(remote.toJson()),
|
||||
answer: null,
|
||||
explanation: fillBlankQuiz.explanation,
|
||||
multipleChoiceQuestion: null,
|
||||
multipleChoiceAnswer: null,
|
||||
fillBlankQuestion: fillBlankQuiz,
|
||||
fillBlankAnswer: null,
|
||||
groupQuestion: null,
|
||||
groupAnswer: null,
|
||||
);
|
||||
}
|
||||
} else if (quizType == 3) {
|
||||
final groupQuiz = await _tryToGenerateGroupQuiz(
|
||||
fullText: text,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (groupQuiz != null) {
|
||||
final remote = QuizGroupMapper.entityToRemote(groupQuiz);
|
||||
return BookHistoryQuizEntity(
|
||||
quizType: 3,
|
||||
retry: 3,
|
||||
question: jsonEncode(remote.toJson()),
|
||||
answer: null,
|
||||
explanation: groupQuiz.explanation,
|
||||
multipleChoiceQuestion: null,
|
||||
multipleChoiceAnswer: null,
|
||||
fillBlankQuestion: null,
|
||||
fillBlankAnswer: null,
|
||||
groupQuestion: groupQuiz,
|
||||
groupAnswer: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<QuizChoiceEntity?> _tryToGenerateMultipleChoiceQuiz({
|
||||
required String fullText,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
final quizJson = await quizUsecase.generate(
|
||||
prompt: QuizPrompt.multipleChoicePrompt(
|
||||
text: fullText,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
),
|
||||
);
|
||||
if (quizJson == null) return null;
|
||||
|
||||
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
|
||||
final quizModel = QuizChoiceRemoteModel.fromJson(parsed);
|
||||
// === VALIDASI ===
|
||||
if (quizModel.question == null ||
|
||||
quizModel.choices == null ||
|
||||
quizModel.choices!.length != 4 ||
|
||||
quizModel.correctedIndex == null ||
|
||||
quizModel.correctedIndex! < 0 ||
|
||||
quizModel.correctedIndex! >= quizModel.choices!.length) {
|
||||
EpicLog.debug(
|
||||
'_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
|
||||
return null;
|
||||
}
|
||||
final quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
|
||||
return quizEntity.copyWith(
|
||||
quizDataHelper: quizModel.toJson().toString(),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<QuizFillBlankEntity?> _tryToGenerateFillBlankQuiz({
|
||||
required String fullText,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
final quizJson = await quizUsecase.generate(
|
||||
prompt: QuizPrompt.fillBlankPrompt(
|
||||
text: fullText,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
),
|
||||
);
|
||||
if (quizJson == null) return null;
|
||||
|
||||
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
|
||||
final quizModel = QuizFillBlankRemoteModel.fromJson(parsed);
|
||||
// Validasi fill blank:
|
||||
if (quizModel.sentence == null ||
|
||||
quizModel.options == null ||
|
||||
quizModel.options!.length != 4 ||
|
||||
quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
|
||||
quizModel.options!
|
||||
.any((o) => o.value == null || o.correctBlankId == null)) {
|
||||
EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
|
||||
return null;
|
||||
}
|
||||
final quizEntity = QuizFillBlankMapper.remoteToEntity(quizModel);
|
||||
return quizEntity.copyWith(
|
||||
quizDataHelper: quizModel.toJson().toString(),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryToGenerateFillBlankQuiz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<QuizGroupEntity?> _tryToGenerateGroupQuiz({
|
||||
required String fullText,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
final quizJson = await quizUsecase.generate(
|
||||
prompt: QuizPrompt.groupQuizPrompt(
|
||||
text: fullText,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
),
|
||||
);
|
||||
if (quizJson == null) return null;
|
||||
|
||||
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
|
||||
final quizModel = QuizGroupRemoteModel.fromJson(parsed);
|
||||
// Validasi group quiz:
|
||||
final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
|
||||
if (quizModel.groups == null ||
|
||||
quizModel.groups!.length != 2 ||
|
||||
quizModel.options == null ||
|
||||
quizModel.options!.length != 8 ||
|
||||
quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
|
||||
EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
|
||||
return null;
|
||||
}
|
||||
final quizEntity = QuizGroupMapper.remoteToEntity(quizModel);
|
||||
return quizEntity.copyWith(
|
||||
quizDataHelper: quizModel.toJson().toString(),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryToGenerateGroupQuiz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> closePage() async {
|
||||
await SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
Get.back(result: bookHistory.value);
|
||||
}
|
||||
|
||||
void openBookRewardClaim() async {
|
||||
await Get.toNamed(
|
||||
EpicRoutes.bookReward,
|
||||
arguments: currentBook.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,600 @@
|
|||
import 'package:epic_story_app/core/styles/epic_box_colors.dart';
|
||||
import 'package:epic_story_app/core/styles/epic_app_size.dart';
|
||||
import 'package:epic_story_app/core/widgets/cards/box_shadow.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookQuizPage extends StatefulWidget {
|
||||
const BookQuizPage({super.key});
|
||||
|
||||
@override
|
||||
State<BookQuizPage> createState() => _BookQuizPageState();
|
||||
}
|
||||
|
||||
class _BookQuizPageState extends State<BookQuizPage> {
|
||||
final BookQuizController controller = Get.find<BookQuizController>();
|
||||
|
||||
void _handleBackTap() {
|
||||
if (controller.isLoading.value) {
|
||||
Get.snackbar(
|
||||
'Quiz',
|
||||
'Tunggu proses generate selesai.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
controller.closePage();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
EpicAppSize.initialize(context);
|
||||
var width = EpicAppSize.screenWidth;
|
||||
var height = EpicAppSize.screenHeight;
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFE0CEFF),
|
||||
appBar: AppBar(
|
||||
backgroundColor: const Color(0xFFE0CEFF),
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
toolbarHeight: 78,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 4),
|
||||
child: Obx(
|
||||
() => _BookQuizTopBar(
|
||||
title: controller.title.value,
|
||||
onBackTap: _handleBackTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: WillPopScope(
|
||||
onWillPop: () async {
|
||||
_handleBackTap();
|
||||
return false;
|
||||
},
|
||||
child: Obx(
|
||||
() => Center(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
ShadowBox(
|
||||
width: width * 0.9,
|
||||
height: height * 0.75,
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 26),
|
||||
cardColor: BoxColors.purplePrimary,
|
||||
radius: BorderRadius.circular(
|
||||
EpicAppSize.calculatedSize(24),
|
||||
),
|
||||
child: _buildQuizBody(),
|
||||
),
|
||||
if (controller.isQuizAvailable)
|
||||
Positioned(
|
||||
bottom: EpicAppSize.calculatedSize(-20),
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: _buildCheckButton(context),
|
||||
),
|
||||
),
|
||||
if (controller.isQuizAvailable)
|
||||
Positioned(
|
||||
top: -20,
|
||||
right: 16,
|
||||
child: _buildOverlayControl(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuizBody() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!controller.isQuizAvailable) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'Quiz belum tersedia untuk buku ini.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: _buildQuizWidgetByType(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuizWidgetByType() {
|
||||
if (controller.quizType == 1 && controller.multipleChoiceQuiz != null) {
|
||||
return BookMultipleChoiceQuiz(
|
||||
quizController: controller,
|
||||
quizData: controller.multipleChoiceQuiz!,
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.quizType == 2 && controller.fillBlankQuiz != null) {
|
||||
return BookFillBlankQuiz(
|
||||
quizController: controller,
|
||||
quizData: controller.fillBlankQuiz!,
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.quizType == 3 && controller.groupQuiz != null) {
|
||||
return BookGroupQuiz(
|
||||
quizController: controller,
|
||||
quizData: controller.groupQuiz!,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildCheckButton(BuildContext context) {
|
||||
if (controller.isLoading.value) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isRewardUnclaimed =
|
||||
controller.bookHistory.value?.claimedRewards == null ||
|
||||
controller.bookHistory.value!.claimedRewards!.isEmpty;
|
||||
|
||||
if (controller.isAnswerCorrect.value) {
|
||||
if (isRewardUnclaimed) {
|
||||
return _PulsingRewardShadowButton(
|
||||
onTap: controller.openBookRewardClaim,
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
height: EpicAppSize.calculatedSize(50),
|
||||
);
|
||||
}
|
||||
|
||||
return ShadowBox(
|
||||
cardColor: BoxColors.bluePrimary,
|
||||
isCanClick: false,
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
height: EpicAppSize.calculatedSize(50),
|
||||
borderWidthRatio: EpicAppSize.borderWidthRatio,
|
||||
radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Kuis Sudah Diselesaikan',
|
||||
style: TextStyle(
|
||||
color: BoxColors.bluePrimary.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final isCanAnswer = (controller.retryQuestion.value > 0) &&
|
||||
controller.isAnswerCorrect.value == false;
|
||||
final bgColor =
|
||||
isCanAnswer ? BoxColors.greenSecondary : BoxColors.grayDisabled;
|
||||
|
||||
return ShadowBox(
|
||||
cardColor: bgColor,
|
||||
isCanClick: isCanAnswer,
|
||||
onTap: controller.onCheckAnswer,
|
||||
width: MediaQuery.of(context).size.width * 0.65,
|
||||
height: EpicAppSize.calculatedSize(50),
|
||||
borderWidthRatio: EpicAppSize.borderWidthRatio,
|
||||
radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cek Jawaban',
|
||||
style: TextStyle(
|
||||
color: bgColor.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverlayControl() {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
final isCorrect = controller.isAnswerCorrect.value;
|
||||
final explanation = controller.explanation;
|
||||
|
||||
if (isCorrect) {
|
||||
return _buildHelpButton(explanation);
|
||||
}
|
||||
|
||||
final retries = controller.retryQuestion.value;
|
||||
if (retries <= 0) {
|
||||
return Row(
|
||||
children: [
|
||||
_PulsingRetryButton(
|
||||
onPressed: () {
|
||||
controller.forceGenerateQuiz();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildHelpButton(explanation),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: BoxColors.purplePrimary.backgroundColor,
|
||||
width: 1.1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
final filled = retries > index;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(left: index == 0 ? 0 : 8),
|
||||
child: Icon(
|
||||
filled ? Icons.favorite : Icons.favorite_border,
|
||||
size: 30,
|
||||
color: filled ? Colors.pink : BoxColors.purplePrimary.textColor,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildHelpButton(String? explanation) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
// Visibility(
|
||||
// visible: controller.bookHistory.value?.claimedRewards == null ||
|
||||
// controller.bookHistory.value!.claimedRewards!.isEmpty,
|
||||
// child: GestureDetector(
|
||||
// onTap: () {
|
||||
// controller.openBookRewardClaim();
|
||||
// },
|
||||
// child: Container(
|
||||
// padding: const EdgeInsets.symmetric(
|
||||
// horizontal: 10,
|
||||
// vertical: 6,
|
||||
// ),
|
||||
// decoration: BoxDecoration(
|
||||
// color: Colors.white,
|
||||
// borderRadius: BorderRadius.circular(14),
|
||||
// border: Border.all(
|
||||
// color: BoxColors.purplePrimary.backgroundColor,
|
||||
// width: 1.1,
|
||||
// ),
|
||||
// ),
|
||||
// child: Text(
|
||||
// 'Dapatkan Hadiah',
|
||||
// style: TextStyle(
|
||||
// color: BoxColors.purplePrimary.textColor,
|
||||
// fontSize: 14,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
SizedBox(width: 8),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => controller.showExplanationDialog(explanation),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFD700),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: BoxColors.purplePrimary.backgroundColor,
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.help_outline,
|
||||
size: 22,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookQuizTopBar extends StatelessWidget {
|
||||
const _BookQuizTopBar({
|
||||
required this.title,
|
||||
required this.onBackTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onBackTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_TopBackButton(onTap: onBackTap),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 55,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/judul-kartu.png',
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
title.isEmpty ? 'Quiz' : title,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
softWrap: false,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
height: 1,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TopBackButton extends StatelessWidget {
|
||||
const _TopBackButton({required this.onTap});
|
||||
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Ink(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.18),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 21,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulsingRetryButton extends StatefulWidget {
|
||||
const _PulsingRetryButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_PulsingRetryButton> createState() => _PulsingRetryButtonState();
|
||||
}
|
||||
|
||||
class _PulsingRetryButtonState extends State<_PulsingRetryButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 0.94, end: 1.06).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: BoxColors.purplePrimary.textColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
side: BorderSide(
|
||||
color: BoxColors.purplePrimary.backgroundColor,
|
||||
width: 1.1,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
onPressed: widget.onPressed,
|
||||
child: const Text(
|
||||
'Coba Lagi',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulsingRewardShadowButton extends StatefulWidget {
|
||||
const _PulsingRewardShadowButton({
|
||||
required this.onTap,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
State<_PulsingRewardShadowButton> createState() =>
|
||||
_PulsingRewardShadowButtonState();
|
||||
}
|
||||
|
||||
class _PulsingRewardShadowButtonState extends State<_PulsingRewardShadowButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.05).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: ShadowBox(
|
||||
cardColor: BoxColors.yellowPrimary,
|
||||
isCanClick: true,
|
||||
onTap: widget.onTap,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
borderWidthRatio: EpicAppSize.borderWidthRatio,
|
||||
radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Dapatkan Hadiah',
|
||||
style: TextStyle(
|
||||
color: BoxColors.yellowPrimary.textColor,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RayBurstController extends ChangeNotifier {
|
||||
void fire() => notifyListeners();
|
||||
}
|
||||
|
||||
class RayBurst extends StatefulWidget {
|
||||
const RayBurst({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.child,
|
||||
this.color = const Color(0xFF6B42B8),
|
||||
this.duration = const Duration(milliseconds: 380),
|
||||
this.rayCount = 10,
|
||||
});
|
||||
|
||||
final RayBurstController controller;
|
||||
final Widget child;
|
||||
final Color color;
|
||||
final Duration duration;
|
||||
final int rayCount;
|
||||
|
||||
@override
|
||||
State<RayBurst> createState() => _RayBurstState();
|
||||
}
|
||||
|
||||
class _RayBurstState extends State<RayBurst>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_anim = AnimationController(vsync: this, duration: widget.duration)
|
||||
..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
_anim.reset();
|
||||
}
|
||||
});
|
||||
widget.controller.addListener(_onFire);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RayBurst oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.controller != widget.controller) {
|
||||
oldWidget.controller.removeListener(_onFire);
|
||||
widget.controller.addListener(_onFire);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onFire);
|
||||
_anim.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFire() {
|
||||
_anim.forward(from: 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child!,
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _RayPainter(
|
||||
progress: _anim.value,
|
||||
color: widget.color,
|
||||
rayCount: widget.rayCount,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RayPainter extends CustomPainter {
|
||||
_RayPainter({
|
||||
required this.progress,
|
||||
required this.color,
|
||||
required this.rayCount,
|
||||
});
|
||||
|
||||
final double progress;
|
||||
final Color color;
|
||||
final int rayCount;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (progress <= 0) return;
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final maxLen = (math.min(size.width, size.height) / 2) + 32;
|
||||
final len = maxLen * progress;
|
||||
final strokeWidth = 3.0 * (1 - progress).clamp(0.3, 1.0);
|
||||
final paint = Paint()
|
||||
..color = color.withOpacity(0.7 * (1 - progress))
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
for (var i = 0; i < rayCount; i++) {
|
||||
final angle = (2 * math.pi / rayCount) * i;
|
||||
final dx = math.cos(angle) * len;
|
||||
final dy = math.sin(angle) * len;
|
||||
canvas.drawLine(center, center.translate(dx, dy), paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _RayPainter oldDelegate) {
|
||||
return oldDelegate.progress != progress ||
|
||||
oldDelegate.color != color ||
|
||||
oldDelegate.rayCount != rayCount;
|
||||
}
|
||||
}
|
||||
|
||||
class RayDraggableChip<T extends Object> extends StatefulWidget {
|
||||
const RayDraggableChip({
|
||||
super.key,
|
||||
required this.data,
|
||||
required this.child,
|
||||
required this.feedback,
|
||||
this.childWhenDragging,
|
||||
this.color = const Color(0xFF6B42B8),
|
||||
});
|
||||
|
||||
final T data;
|
||||
final Widget child;
|
||||
final Widget feedback;
|
||||
final Widget? childWhenDragging;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<RayDraggableChip<T>> createState() => _RayDraggableChipState<T>();
|
||||
}
|
||||
|
||||
class _RayDraggableChipState<T extends Object>
|
||||
extends State<RayDraggableChip<T>> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Draggable<T>(
|
||||
data: widget.data,
|
||||
feedback: widget.feedback,
|
||||
childWhenDragging: widget.childWhenDragging,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookFillBlankQuiz extends StatelessWidget {
|
||||
const BookFillBlankQuiz({
|
||||
super.key,
|
||||
required this.quizController,
|
||||
required this.quizData,
|
||||
});
|
||||
|
||||
final BookQuizController quizController;
|
||||
final QuizFillBlankEntity quizData;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sentences = quizData.sentence ?? [];
|
||||
final options = quizData.options ?? [];
|
||||
|
||||
// limit to 8 options to mirror mock scale
|
||||
final limitedOptions = options.take(8).toList();
|
||||
quizController.cleanupAssignments(
|
||||
limitedOptions.where((o) => o.id != null).map((o) => o.id!).toSet(),
|
||||
);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SentenceArea(
|
||||
sentences: sentences,
|
||||
quizController: quizController,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Obx(() {
|
||||
final isLocked = quizController.isAnswerCorrect.value ||
|
||||
quizController.retryQuestion.value == 0;
|
||||
return _OptionsTray(
|
||||
options: limitedOptions,
|
||||
quizController: quizController,
|
||||
isLocked: isLocked,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SentenceArea extends StatelessWidget {
|
||||
const _SentenceArea({
|
||||
required this.sentences,
|
||||
required this.quizController,
|
||||
});
|
||||
|
||||
final List<QuizFillBlankSentenceEntity> sentences;
|
||||
final BookQuizController quizController;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
alignment: WrapAlignment.start,
|
||||
runSpacing: 8,
|
||||
spacing: 8,
|
||||
children: sentences.map((part) {
|
||||
if (part.type == 'blank') {
|
||||
final blankId = part.id ?? '';
|
||||
return _BlankTarget(
|
||||
blankId: blankId,
|
||||
quizController: quizController,
|
||||
);
|
||||
}
|
||||
return Text(
|
||||
part.value ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.35,
|
||||
color: Colors.black,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlankTarget extends StatefulWidget {
|
||||
const _BlankTarget({
|
||||
required this.blankId,
|
||||
required this.quizController,
|
||||
});
|
||||
|
||||
final String blankId;
|
||||
final BookQuizController quizController;
|
||||
|
||||
@override
|
||||
State<_BlankTarget> createState() => _BlankTargetState();
|
||||
}
|
||||
|
||||
class _BlankTargetState extends State<_BlankTarget>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final RayBurstController _rayController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rayController = RayBurstController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rayController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
widget.quizController.assignedBlankByOption.length;
|
||||
widget.quizController.isAnswerChecked.value;
|
||||
|
||||
QuizFillBlankOptionEntity? assignedOption;
|
||||
widget.quizController.assignedBlankByOption.forEach((optId, bId) {
|
||||
if (bId == widget.blankId) {
|
||||
assignedOption = widget.quizController.fillBlankQuiz?.options
|
||||
?.firstWhereOrNull((opt) => opt.id == optId);
|
||||
}
|
||||
});
|
||||
|
||||
final isChecked = widget.quizController.isAnswerChecked.value;
|
||||
final isCorrect = assignedOption != null &&
|
||||
assignedOption!.correctBlankId != null &&
|
||||
assignedOption!.correctBlankId == widget.blankId;
|
||||
final isLocked = widget.quizController.isAnswerCorrect.value ||
|
||||
widget.quizController.retryQuestion.value == 0;
|
||||
|
||||
Color border = const Color(0xFF7D7D7D);
|
||||
Color bg = Colors.white;
|
||||
if (assignedOption != null && isChecked) {
|
||||
border = isCorrect ? Colors.green : Colors.red;
|
||||
bg = isCorrect ? const Color(0x102ECC71) : const Color(0x10F44336);
|
||||
}
|
||||
|
||||
return DragTarget<QuizFillBlankOptionEntity>(
|
||||
onWillAccept: (_) => !isLocked,
|
||||
onAccept: (incoming) {
|
||||
if (isLocked) return;
|
||||
final optId = incoming.id;
|
||||
if (optId == null) return;
|
||||
widget.quizController
|
||||
.assignOptionToBlank(optionId: optId, blankId: widget.blankId);
|
||||
_rayController.fire();
|
||||
},
|
||||
builder: (context, candidate, rejected) {
|
||||
final hovering = candidate.isNotEmpty;
|
||||
return RayBurst(
|
||||
controller: _rayController,
|
||||
color: const Color(0xFF6B42B8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovering ? const Color(0x0F6B42B8) : bg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: hovering ? const Color(0xFF6B42B8) : border,
|
||||
width: 1.4,
|
||||
),
|
||||
),
|
||||
child: assignedOption == null
|
||||
? const SizedBox(width: 56, height: 20)
|
||||
: (isLocked
|
||||
? _Chip(label: assignedOption!.value ?? '')
|
||||
: RayDraggableChip<QuizFillBlankOptionEntity>(
|
||||
data: assignedOption!,
|
||||
color: const Color(0xFF6B42B8),
|
||||
feedback: _Chip(
|
||||
label: assignedOption!.value ?? '',
|
||||
isDragging: true,
|
||||
),
|
||||
childWhenDragging:
|
||||
const SizedBox(width: 56, height: 20),
|
||||
child: _Chip(label: assignedOption!.value ?? ''),
|
||||
)),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionsTray extends StatefulWidget {
|
||||
const _OptionsTray({
|
||||
required this.options,
|
||||
required this.quizController,
|
||||
required this.isLocked,
|
||||
});
|
||||
|
||||
final List<QuizFillBlankOptionEntity> options;
|
||||
final BookQuizController quizController;
|
||||
final bool isLocked;
|
||||
|
||||
@override
|
||||
State<_OptionsTray> createState() => _OptionsTrayState();
|
||||
}
|
||||
|
||||
class _OptionsTrayState extends State<_OptionsTray> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final RayBurstController _rayController;
|
||||
|
||||
List<QuizFillBlankOptionEntity> _unassigned() {
|
||||
return widget.options.where((opt) {
|
||||
final id = opt.id;
|
||||
if (id == null) return true;
|
||||
return !widget.quizController.assignedBlankByOption.containsKey(id);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_rayController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rayController = RayBurstController();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
widget.quizController.assignedBlankByOption.length;
|
||||
final pool = _unassigned();
|
||||
return DragTarget<QuizFillBlankOptionEntity>(
|
||||
onWillAccept: (_) => !widget.isLocked,
|
||||
onAccept: (incoming) {
|
||||
if (widget.isLocked) return;
|
||||
final optId = incoming.id;
|
||||
if (optId == null) return;
|
||||
widget.quizController
|
||||
.assignOptionToBlank(optionId: optId, blankId: null);
|
||||
_rayController.fire();
|
||||
},
|
||||
builder: (context, candidate, rejected) {
|
||||
final hovering = candidate.isNotEmpty;
|
||||
return RayBurst(
|
||||
controller: _rayController,
|
||||
color: const Color(0xFF6B42B8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: 96,
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: hovering ? const Color(0x0F6B42B8) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
radius: const Radius.circular(8),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Row(
|
||||
children: pool.map((opt) {
|
||||
final chip = _Chip(label: opt.value ?? '-');
|
||||
if (widget.isLocked) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: RayDraggableChip<QuizFillBlankOptionEntity>(
|
||||
data: opt,
|
||||
color: const Color(0xFF6B42B8),
|
||||
feedback:
|
||||
_Chip(label: opt.value ?? '-', isDragging: true),
|
||||
childWhenDragging:
|
||||
const SizedBox(width: 64, height: 32),
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
const _Chip({
|
||||
required this.label,
|
||||
this.isDragging = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool isDragging;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
elevation: isDragging ? 6 : 0,
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFF7D7D7D), width: 1.2),
|
||||
boxShadow: isDragging
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x22000000),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 3),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookGroupQuiz extends StatelessWidget {
|
||||
const BookGroupQuiz({
|
||||
super.key,
|
||||
required this.quizController,
|
||||
required this.quizData,
|
||||
});
|
||||
|
||||
final BookQuizController quizController;
|
||||
final QuizGroupEntity quizData;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groups = (quizData.groups ?? []).take(2).toList();
|
||||
final options = (quizData.options ?? []).take(8).toList();
|
||||
quizController.cleanupAssignments(
|
||||
options.where((o) => o.id != null).map((o) => o.id!).toSet(),
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Obx(() {
|
||||
// observe changes to trigger rebuilds
|
||||
quizController.assignedGroupByOption.length;
|
||||
quizController.isAnswerChecked.value;
|
||||
final isLocked = quizController.isAnswerCorrect.value ||
|
||||
quizController.retryQuestion.value == 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFBFBFBF), width: 1.2),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 5)),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(groups.length, (index) {
|
||||
final group = groups[index];
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: index == 0 ? 0 : 10,
|
||||
right: index == groups.length - 1 ? 0 : 10,
|
||||
),
|
||||
child: _GroupColumn(
|
||||
group: group,
|
||||
allOptions: options,
|
||||
quizController: quizController,
|
||||
isLocked: isLocked,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 24),
|
||||
Obx(() {
|
||||
final isLocked = quizController.isAnswerCorrect.value ||
|
||||
quizController.retryQuestion.value == 0;
|
||||
return _UnassignedOptionsRow(
|
||||
options: options,
|
||||
quizController: quizController,
|
||||
isLocked: isLocked,
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupColumn extends StatelessWidget {
|
||||
const _GroupColumn({
|
||||
required this.group,
|
||||
required this.allOptions,
|
||||
required this.quizController,
|
||||
required this.isLocked,
|
||||
});
|
||||
|
||||
final QuizGroupItemEntity group;
|
||||
final List<QuizGroupOptionEntity> allOptions;
|
||||
final BookQuizController quizController;
|
||||
final bool isLocked;
|
||||
|
||||
List<QuizGroupOptionEntity> _assignedOptions(String groupId) {
|
||||
return allOptions.where((option) {
|
||||
final optId = option.id;
|
||||
if (optId == null) return false;
|
||||
return quizController.assignedGroupByOption[optId] == groupId;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groupId = group.id ?? '';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5BE),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFBFBFBF), width: 1.2),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
child: Center(
|
||||
child: Text(
|
||||
group.title ?? '-',
|
||||
softWrap: true,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
..._buildDropSlots(groupId),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDropSlots(String groupId) {
|
||||
final assigned = _assignedOptions(groupId);
|
||||
const slotsCount = 4; // static max 4 slots per group
|
||||
return List.generate(slotsCount, (index) {
|
||||
final option = index < assigned.length ? assigned[index] : null;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _GroupDropSlot(
|
||||
groupId: groupId,
|
||||
option: option,
|
||||
quizController: quizController,
|
||||
isLocked: isLocked,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _GroupDropSlot extends StatefulWidget {
|
||||
const _GroupDropSlot({
|
||||
required this.groupId,
|
||||
required this.option,
|
||||
required this.quizController,
|
||||
required this.isLocked,
|
||||
});
|
||||
|
||||
final String groupId;
|
||||
final QuizGroupOptionEntity? option;
|
||||
final BookQuizController quizController;
|
||||
final bool isLocked;
|
||||
|
||||
@override
|
||||
State<_GroupDropSlot> createState() => _GroupDropSlotState();
|
||||
}
|
||||
|
||||
class _GroupDropSlotState extends State<_GroupDropSlot> {
|
||||
late final RayBurstController _rayController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rayController = RayBurstController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_rayController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isChecked = widget.quizController.isAnswerChecked.value;
|
||||
final isCorrect = widget.option != null &&
|
||||
widget.option!.correctGroupId != null &&
|
||||
widget.option!.correctGroupId == widget.groupId;
|
||||
|
||||
Color borderColor = const Color(0xFF6B6B6B);
|
||||
Color fillColor = Colors.white;
|
||||
if (widget.option != null && isChecked) {
|
||||
borderColor = isCorrect ? Colors.green : Colors.red;
|
||||
fillColor = isCorrect ? const Color(0x102ECC71) : const Color(0x10F44336);
|
||||
}
|
||||
|
||||
return DragTarget<QuizGroupOptionEntity>(
|
||||
onWillAccept: (incoming) {
|
||||
if (widget.isLocked) return false;
|
||||
if (incoming?.id == null) return false;
|
||||
final optId = incoming!.id!;
|
||||
final existing = widget.quizController.assignedGroupByOption[optId];
|
||||
if (existing == widget.groupId) return true;
|
||||
return widget.quizController.countForGroup(widget.groupId) < 4;
|
||||
},
|
||||
onAccept: (incoming) {
|
||||
if (widget.isLocked) return;
|
||||
final optId = incoming.id;
|
||||
if (optId == null) return;
|
||||
widget.quizController
|
||||
.assignOptionToGroup(optionId: optId, groupId: widget.groupId);
|
||||
_rayController.fire();
|
||||
},
|
||||
builder: (context, candidate, rejected) {
|
||||
final isHovering = candidate.isNotEmpty;
|
||||
return RayBurst(
|
||||
controller: _rayController,
|
||||
color: const Color(0xFF6B42B8),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 140),
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: isHovering ? const Color(0x0F6B42B8) : fillColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: isHovering ? const Color(0xFF6B42B8) : borderColor,
|
||||
width: 1.2,
|
||||
),
|
||||
),
|
||||
child: widget.option == null
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Taruh disini',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B6B6B),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Center(
|
||||
child: _DraggableOptionCard(
|
||||
option: widget.option!,
|
||||
quizController: widget.quizController,
|
||||
groupId: widget.groupId,
|
||||
isLocked: widget.isLocked,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DraggableOptionCard extends StatelessWidget {
|
||||
const _DraggableOptionCard({
|
||||
required this.option,
|
||||
required this.quizController,
|
||||
required this.groupId,
|
||||
required this.isLocked,
|
||||
});
|
||||
|
||||
final QuizGroupOptionEntity option;
|
||||
final BookQuizController quizController;
|
||||
final String groupId;
|
||||
final bool isLocked;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isChecked = quizController.isAnswerChecked.value;
|
||||
final isCorrect =
|
||||
option.correctGroupId != null && option.correctGroupId == groupId;
|
||||
|
||||
if (isLocked) {
|
||||
return _OptionChip(
|
||||
label: option.value ?? '-',
|
||||
isCorrect: isChecked ? isCorrect : null,
|
||||
);
|
||||
}
|
||||
|
||||
return Draggable<QuizGroupOptionEntity>(
|
||||
data: option,
|
||||
feedback: _OptionChip(
|
||||
label: option.value ?? '-',
|
||||
isDragging: true,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
child: _OptionChip(
|
||||
label: option.value ?? '-',
|
||||
isCorrect: isChecked ? isCorrect : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnassignedOptionsRow extends StatefulWidget {
|
||||
const _UnassignedOptionsRow({
|
||||
required this.options,
|
||||
required this.quizController,
|
||||
required this.isLocked,
|
||||
});
|
||||
|
||||
final List<QuizGroupOptionEntity> options;
|
||||
final BookQuizController quizController;
|
||||
final bool isLocked;
|
||||
|
||||
@override
|
||||
State<_UnassignedOptionsRow> createState() => _UnassignedOptionsRowState();
|
||||
}
|
||||
|
||||
class _UnassignedOptionsRowState extends State<_UnassignedOptionsRow> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final RayBurstController _rayController;
|
||||
|
||||
List<QuizGroupOptionEntity> _unassigned() {
|
||||
return widget.options.where((option) {
|
||||
final id = option.id;
|
||||
if (id == null) return true;
|
||||
return !widget.quizController.assignedGroupByOption.containsKey(id);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_rayController = RayBurstController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
_rayController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
widget.quizController.assignedGroupByOption.length;
|
||||
return DragTarget<QuizGroupOptionEntity>(
|
||||
onWillAccept: (_) => !widget.isLocked,
|
||||
onAccept: (incoming) {
|
||||
if (widget.isLocked) return;
|
||||
final optId = incoming.id;
|
||||
if (optId == null) return;
|
||||
widget.quizController
|
||||
.assignOptionToGroup(optionId: optId, groupId: null);
|
||||
_rayController.fire();
|
||||
},
|
||||
builder: (context, candidate, rejected) {
|
||||
final items = _unassigned();
|
||||
return RayBurst(
|
||||
controller: _rayController,
|
||||
color: const Color(0xFF6B42B8),
|
||||
child: AnimatedContainer(
|
||||
width: double.infinity,
|
||||
duration: const Duration(milliseconds: 120),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xFFFFF5BE),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFD8D8D8)),
|
||||
),
|
||||
height: 90,
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
radius: const Radius.circular(8),
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Row(
|
||||
children: items.map((option) {
|
||||
final chip = _OptionChip(
|
||||
label: option.value ?? '-',
|
||||
isDragging: false,
|
||||
);
|
||||
if (widget.isLocked) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Draggable<QuizGroupOptionEntity>(
|
||||
data: option,
|
||||
feedback: _OptionChip(
|
||||
label: option.value ?? '-',
|
||||
isDragging: true,
|
||||
),
|
||||
childWhenDragging: const SizedBox.shrink(),
|
||||
child: chip,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionChip extends StatelessWidget {
|
||||
const _OptionChip({
|
||||
required this.label,
|
||||
this.isCorrect,
|
||||
this.isDragging = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool? isCorrect;
|
||||
final bool isDragging;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color border = const Color(0xFF6B6B6B);
|
||||
Color bg = Colors.white;
|
||||
if (isCorrect != null) {
|
||||
border = isCorrect! ? Colors.green : Colors.red;
|
||||
bg = isCorrect! ? const Color(0x102ECC71) : const Color(0x10F44336);
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: isDragging ? 6 : 0,
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: 105,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: border, width: 1.2),
|
||||
boxShadow: isDragging
|
||||
? const [
|
||||
BoxShadow(
|
||||
color: Color(0x22000000),
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: const [
|
||||
BoxShadow(
|
||||
color: Color(0x14000000),
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 3),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: FittedBox(
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:epic_story_app/data/modules/children_module.dart';
|
||||
import 'package:epic_story_app/data/modules/collection_module.dart';
|
||||
import 'package:epic_story_app/data/modules/quiz_module.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookReadBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
ChildrenModule();
|
||||
QuizModule();
|
||||
CollectionModule();
|
||||
Get.lazyPut<BookReadController>(
|
||||
() => BookReadController(
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
quizUsecase: Get.find<QuizUsecase>(),
|
||||
collectionUsecase: Get.find<CollectionUsecase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,973 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:epic_story_app/core/constants/quiz_prompt.dart';
|
||||
import 'package:epic_story_app/core/routes/epic_routes.dart';
|
||||
import 'package:epic_story_app/core/utils/epic_log.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/quiz_choice_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/quiz_fill_blank_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/quiz/quiz_choice_remote_model.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/quiz/quiz_group_remote_model.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_history_quiz_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_pdfview/flutter_pdfview.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookReadController extends GetxController {
|
||||
late BookEntity book;
|
||||
final Rxn<BookHistoryEntity> bookHistory = Rxn<BookHistoryEntity>();
|
||||
final collections = <ChildrenCollectionEntity>[].obs;
|
||||
|
||||
final RxInt currentIndex = 0.obs;
|
||||
final RxInt totalPages = 0.obs;
|
||||
final RxBool isLoading = false.obs;
|
||||
final RxBool isPdfLoading = false.obs;
|
||||
final RxnString pdfPath = RxnString();
|
||||
final RxnString error = RxnString();
|
||||
final Rxn<PDFViewController> pdfController = Rxn<PDFViewController>();
|
||||
final RxBool isSoundOn = true.obs;
|
||||
final Stopwatch _readingTimer = Stopwatch();
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
final List<int> _visiblePdfPages =
|
||||
<int>[]; // PDF page indexes that are allowed to show
|
||||
final Map<int, int> _pdfToVisibleIndex =
|
||||
<int, int>{}; // PDF index -> visible index
|
||||
final Map<int, String> _pageTextByPdfIndex = <int, String>{};
|
||||
int _pdfTotalPages = 0;
|
||||
int _lastPdfPage = -1;
|
||||
DateTime _sessionStartedAt = DateTime.now();
|
||||
int _initialReadingSeconds = 0;
|
||||
bool _isBookInitialized = false;
|
||||
bool _isBootstrapping = false;
|
||||
bool _isDisposing = false;
|
||||
bool _isPersistingHistory = false;
|
||||
bool _pendingPersistHistory = false;
|
||||
int _pageChangeTicket = 0;
|
||||
Timer? _persistDebounce;
|
||||
var allTextTheory = ''.obs; // berisi gabungan semua teks dari kartu
|
||||
|
||||
static const Duration _historyTimeout = Duration(seconds: 8);
|
||||
static const Duration _collectionRemoteTimeout = Duration(seconds: 10);
|
||||
static const Duration _collectionMutationTimeout = Duration(seconds: 12);
|
||||
static const Duration _quizGenerationTimeout = Duration(seconds: 14);
|
||||
|
||||
final navController = Get.find<EpicNavigationController>();
|
||||
final mainController = Get.find<MainController>();
|
||||
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
final QuizUsecase quizUsecase;
|
||||
final CollectionUsecase collectionUsecase;
|
||||
|
||||
BookReadController({
|
||||
required this.childrenUsecase,
|
||||
required this.quizUsecase,
|
||||
required this.collectionUsecase,
|
||||
});
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_isDisposing = true;
|
||||
_persistDebounce?.cancel();
|
||||
_readingTimer.stop();
|
||||
pdfController.value = null;
|
||||
_visiblePdfPages.clear();
|
||||
_pdfToVisibleIndex.clear();
|
||||
_pageTextByPdfIndex.clear();
|
||||
unawaited(_stopTts(force: true));
|
||||
unawaited(pushHistoryToFirestore());
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_sessionStartedAt = DateTime.now();
|
||||
_readingTimer.start();
|
||||
loadBook();
|
||||
}
|
||||
|
||||
Future<void> loadBook() async {
|
||||
if (_isBootstrapping) return;
|
||||
_isBootstrapping = true;
|
||||
|
||||
try {
|
||||
var arg = Get.arguments;
|
||||
if (arg is BookEntity) {
|
||||
book = arg;
|
||||
_isBookInitialized = true;
|
||||
} else {
|
||||
throw ArgumentError('Expected BookEntity as argument');
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// Keep the screen responsive: PDF is loaded in background.
|
||||
isPdfLoading.value = true;
|
||||
unawaited(_loadPdf().whenComplete(() {
|
||||
if (!isClosed) {
|
||||
isPdfLoading.value = false;
|
||||
}
|
||||
}));
|
||||
|
||||
await Future.wait([
|
||||
initBookHistory().timeout(
|
||||
_historyTimeout,
|
||||
onTimeout: () {
|
||||
EpicLog.warn(
|
||||
'initBookHistory timeout, continue with default state');
|
||||
},
|
||||
),
|
||||
_loadCollectionsFromLocal(),
|
||||
]);
|
||||
|
||||
// Refresh collections from remote in background; UI should not block for this.
|
||||
unawaited(_refreshCollectionsFromRemote());
|
||||
|
||||
_buildAllTextTheory();
|
||||
|
||||
// Quiz generation is optional; run in background with timeout.
|
||||
unawaited(_generateQuizSafely());
|
||||
_initTts();
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'loadBook');
|
||||
if (!isClosed && error.value == null) {
|
||||
error.value = 'Gagal memuat buku';
|
||||
}
|
||||
} finally {
|
||||
if (!isClosed) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
_isBootstrapping = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadCollectionsFromLocal() async {
|
||||
try {
|
||||
final children = await childrenUsecase
|
||||
.getCurrentChildren()
|
||||
.timeout(const Duration(seconds: 3));
|
||||
if (!isClosed) {
|
||||
collections.assignAll(children?.collections ?? []);
|
||||
}
|
||||
} on TimeoutException {
|
||||
EpicLog.warn('_loadCollectionsFromLocal timeout');
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_loadCollectionsFromLocal');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshCollectionsFromRemote() async {
|
||||
try {
|
||||
final children = await mainController.childrenUsecase
|
||||
.getChildrenRemoteModel()
|
||||
.timeout(_collectionRemoteTimeout);
|
||||
if (!isClosed) {
|
||||
collections.assignAll(children?.collections ?? []);
|
||||
}
|
||||
} on TimeoutException {
|
||||
EpicLog.warn('_refreshCollectionsFromRemote timeout');
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_refreshCollectionsFromRemote');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _generateQuizSafely() async {
|
||||
try {
|
||||
await generateQuiz().timeout(
|
||||
_quizGenerationTimeout,
|
||||
onTimeout: () {
|
||||
EpicLog.warn('generateQuiz timeout, skipped for this session');
|
||||
},
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_generateQuizSafely');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initBookHistory() async {
|
||||
try {
|
||||
final bookId = book.bookId;
|
||||
if (bookId == null || bookId.isEmpty) return;
|
||||
|
||||
final existing = await childrenUsecase.getBookHistoryById(bookId);
|
||||
if (existing != null) {
|
||||
bookHistory.value = existing;
|
||||
_initialReadingSeconds = existing.totalReadingSeconds ?? 0;
|
||||
}
|
||||
await _persistBookHistory();
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_initBookHistory');
|
||||
}
|
||||
}
|
||||
|
||||
void setInitialSound(bool value) {
|
||||
isSoundOn.value = value;
|
||||
}
|
||||
|
||||
Future<void> onPageChanged(int index) async {
|
||||
if (_isDisposing || isClosed) return;
|
||||
final ticket = ++_pageChangeTicket;
|
||||
|
||||
await _stopTts();
|
||||
if (_isDisposing || isClosed || ticket != _pageChangeTicket) return;
|
||||
|
||||
if (_visiblePdfPages.isEmpty && _pdfTotalPages > 0) {
|
||||
_rebuildVisiblePages(_pdfTotalPages);
|
||||
}
|
||||
|
||||
final visibleIndex = _pdfToVisibleIndex[index];
|
||||
if (visibleIndex != null) {
|
||||
_lastPdfPage = index;
|
||||
currentIndex.value = visibleIndex;
|
||||
_debouncedPersistHistory();
|
||||
if (_isDisposing || isClosed || ticket != _pageChangeTicket) return;
|
||||
await _speakCurrentVisibleText();
|
||||
return;
|
||||
}
|
||||
|
||||
final direction = index >= _lastPdfPage ? 1 : -1;
|
||||
final target = _nextVisiblePdf(index, direction);
|
||||
if (target != null && target != index) {
|
||||
_lastPdfPage = target;
|
||||
pdfController.value?.setPage(target);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void _debouncedPersistHistory() {
|
||||
_persistDebounce?.cancel();
|
||||
_persistDebounce = Timer(const Duration(seconds: 2), () {
|
||||
if (!_isDisposing && !isClosed) {
|
||||
_persistBookHistory();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setTotalPages(int total) {
|
||||
if (_isDisposing || isClosed) return;
|
||||
final sanitizedTotal = total.clamp(0, 10000).toInt();
|
||||
if (_pdfTotalPages == sanitizedTotal && _visiblePdfPages.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
_pdfTotalPages = sanitizedTotal;
|
||||
_rebuildVisiblePages(_pdfTotalPages);
|
||||
}
|
||||
|
||||
void toggleSound() {
|
||||
isSoundOn.toggle();
|
||||
if (isSoundOn.value) {
|
||||
_speakCurrentVisibleText();
|
||||
} else {
|
||||
_stopTts();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> goTo(int index) async {
|
||||
final controller = pdfController.value;
|
||||
if (controller == null) return;
|
||||
if (_visiblePdfPages.isEmpty) return;
|
||||
if (index < 0 || index >= _visiblePdfPages.length) return;
|
||||
await _stopTts();
|
||||
final pdfPage = _visiblePdfPages[index];
|
||||
await controller.setPage(pdfPage);
|
||||
}
|
||||
|
||||
void onPdfViewCreated(PDFViewController controller) {
|
||||
if (_isDisposing || isClosed) return;
|
||||
pdfController.value = controller;
|
||||
}
|
||||
|
||||
Future<void> _loadPdf() async {
|
||||
if (isClosed) return;
|
||||
error.value = null;
|
||||
pdfPath.value = null;
|
||||
currentIndex.value = 0;
|
||||
|
||||
try {
|
||||
final url = book.bookAsset;
|
||||
if (url == null || url.isEmpty) {
|
||||
error.value = 'Book asset tidak tersedia';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache first — avoid re-downloading if PDF already on disk.
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File('${dir.path}/book_${book.bookId ?? 'temp'}.pdf');
|
||||
if (await file.exists()) {
|
||||
final fileSize = await file.length();
|
||||
if (fileSize > 0) {
|
||||
if (!isClosed) {
|
||||
pdfPath.value = file.path;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final uri = Uri.parse(url);
|
||||
final client = HttpClient()
|
||||
..connectionTimeout = const Duration(seconds: 20);
|
||||
try {
|
||||
final request = await client.getUrl(uri);
|
||||
final response = await request.close().timeout(
|
||||
const Duration(seconds: 30),
|
||||
);
|
||||
if (response.statusCode != HttpStatus.ok) {
|
||||
error.value = 'Gagal mengunduh PDF (${response.statusCode})';
|
||||
return;
|
||||
}
|
||||
|
||||
final bytes =
|
||||
await consolidateHttpClientResponseBytes(response).timeout(
|
||||
const Duration(seconds: 40),
|
||||
);
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
if (!isClosed) {
|
||||
pdfPath.value = file.path;
|
||||
}
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (!isClosed) {
|
||||
error.value = 'Waktu memuat PDF habis. Coba buka ulang buku.';
|
||||
}
|
||||
} catch (_) {
|
||||
if (!isClosed) {
|
||||
error.value = 'Gagal memuat PDF';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _rebuildVisiblePages(int pdfTotal) {
|
||||
if (_isDisposing || isClosed) return;
|
||||
_visiblePdfPages.clear();
|
||||
_pdfToVisibleIndex.clear();
|
||||
_pageTextByPdfIndex.clear();
|
||||
if (pdfTotal <= 0) {
|
||||
totalPages.value = 0;
|
||||
currentIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
final skipped = <int>{};
|
||||
final pages = book.pages;
|
||||
if (pages != null && pages.isNotEmpty) {
|
||||
for (var i = 0; i < pages.length; i++) {
|
||||
final page = pages[i];
|
||||
if (page.skipped == true) {
|
||||
final rawIndex =
|
||||
page.nPage != null && page.nPage! > 0 ? page.nPage! - 1 : i;
|
||||
if (rawIndex >= 0 && rawIndex < pdfTotal) {
|
||||
skipped.add(rawIndex);
|
||||
}
|
||||
}
|
||||
|
||||
final text = page.text?.trim();
|
||||
if (text != null && text.isNotEmpty) {
|
||||
final rawIndex =
|
||||
page.nPage != null && page.nPage! > 0 ? page.nPage! - 1 : i;
|
||||
if (rawIndex >= 0 && rawIndex < pdfTotal) {
|
||||
_pageTextByPdfIndex[rawIndex] = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < pdfTotal; i++) {
|
||||
if (!skipped.contains(i)) {
|
||||
_visiblePdfPages.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if all pages were marked skipped, show all pages to avoid empty viewer.
|
||||
if (_visiblePdfPages.isEmpty) {
|
||||
for (var i = 0; i < pdfTotal; i++) {
|
||||
_visiblePdfPages.add(i);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < _visiblePdfPages.length; i++) {
|
||||
_pdfToVisibleIndex[_visiblePdfPages[i]] = i;
|
||||
}
|
||||
|
||||
totalPages.value = _visiblePdfPages.length;
|
||||
|
||||
if (currentIndex.value >= totalPages.value) {
|
||||
currentIndex.value = totalPages.value > 0 ? totalPages.value - 1 : 0;
|
||||
}
|
||||
|
||||
if (_visiblePdfPages.isNotEmpty) {
|
||||
final firstVisible = _visiblePdfPages.first;
|
||||
if (_lastPdfPage == -1 || !_pdfToVisibleIndex.containsKey(_lastPdfPage)) {
|
||||
_lastPdfPage = firstVisible;
|
||||
currentIndex.value = 0;
|
||||
pdfController.value?.setPage(firstVisible);
|
||||
}
|
||||
}
|
||||
|
||||
_speakCurrentVisibleText();
|
||||
}
|
||||
|
||||
int? _nextVisiblePdf(int currentPdf, int direction) {
|
||||
if (_visiblePdfPages.isEmpty) return null;
|
||||
if (direction >= 0) {
|
||||
for (final page in _visiblePdfPages) {
|
||||
if (page > currentPdf) return page;
|
||||
}
|
||||
for (var i = _visiblePdfPages.length - 1; i >= 0; i--) {
|
||||
final page = _visiblePdfPages[i];
|
||||
if (page < currentPdf) return page;
|
||||
}
|
||||
} else {
|
||||
for (var i = _visiblePdfPages.length - 1; i >= 0; i--) {
|
||||
final page = _visiblePdfPages[i];
|
||||
if (page < currentPdf) return page;
|
||||
}
|
||||
for (final page in _visiblePdfPages) {
|
||||
if (page > currentPdf) return page;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _initTts() {
|
||||
_tts.setLanguage('id-ID');
|
||||
_tts.setPitch(1.0);
|
||||
_tts.setSpeechRate(0.5);
|
||||
}
|
||||
|
||||
Future<void> _speakCurrentVisibleText() async {
|
||||
if (_isDisposing || isClosed) return;
|
||||
if (!isSoundOn.value) {
|
||||
await _stopTts();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_visiblePdfPages.isEmpty) {
|
||||
await _stopTts();
|
||||
return;
|
||||
}
|
||||
|
||||
final visibleIdx = currentIndex.value;
|
||||
if (visibleIdx < 0 || visibleIdx >= _visiblePdfPages.length) {
|
||||
await _stopTts();
|
||||
return;
|
||||
}
|
||||
|
||||
final pdfIdx = _visiblePdfPages[visibleIdx];
|
||||
final text = _pageTextByPdfIndex[pdfIdx];
|
||||
if (text == null || text.trim().isEmpty) {
|
||||
await _stopTts();
|
||||
return;
|
||||
}
|
||||
|
||||
await _tts.stop();
|
||||
await _tts.speak(text);
|
||||
}
|
||||
|
||||
Future<void> _stopTts({bool force = false}) async {
|
||||
if (!force && (_isDisposing || isClosed)) return;
|
||||
await _tts.stop();
|
||||
}
|
||||
|
||||
Future<void> _persistBookHistory() async {
|
||||
if (!_isBookInitialized || _isDisposing || isClosed) return;
|
||||
if (_isPersistingHistory) {
|
||||
_pendingPersistHistory = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_isPersistingHistory = true;
|
||||
|
||||
try {
|
||||
do {
|
||||
_pendingPersistHistory = false;
|
||||
if (_isDisposing || isClosed) return;
|
||||
|
||||
final now = DateTime.now();
|
||||
_readingTimer.stop();
|
||||
|
||||
final existing = bookHistory.value;
|
||||
final total =
|
||||
totalPages.value > 0 ? totalPages.value : (book.totalPages ?? 0);
|
||||
int read = currentIndex.value + 1;
|
||||
if (total > 0 && read > total) {
|
||||
read = total;
|
||||
}
|
||||
|
||||
late final BookHistoryEntity history;
|
||||
if (existing != null) {
|
||||
// UPDATE: preserve existing values that should not regress.
|
||||
final mergedRead = (existing.readPages ?? 0) > read
|
||||
? (existing.readPages ?? 0)
|
||||
: read;
|
||||
final readingSeconds =
|
||||
_initialReadingSeconds + _readingTimer.elapsed.inSeconds;
|
||||
|
||||
history = existing.copyWith(
|
||||
lastReadAt: now,
|
||||
readPages: mergedRead,
|
||||
totalReadingSeconds: readingSeconds,
|
||||
);
|
||||
} else {
|
||||
history = BookHistoryEntity(
|
||||
bookId: book.bookId,
|
||||
title: book.title,
|
||||
bookCover: book.bookCover,
|
||||
totalPages: total == 0 ? book.totalPages : total,
|
||||
readPages: read,
|
||||
isFinished: false,
|
||||
startedAt: _sessionStartedAt,
|
||||
lastReadAt: now,
|
||||
finishedAt: null,
|
||||
totalReadingSeconds: _readingTimer.elapsed.inSeconds,
|
||||
isHaveQuiz: book.isHaveQuiz,
|
||||
totalQuizes: book.totalQuizes,
|
||||
completedQuizes: null,
|
||||
category: book.category,
|
||||
);
|
||||
}
|
||||
|
||||
await childrenUsecase.updateBookHistory(bookHistory: history);
|
||||
if (_isDisposing || isClosed) return;
|
||||
bookHistory.value = history;
|
||||
} while (_pendingPersistHistory && !_isDisposing && !isClosed);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_persistBookHistory');
|
||||
} finally {
|
||||
_isPersistingHistory = false;
|
||||
if (!_readingTimer.isRunning && !_isDisposing && !isClosed) {
|
||||
_readingTimer.start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pushHistoryToFirestore() async {
|
||||
if (!_isBookInitialized) return;
|
||||
|
||||
try {
|
||||
var history = bookHistory.value;
|
||||
if (history != null) {
|
||||
await childrenUsecase.updateBookHistory(
|
||||
bookHistory: history,
|
||||
pushToRemote: true,
|
||||
);
|
||||
// navController.refreshRewardBook.value++;
|
||||
EpicLog.debug(
|
||||
'pushHistoryToFirestore - Book history pushed to Firestore for bookId ${book.bookId}');
|
||||
bookHistory.value = history;
|
||||
if (history.isFinished == true &&
|
||||
history.claimedRewards != null &&
|
||||
history.claimedRewards!.isEmpty) {
|
||||
// navController.refreshRewardBook.value++;
|
||||
}
|
||||
} else {
|
||||
EpicLog.debug(
|
||||
'pushHistoryToFirestore - No book history to push for bookId ${book.bookId}');
|
||||
}
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s,
|
||||
'pushHistoryToFirestore - Error pushing book history to Firestore');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addBookToCollection({
|
||||
required String collectionId,
|
||||
required String bookId,
|
||||
}) async {
|
||||
try {
|
||||
Get.back();
|
||||
mainController.showLoadingPage();
|
||||
await collectionUsecase
|
||||
.addBookToCollection(
|
||||
collectionId: collectionId,
|
||||
bookId: bookId,
|
||||
)
|
||||
.timeout(_collectionMutationTimeout);
|
||||
Get.back<void>();
|
||||
navController.refreshCollectionHome.value++;
|
||||
Get.snackbar(
|
||||
'Berhasil',
|
||||
'Buku ditambahkan ke koleksi.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
} on TimeoutException {
|
||||
Get.snackbar(
|
||||
'Timeout',
|
||||
'Proses tambah koleksi terlalu lama. Coba lagi.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'addBookToCollection');
|
||||
Get.snackbar(
|
||||
'Gagal',
|
||||
'Tidak bisa menambah buku ke koleksi.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
} finally {
|
||||
mainController.hideLoadingPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _buildAllTextTheory() {
|
||||
final texts = book.pages
|
||||
?.where((p) => p.skipped != true)
|
||||
.map((p) => p.text?.trim())
|
||||
.where((t) => t != null && t.isNotEmpty)
|
||||
.cast<String>()
|
||||
.toList();
|
||||
allTextTheory.value =
|
||||
texts != null && texts.isNotEmpty ? texts.join('\n') : '';
|
||||
}
|
||||
|
||||
Future<void> generateQuiz({
|
||||
bool forceGenerate = false,
|
||||
}) async {
|
||||
try {
|
||||
if (book.isGenerateQuizSupported != true || book.isHaveQuiz != true) {
|
||||
EpicLog.debug('generateQuiz - skipped, quiz is not supported by book');
|
||||
return;
|
||||
}
|
||||
|
||||
final quizType = book.quizType ?? 0;
|
||||
if (quizType < 1 || quizType > 3) {
|
||||
EpicLog.debug('generateQuiz - skipped, invalid quizType: $quizType');
|
||||
return;
|
||||
}
|
||||
|
||||
final text = allTextTheory.value.trim();
|
||||
if (text.isEmpty) {
|
||||
EpicLog.debug('generateQuiz - skipped, theory text is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
if (bookHistory.value == null) {
|
||||
await _persistBookHistory();
|
||||
}
|
||||
|
||||
var history = bookHistory.value;
|
||||
if (history == null) {
|
||||
EpicLog.debug('generateQuiz - failed generate, book history is null');
|
||||
return;
|
||||
}
|
||||
|
||||
var quizHistory = history.quiz;
|
||||
var isQuizAlreadyGenerated =
|
||||
(quizHistory?.question?.trim().isNotEmpty ?? false);
|
||||
var isNotCompleted = (quizHistory?.answer?.trim().isEmpty ?? true);
|
||||
var totalRetry = quizHistory?.retry ?? 3;
|
||||
var firstAnswer = totalRetry == 3;
|
||||
var isCanGenerate = quizHistory == null ||
|
||||
(isNotCompleted && firstAnswer && !isQuizAlreadyGenerated);
|
||||
|
||||
if (isCanGenerate || forceGenerate) {
|
||||
EpicLog.debug('generateQuiz - ready for generate');
|
||||
final previousQuestions = _extractPreviousQuestions(quizHistory);
|
||||
final generatedQuiz = await _tryGenerateQuizByQuizType(
|
||||
quizType: quizType,
|
||||
text: text,
|
||||
category: book.category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (generatedQuiz == null) {
|
||||
EpicLog.debug('generateQuiz - failed to generate quiz data');
|
||||
return;
|
||||
}
|
||||
|
||||
final updatedHistory = history.copyWith(quiz: generatedQuiz);
|
||||
await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
|
||||
bookHistory.value = updatedHistory;
|
||||
EpicLog.debug('generateQuiz - quiz generated successfully');
|
||||
} else {
|
||||
EpicLog.debug('generateQuiz - rejected for generate');
|
||||
}
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'generateQuiz');
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _extractPreviousQuestions(BookHistoryQuizEntity? quiz) {
|
||||
if (quiz == null) return [];
|
||||
final List<String> previous = [];
|
||||
if (quiz.multipleChoiceQuestion?.question != null) {
|
||||
previous.add(quiz.multipleChoiceQuestion!.question!);
|
||||
}
|
||||
if (quiz.fillBlankQuestion?.sentence != null) {
|
||||
final sentenceText = quiz.fillBlankQuestion!.sentence!
|
||||
.map((s) => s.type == 'blank' ? '___' : (s.value ?? ''))
|
||||
.join();
|
||||
if (sentenceText.trim().isNotEmpty) previous.add(sentenceText);
|
||||
}
|
||||
if (quiz.groupQuestion?.groups != null) {
|
||||
final groupTitles = quiz.groupQuestion!.groups!
|
||||
.map((g) => g.title ?? '')
|
||||
.where((t) => t.isNotEmpty)
|
||||
.join(' vs ');
|
||||
if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles');
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
Future<BookHistoryQuizEntity?> _tryGenerateQuizByQuizType({
|
||||
required int quizType,
|
||||
required String text,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
if (quizType == 1) {
|
||||
final choiceQuiz = await _tryToGenerateMultipleChoiceQuiz(
|
||||
fullText: text,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (choiceQuiz != null) {
|
||||
final remote = QuizChoiceMapper.entityToRemote(choiceQuiz);
|
||||
return BookHistoryQuizEntity(
|
||||
quizType: 1,
|
||||
retry: 3,
|
||||
question: jsonEncode(remote.toJson()),
|
||||
answer: null,
|
||||
explanation: choiceQuiz.explanation,
|
||||
multipleChoiceQuestion: choiceQuiz,
|
||||
multipleChoiceAnswer: null,
|
||||
fillBlankQuestion: null,
|
||||
fillBlankAnswer: null,
|
||||
groupQuestion: null,
|
||||
groupAnswer: null,
|
||||
);
|
||||
}
|
||||
} else if (quizType == 2) {
|
||||
final fillBlankQuiz = await _tryToGenerateFillBlankQuiz(
|
||||
fullText: text,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (fillBlankQuiz != null) {
|
||||
final remote = QuizFillBlankMapper.entityToRemote(fillBlankQuiz);
|
||||
return BookHistoryQuizEntity(
|
||||
quizType: 2,
|
||||
retry: 3,
|
||||
question: jsonEncode(remote.toJson()),
|
||||
answer: null,
|
||||
explanation: fillBlankQuiz.explanation,
|
||||
multipleChoiceQuestion: null,
|
||||
multipleChoiceAnswer: null,
|
||||
fillBlankQuestion: fillBlankQuiz,
|
||||
fillBlankAnswer: null,
|
||||
groupQuestion: null,
|
||||
groupAnswer: null,
|
||||
);
|
||||
}
|
||||
} else if (quizType == 3) {
|
||||
final groupQuiz = await _tryToGenerateGroupQuiz(
|
||||
fullText: text,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
);
|
||||
if (groupQuiz != null) {
|
||||
final remote = QuizGroupMapper.entityToRemote(groupQuiz);
|
||||
return BookHistoryQuizEntity(
|
||||
quizType: 3,
|
||||
retry: 3,
|
||||
question: jsonEncode(remote.toJson()),
|
||||
answer: null,
|
||||
explanation: groupQuiz.explanation,
|
||||
multipleChoiceQuestion: null,
|
||||
multipleChoiceAnswer: null,
|
||||
fillBlankQuestion: null,
|
||||
fillBlankAnswer: null,
|
||||
groupQuestion: groupQuiz,
|
||||
groupAnswer: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<QuizChoiceEntity?> _tryToGenerateMultipleChoiceQuiz({
|
||||
required String fullText,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
final quizJson = await quizUsecase.generate(
|
||||
prompt: QuizPrompt.multipleChoicePrompt(
|
||||
text: fullText,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
),
|
||||
);
|
||||
|
||||
if (quizJson == null) {
|
||||
EpicLog.debug('_tryToGenerateMultipleChoiceQuiz - quizJson is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
|
||||
final quizModel = QuizChoiceRemoteModel.fromJson(parsed);
|
||||
// === VALIDASI ===
|
||||
if (quizModel.question == null ||
|
||||
quizModel.choices == null ||
|
||||
quizModel.choices!.length != 4 ||
|
||||
quizModel.correctedIndex == null ||
|
||||
quizModel.correctedIndex! < 0 ||
|
||||
quizModel.correctedIndex! >= quizModel.choices!.length) {
|
||||
EpicLog.debug(
|
||||
'_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
|
||||
return null;
|
||||
}
|
||||
final quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
|
||||
|
||||
EpicLog.debug('generateQuiz - quizJson: $quizJson');
|
||||
return quizEntity.copyWith(
|
||||
quizDataHelper: quizModel.toJson().toString(),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<QuizFillBlankEntity?> _tryToGenerateFillBlankQuiz({
|
||||
required String fullText,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
final quizJson = await quizUsecase.generate(
|
||||
prompt: QuizPrompt.fillBlankPrompt(
|
||||
text: fullText,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
),
|
||||
);
|
||||
|
||||
if (quizJson == null) {
|
||||
EpicLog.debug('_tryToGenerateFillBlankQuiz - quizJson is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
|
||||
final quizModel = QuizFillBlankRemoteModel.fromJson(parsed);
|
||||
// === VALIDASI ===
|
||||
if (quizModel.sentence == null ||
|
||||
quizModel.options == null ||
|
||||
quizModel.options!.length != 4 ||
|
||||
quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
|
||||
quizModel.options!
|
||||
.any((o) => o.value == null || o.correctBlankId == null)) {
|
||||
EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
|
||||
return null;
|
||||
}
|
||||
final quizEntity = QuizFillBlankMapper.remoteToEntity(quizModel);
|
||||
|
||||
EpicLog.debug('generateQuiz - quizJson: $quizJson');
|
||||
return quizEntity.copyWith(
|
||||
quizDataHelper: quizModel.toJson().toString(),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryToGenerateFillBlankQuiz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<QuizGroupEntity?> _tryToGenerateGroupQuiz({
|
||||
required String fullText,
|
||||
String? category,
|
||||
List<String> previousQuestions = const [],
|
||||
}) async {
|
||||
try {
|
||||
final quizJson = await quizUsecase.generate(
|
||||
prompt: QuizPrompt.groupQuizPrompt(
|
||||
text: fullText,
|
||||
category: category,
|
||||
previousQuestions: previousQuestions,
|
||||
),
|
||||
);
|
||||
|
||||
if (quizJson == null) {
|
||||
EpicLog.debug('_tryToGenerateGroupQuiz - quizJson is null');
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = jsonDecode(quizJson) as Map<String, dynamic>;
|
||||
final quizModel = QuizGroupRemoteModel.fromJson(parsed);
|
||||
// Validasi group quiz:
|
||||
final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
|
||||
if (quizModel.groups == null ||
|
||||
quizModel.groups!.length != 2 ||
|
||||
quizModel.options == null ||
|
||||
quizModel.options!.length != 8 ||
|
||||
quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
|
||||
EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
|
||||
return null;
|
||||
}
|
||||
final quizEntity = QuizGroupMapper.remoteToEntity(quizModel);
|
||||
|
||||
EpicLog.debug('generateQuiz - quizJson: $quizJson');
|
||||
return quizEntity.copyWith(
|
||||
quizDataHelper: quizModel.toJson().toString(),
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_tryToGenerateGroupQuiz');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> goToQuizPage() async {
|
||||
final currentHistory = bookHistory.value;
|
||||
if (currentHistory?.quiz == null) return;
|
||||
await _stopTts(force: true);
|
||||
|
||||
try {
|
||||
final result = await Get.toNamed(
|
||||
EpicRoutes.bookQuiz,
|
||||
arguments: {
|
||||
'history': currentHistory,
|
||||
'theoryText': allTextTheory.value,
|
||||
'bookQuizType': book.quizType,
|
||||
'book': book,
|
||||
},
|
||||
);
|
||||
if (result is BookHistoryEntity) {
|
||||
bookHistory.value = result;
|
||||
}
|
||||
} finally {
|
||||
await _forceLandscapeOrientation();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _forceLandscapeOrientation() async {
|
||||
const landscape = [
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
];
|
||||
|
||||
await SystemChrome.setPreferredOrientations(landscape);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_read/components/dialog_add_book_coll.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_read/components/dot_indicator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_pdfview/flutter_pdfview.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
/// PDF reader page driven by controller; no widget parameters (clean architecture).
|
||||
class BookReadPage extends StatefulWidget {
|
||||
const BookReadPage({super.key});
|
||||
|
||||
@override
|
||||
State<BookReadPage> createState() => _BookReadPageState();
|
||||
}
|
||||
|
||||
class _BookReadPageState extends State<BookReadPage> {
|
||||
final BookReadController controller = Get.find<BookReadController>();
|
||||
|
||||
static const Color _mainBackground = Color(0xFFE0CEFF);
|
||||
static const double _topBarHeight = 84;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations(
|
||||
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _mainBackground,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// PDF content area
|
||||
Positioned.fill(
|
||||
child: Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final path = controller.pdfPath.value;
|
||||
if (path == null) {
|
||||
if (controller.isPdfLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (controller.error.value != null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
controller.error.value!,
|
||||
style: const TextStyle(color: Colors.black87),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'PDF tidak tersedia',
|
||||
style: TextStyle(color: Colors.black87),
|
||||
),
|
||||
);
|
||||
}
|
||||
return _PdfContent(
|
||||
key: ValueKey(path),
|
||||
path: path,
|
||||
controller: controller,
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
// Top bar: back + actions
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Obx(() {
|
||||
final bool isBookVisible = !controller.isLoading.value &&
|
||||
!controller.isPdfLoading.value &&
|
||||
controller.error.value == null &&
|
||||
controller.pdfPath.value != null;
|
||||
|
||||
return Container(
|
||||
height: _topBarHeight,
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: isBookVisible
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
],
|
||||
)
|
||||
: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF7E32AB),
|
||||
Color(0xFFE0CEFF),
|
||||
],
|
||||
stops: [0.0, 1.0],
|
||||
),
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
bottom: Radius.circular(18),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => Get.back<void>(),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.42),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.reply_rounded,
|
||||
size: 46,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
_buildTopActionButton(
|
||||
icon: Icons.bookmark,
|
||||
backgroundColor: const Color(0xFFFFED8C),
|
||||
iconColor: const Color(0xFF8D8D8D),
|
||||
onTap: () {
|
||||
if (controller.isLoading.value) {
|
||||
Get.snackbar(
|
||||
'Collection',
|
||||
'Tunggu sampai buku selesai dimuat.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
showAddBookCollectionSheet(
|
||||
context,
|
||||
controller,
|
||||
controller.book,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_buildTopActionButton(
|
||||
icon: controller.isSoundOn.value
|
||||
? Icons.volume_up_rounded
|
||||
: Icons.volume_off_rounded,
|
||||
backgroundColor: const Color(0xFF999999),
|
||||
iconColor: controller.isSoundOn.value
|
||||
? const Color(0xFFF3D86A)
|
||||
: const Color(0xFFE4E4E4),
|
||||
onTap: controller.toggleSound,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
||||
// Bottom controls: prev, indicator, next
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 24,
|
||||
child: Obx(
|
||||
() {
|
||||
final idx = controller.currentIndex.value;
|
||||
final length = controller.totalPages.value;
|
||||
final canGoPrev = idx > 0;
|
||||
final bool isLastPage = length > 0 && idx >= length - 1;
|
||||
final bool hasQuiz =
|
||||
controller.bookHistory.value?.quiz != null;
|
||||
final bool canGoNextPage = length > 0 && !isLastPage;
|
||||
final bool allowQuizNav = isLastPage;
|
||||
final VoidCallback? nextAction = canGoNextPage
|
||||
? () => controller.goTo(idx + 1)
|
||||
: allowQuizNav
|
||||
? () {
|
||||
if (hasQuiz) {
|
||||
controller.goToQuizPage();
|
||||
} else {
|
||||
Get.snackbar(
|
||||
'Quiz',
|
||||
'Quiz belum tersedia.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
: null;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildNavArrowButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
onTap:
|
||||
canGoPrev ? () => controller.goTo(idx - 1) : null,
|
||||
isPrevious: true,
|
||||
),
|
||||
DotsIndicator(
|
||||
length: length,
|
||||
index: idx,
|
||||
activeColor: const Color(0xFFD9D9D9),
|
||||
inactiveColor: const Color(0xFFD9D9D9),
|
||||
activeBorderColor: const Color(0xFF2A2A2A),
|
||||
),
|
||||
_buildNavArrowButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
onTap: nextAction,
|
||||
isPrevious: false,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopActionButton({
|
||||
required IconData icon,
|
||||
required Color backgroundColor,
|
||||
required Color iconColor,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Ink(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.5), width: 1),
|
||||
),
|
||||
child: Icon(icon, size: 32, color: iconColor),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavArrowButton({
|
||||
required IconData icon,
|
||||
required VoidCallback? onTap,
|
||||
required bool isPrevious,
|
||||
}) {
|
||||
final disabled = onTap == null;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Ink(
|
||||
width: 62,
|
||||
height: 62,
|
||||
decoration: BoxDecoration(
|
||||
color: disabled ? const Color(0xFF9CC8F2) : const Color(0xFF66B0FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFF2A2A2A), width: 1),
|
||||
),
|
||||
child: Transform(
|
||||
alignment: Alignment.center,
|
||||
transform: Matrix4.identity()..rotateZ(isPrevious ? 3.14159 : 0),
|
||||
child: Icon(
|
||||
icon,
|
||||
color:
|
||||
disabled ? const Color(0xFF2F2F2F) : const Color(0xFF161616),
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracted PDF widget to avoid rebuild when parent Obx re-evaluates.
|
||||
class _PdfContent extends StatelessWidget {
|
||||
const _PdfContent({
|
||||
super.key,
|
||||
required this.path,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final BookReadController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRect(
|
||||
child: SizedBox.expand(
|
||||
child: PDFView(
|
||||
filePath: path,
|
||||
enableSwipe: true,
|
||||
swipeHorizontal: true,
|
||||
autoSpacing: true,
|
||||
pageFling: true,
|
||||
pageSnap: true,
|
||||
fitPolicy: FitPolicy.HEIGHT,
|
||||
preventLinkNavigation: true,
|
||||
onViewCreated: controller.onPdfViewCreated,
|
||||
onRender: (pages) =>
|
||||
controller.setTotalPages((pages ?? 0).clamp(0, 10000)),
|
||||
onPageChanged: (page, total) {
|
||||
if (page != null) controller.onPageChanged(page);
|
||||
if (total != null) controller.setTotalPages(total);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
import 'package:epic_story_app/core/constants/size/epic_size.dart';
|
||||
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
const List<int> _fallbackGradientIndexes = [2, 7, 6];
|
||||
const List<String> _thumbAssets = [
|
||||
'assets/images/book-bhs-indo.png',
|
||||
'assets/images/book-matematika.png',
|
||||
'assets/images/tipe-book.png',
|
||||
];
|
||||
|
||||
void showAddBookCollectionSheet(
|
||||
BuildContext context,
|
||||
BookReadController controller,
|
||||
BookEntity book,
|
||||
) {
|
||||
Get.bottomSheet(
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Obx(
|
||||
() {
|
||||
final items = controller.collections;
|
||||
final media = MediaQuery.of(context);
|
||||
final isLandscape = media.orientation == Orientation.landscape;
|
||||
final sheetMaxWidth = isLandscape
|
||||
? (media.size.width * 0.52).clamp(300.0, 430.0).toDouble()
|
||||
: media.size.width;
|
||||
final listMaxHeight = media.size.height * (isLandscape ? 0.34 : 0.50);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: sheetMaxWidth),
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFC6B2E6),
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(32),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Collection',
|
||||
style: EpicSize.titleLarge.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: const Color(0xFF111111),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text(
|
||||
'Belum ada koleksi buku.',
|
||||
style: EpicSize.bodyMedium.copyWith(
|
||||
fontSize: 14,
|
||||
color: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: listMaxHeight,
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (_, index) {
|
||||
final item = items[index];
|
||||
final colorIndex = item.bgColor ??
|
||||
_fallbackGradientIndexes[
|
||||
index % _fallbackGradientIndexes.length];
|
||||
final gradient =
|
||||
collectionColorAt(colorIndex).gradient;
|
||||
final thumbAsset =
|
||||
_thumbAssets[index % _thumbAssets.length];
|
||||
|
||||
return _BookCollectionSheetTile(
|
||||
item: item,
|
||||
thumbnailAsset: thumbAsset,
|
||||
gradient: gradient,
|
||||
onAdd: () {
|
||||
controller.addBookToCollection(
|
||||
collectionId: item.id,
|
||||
bookId: book.bookId ?? '',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
class _BookCollectionSheetTile extends StatelessWidget {
|
||||
const _BookCollectionSheetTile({
|
||||
required this.item,
|
||||
required this.thumbnailAsset,
|
||||
required this.gradient,
|
||||
required this.onAdd,
|
||||
});
|
||||
|
||||
final ChildrenCollectionEntity item;
|
||||
final String thumbnailAsset;
|
||||
final LinearGradient gradient;
|
||||
final VoidCallback onAdd;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0x99FFFFFF), width: 1.1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0x22000000),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_BookThumbnail(
|
||||
coverUrl: item.bookCovers?.isNotEmpty == true
|
||||
? item.bookCovers!.first
|
||||
: null,
|
||||
fallbackAssetPath: thumbnailAsset,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title ?? '-',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EpicSize.titleMedium.copyWith(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFF111111),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
_chip(
|
||||
icon: Icons.menu_book_rounded,
|
||||
text: 'Total Buku : ${item.nBooks ?? 0}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
GestureDetector(
|
||||
onTap: onAdd,
|
||||
child: Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color.fromARGB(255, 47, 85, 255),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.add_rounded,
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip({required String text, IconData? icon}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.55),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFF111111), width: 0.9),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: const Color(0xFF111111),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(
|
||||
text,
|
||||
style: EpicSize.bodySmall.copyWith(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF111111),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookThumbnail extends StatelessWidget {
|
||||
const _BookThumbnail({
|
||||
required this.coverUrl,
|
||||
required this.fallbackAssetPath,
|
||||
});
|
||||
|
||||
final String? coverUrl;
|
||||
final String fallbackAssetPath;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFFFF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0x44FFFFFF), width: 1.1),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: (coverUrl != null && coverUrl!.trim().isNotEmpty)
|
||||
? Image.network(
|
||||
coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Image.asset(
|
||||
fallbackAssetPath,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
: Image.asset(
|
||||
fallbackAssetPath,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../../data/modules/children_module.dart';
|
||||
import '../../../../domain/usecases/children_usecase.dart';
|
||||
|
||||
import 'book_reward_controller.dart';
|
||||
|
||||
class BookRewardBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
ChildrenModule();
|
||||
Get.lazyPut<BookRewardController>(
|
||||
() => BookRewardController(
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import 'package:epic_story_app/core/utils/epic_log.dart';
|
||||
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookRewardController extends GetxController {
|
||||
BookRewardController({
|
||||
required this.childrenUsecase,
|
||||
});
|
||||
|
||||
static const int maxSelection = 3;
|
||||
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
final mainController = Get.find<MainController>();
|
||||
|
||||
late BookEntity book;
|
||||
|
||||
final rewards = <String>[].obs;
|
||||
final selectedQueue = <int>[].obs;
|
||||
final bookHistory = Rxn<BookHistoryEntity>();
|
||||
|
||||
int get selectedCount => selectedQueue.length;
|
||||
int get remainingSelection =>
|
||||
(maxSelection - selectedCount).clamp(0, maxSelection);
|
||||
bool get hasRewards => rewards.isNotEmpty;
|
||||
|
||||
List<String> get selectedRewardsOrdered => selectedQueue
|
||||
.where((index) => index >= 0 && index < rewards.length)
|
||||
.map((index) => rewards[index])
|
||||
.toList();
|
||||
|
||||
@override
|
||||
void onInit() async {
|
||||
super.onInit();
|
||||
book = const BookEntity();
|
||||
|
||||
final argument = Get.arguments;
|
||||
if (argument != null && argument is BookEntity) {
|
||||
book = argument;
|
||||
final bookId = book.bookId;
|
||||
if (bookId != null && bookId.isNotEmpty) {
|
||||
bookHistory.value = await childrenUsecase.getBookHistoryById(bookId);
|
||||
}
|
||||
}
|
||||
|
||||
_initRewards();
|
||||
}
|
||||
|
||||
void _initRewards() {
|
||||
var rewardList = book.rewards ?? [];
|
||||
rewards.clear();
|
||||
rewards.addAll(rewardList);
|
||||
}
|
||||
|
||||
void selectReward(int index) {
|
||||
if (index < 0 || index >= rewards.length) return;
|
||||
|
||||
if (selectedQueue.contains(index)) {
|
||||
selectedQueue.remove(index);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedQueue.length >= maxSelection) {
|
||||
selectedQueue.removeAt(0);
|
||||
}
|
||||
|
||||
selectedQueue.add(index);
|
||||
}
|
||||
|
||||
void removeSelectionAt(int slotIndex) {
|
||||
if (slotIndex < 0 || slotIndex >= selectedQueue.length) return;
|
||||
selectedQueue.removeAt(slotIndex);
|
||||
}
|
||||
|
||||
Future<void> claimReward() async {
|
||||
try {
|
||||
if (selectedQueue.length < maxSelection) {
|
||||
EpicSnackBar.showWarningSnackBar(
|
||||
'Pilih Reward',
|
||||
'Silakan pilih $maxSelection gambar terlebih dahulu.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final history = bookHistory.value;
|
||||
if (history == null) {
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Error',
|
||||
'Riwayat buku tidak ditemukan.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final newHistory = history.copyWith(
|
||||
claimedRewards: List<int>.from(selectedQueue),
|
||||
);
|
||||
|
||||
mainController.showLoadingPage();
|
||||
await childrenUsecase.updateBookHistory(
|
||||
bookHistory: newHistory,
|
||||
pushToRemote: true,
|
||||
);
|
||||
bookHistory.value = newHistory;
|
||||
|
||||
mainController.hideLoadingPage();
|
||||
Get.back(result: true);
|
||||
EpicSnackBar.showSuccessSnackBar(
|
||||
'Success',
|
||||
'Rewards claimed successfully!',
|
||||
);
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, 'claimReward');
|
||||
EpicSnackBar.showErrorSnackBar(
|
||||
'Error',
|
||||
'Failed to claim rewards. Please try again.',
|
||||
);
|
||||
} finally {
|
||||
mainController.hideLoadingPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,372 @@
|
|||
import 'package:epic_story_app/feature/books/presentation/book_reward/book_reward_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookRewardPage extends GetView<BookRewardController> {
|
||||
const BookRewardPage({super.key});
|
||||
|
||||
static const Color _pageBackground = Color(0xFFE0CEFF);
|
||||
static const Color _contentBackground = Color(0xFFA991D2);
|
||||
static const Color _scrollbarColor = Color(0xFFCACACA);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _pageBackground,
|
||||
body: Obx(
|
||||
() {
|
||||
final rewards = controller.rewards;
|
||||
final remaining = controller.remainingSelection;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
const _RewardBadgeHeader(),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'Silahkan pilih 3 gambar\nsebagai reward buku',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF1E1532),
|
||||
height: 1.32,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
controller.selectedCount == 0
|
||||
? 'Kamu belum memilih gambar'
|
||||
: 'Kamu sudah memilih ${controller.selectedCount} gambar sekarang kurang $remaining lagi',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF2D1F49),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_SelectedSlotsRow(controller: controller),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: const BoxDecoration(
|
||||
color: _contentBackground,
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(14),
|
||||
),
|
||||
),
|
||||
child: RawScrollbar(
|
||||
thumbVisibility: true,
|
||||
trackVisibility: true,
|
||||
thickness: 6.5,
|
||||
radius: const Radius.circular(6),
|
||||
thumbColor: _scrollbarColor,
|
||||
trackColor: _scrollbarColor.withOpacity(0.45),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 0, 18, 14),
|
||||
child: _buildGrid(rewards),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_claimButton(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGrid(List<String> rewards) {
|
||||
if (rewards.isEmpty) {
|
||||
return _emptyList();
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 18,
|
||||
crossAxisSpacing: 18,
|
||||
childAspectRatio: 1,
|
||||
),
|
||||
itemCount: rewards.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isSelected = controller.selectedQueue.contains(index);
|
||||
return _rewardTile(
|
||||
imageUrl: rewards[index],
|
||||
isSelected: isSelected,
|
||||
onTap: () => controller.selectReward(index),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rewardTile({
|
||||
required String imageUrl,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
decoration: BoxDecoration(
|
||||
color: _pageBackground,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? const Color(0xFF33234A)
|
||||
: Colors.black.withOpacity(0.42),
|
||||
width: isSelected ? 2.2 : 1.1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12.5),
|
||||
child: _networkImage(imageUrl, key: ValueKey(imageUrl)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _networkImage(String url, {Key? key}) {
|
||||
return Image.network(
|
||||
url,
|
||||
key: key,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => _imageFallback(),
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return ColoredBox(
|
||||
color: _pageBackground,
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.4,
|
||||
color: Color(0xFF6B4F92),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _emptyList() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _pageBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 18, color: Color(0xFF2F2249)),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Belum ada reward untuk buku ini.',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF2F2249),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _imageFallback() {
|
||||
return Container(
|
||||
color: _pageBackground,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 30,
|
||||
color: Color(0xFF6A5A83),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _claimButton() {
|
||||
final canClaim =
|
||||
controller.selectedCount == BookRewardController.maxSelection;
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(66, 10, 66, 12),
|
||||
child: Opacity(
|
||||
opacity: canClaim ? 1 : 0.68,
|
||||
child: GestureDetector(
|
||||
onTap: canClaim ? controller.claimReward : null,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Image.asset(
|
||||
'assets/images/buttom-ambil-reward.png',
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectedSlotsRow extends StatelessWidget {
|
||||
const _SelectedSlotsRow({required this.controller});
|
||||
|
||||
final BookRewardController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = controller.selectedRewardsOrdered;
|
||||
final maxSlot = BookRewardController.maxSelection;
|
||||
|
||||
return Row(
|
||||
children: List.generate(maxSlot, (slotIndex) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(right: slotIndex == maxSlot - 1 ? 0 : 12),
|
||||
child: _SlotPreview(
|
||||
imageUrl:
|
||||
slotIndex < selected.length ? selected[slotIndex] : null,
|
||||
slotIndex: slotIndex,
|
||||
onRemove: () => controller.removeSelectionAt(slotIndex),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SlotPreview extends StatelessWidget {
|
||||
const _SlotPreview({
|
||||
required this.imageUrl,
|
||||
required this.slotIndex,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final String? imageUrl;
|
||||
final int slotIndex;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasImage = imageUrl != null && imageUrl!.isNotEmpty;
|
||||
return GestureDetector(
|
||||
onTap: hasImage ? onRemove : null,
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE0CEFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
width: 1.1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14.5),
|
||||
child: hasImage
|
||||
? Image.network(
|
||||
imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
color: const Color(0xFFE0CEFF),
|
||||
alignment: Alignment.center,
|
||||
child: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.1,
|
||||
color: Color(0xFF6B4F92),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
_slotFallback(),
|
||||
)
|
||||
: _slotPlaceholder(slotIndex),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _slotPlaceholder(int slotIndex) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.image_outlined,
|
||||
color: Color(0xFF6A5A83),
|
||||
size: 26,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Slot\ngambar\nke ${slotIndex + 1}',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF33254B),
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _slotFallback() {
|
||||
return Container(
|
||||
color: const Color(0xFFE0CEFF),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
size: 22,
|
||||
color: Color(0xFF6A5A83),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardBadgeHeader extends StatelessWidget {
|
||||
const _RewardBadgeHeader();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
child: Image.asset(
|
||||
'assets/images/header-claim-reward.png',
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue