feat: add favorites, AI tutors, and references features

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Achmad Baihaqi 2026-06-28 23:06:59 +07:00
parent 1ab2705f46
commit 5e698ae78c
19 changed files with 2670 additions and 0 deletions

View File

@ -0,0 +1,9 @@
import 'package:epic_story_app/feature/ai_tutors/presentation/ai_tutor_controller.dart';
import 'package:get/get.dart';
class AiTutorBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => AiTutorController());
}
}

View File

@ -0,0 +1,5 @@
import 'package:get/get.dart';
class AiTutorController extends GetxController{
//
}

View File

@ -0,0 +1,24 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/feature/ai_tutors/presentation/ai_tutor_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class AiTutorPage extends StatelessWidget {
const AiTutorPage({super.key});
@override
Widget build(BuildContext context) {
final AiTutorController controller = Get.find();
return Scaffold(
backgroundColor: Colors.purple.withOpacity(0.4),
body: Center(
child: Text(
"AI Tutor Home",
style: EpicSize.headlineLarge,
),
),
);
}
}

View File

@ -0,0 +1,16 @@
class CreateCollectionArgs {
final bool isEdit;
final String type;
final String collectionId;
final String? title;
final String? desc;
final int? bgColor;
const CreateCollectionArgs.edit({
required this.type,
required this.collectionId,
this.title,
this.desc,
this.bgColor,
}) : isEdit = true;
}

View File

@ -0,0 +1,16 @@
import 'package:epic_story_app/data/modules/collection_module.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_controller.dart';
import 'package:get/get.dart';
class CreateCollectionBinding extends Bindings {
@override
void dependencies() {
CollectionModule();
Get.lazyPut<CreateCollectionController>(
() => CreateCollectionController(
collectionUsecase: Get.find<CollectionUsecase>(),
),
);
}
}

View File

@ -0,0 +1,187 @@
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_args.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class CreateCollectionController extends GetxController {
final CollectionUsecase collectionUsecase;
CreateCollectionController({
required this.collectionUsecase,
});
final TextEditingController nameCtrl = TextEditingController();
final TextEditingController descCtrl = TextEditingController();
final mainController = Get.find<MainController>();
final types = const ['Buku', 'Flashcard'];
final RxString selectedType = ''.obs;
final List<CollectionColorModel> palette = kCollectionColorPalette;
final RxInt selectedIndex = (-1).obs;
final RxBool isSaving = false.obs;
final RxBool isEditMode = false.obs;
String? editingCollectionId;
CollectionColorModel? get selectedColorModel =>
selectedIndex.value >= 0 ? palette[selectedIndex.value] : null;
Color? get selectedColor => selectedColorModel?.color;
LinearGradient? get selectedGradient => selectedColorModel?.gradient;
@override
void onInit() {
super.onInit();
_loadArgs();
}
void _loadArgs() {
final args = Get.arguments;
if (args is! CreateCollectionArgs || !args.isEdit) return;
isEditMode.value = true;
editingCollectionId = args.collectionId;
selectedType.value = args.type;
nameCtrl.text = args.title ?? '';
descCtrl.text = args.desc ?? '';
final colorIndex = args.bgColor;
if (colorIndex != null && colorIndex >= 0 && colorIndex < palette.length) {
selectedIndex.value = colorIndex;
}
}
Future<void> onSubmit() async {
final title = nameCtrl.text.trim();
final desc = descCtrl.text.trim();
final colorIndex = selectedIndex.value;
final type = selectedType.value;
if (title.isEmpty) {
Get.snackbar('Gagal', 'Nama koleksi tidak boleh kosong');
return;
}
if (colorIndex < 0 || colorIndex >= palette.length) {
Get.snackbar('Gagal', 'Pilih warna background terlebih dahulu');
return;
}
if (type.isEmpty) {
Get.snackbar('Gagal', 'Pilih tipe koleksi');
return;
}
isSaving.value = true;
final now = DateTime.now();
final generatedId = now.millisecondsSinceEpoch.toString();
EpicLog.debug("type selected: ${selectedType.value}");
try {
mainController.showLoadingPage();
if (isEditMode.value) {
final collectionId = editingCollectionId;
if (collectionId == null || collectionId.isEmpty) {
Get.snackbar('Gagal', 'Koleksi tidak ditemukan');
return;
}
if (type == 'Buku') {
final updatedCollection = ChildrenCollectionEntity(
id: collectionId,
bgColor: colorIndex,
title: title,
desc: desc.isEmpty ? null : desc,
updatedAt: now,
);
await collectionUsecase.updateCollection(
collection: updatedCollection,
);
} else {
final updatedCollection = FlashCardCollectionEntity(
collectionId: collectionId,
title: title,
desc: desc.isEmpty ? null : desc,
bgColor: colorIndex,
updatedAt: now,
);
await collectionUsecase.updateFlashcardCollection(
collection: updatedCollection,
);
}
mainController.hideLoadingPage();
Get.back(result: true);
Get.snackbar('Berhasil', 'Koleksi berhasil diperbarui');
return;
}
if (selectedType.value == 'Buku') {
final bookCollection = ChildrenCollectionEntity(
id: generatedId,
bgColor: colorIndex,
title: title,
desc: desc.isEmpty ? null : desc,
nBooks: 0,
nPages: 0,
bookCovers: const [],
categories: const [],
createdAt: now,
updatedAt: now,
);
await collectionUsecase.createCollection(collection: bookCollection);
} else {
var flashcardCollection = FlashCardCollectionEntity(
collectionId: generatedId,
title: title,
desc: desc.isEmpty ? null : desc,
bgColor: colorIndex,
categories: const [],
totalCards: 0,
totalFlashcard: 0,
totalQuiz: 0,
completedQuiz: 0,
createdAt: now,
updatedAt: now,
);
await collectionUsecase.createFlashcardCollection(
collection: flashcardCollection,
);
}
mainController.hideLoadingPage();
Get.back(result: true);
Get.snackbar('Berhasil', 'Koleksi berhasil dibuat');
} catch (e) {
Get.snackbar(
'Gagal',
isEditMode.value
? 'Tidak dapat memperbarui koleksi'
: 'Tidak dapat membuat koleksi',
);
} finally {
isSaving.value = false;
mainController.hideLoadingPage();
}
}
void selectColor(int index) {
selectedIndex.value = index;
}
void selectType(String? value) {
if (value != null) selectedType.value = value;
}
@override
void onClose() {
nameCtrl.dispose();
descCtrl.dispose();
super.onClose();
}
}

View File

@ -0,0 +1,274 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/core/styles/epic_app_size.dart';
import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class CreateCollectionPage extends StatefulWidget {
const CreateCollectionPage({super.key});
@override
State<CreateCollectionPage> createState() => _CreateCollectionPageState();
}
class _CreateCollectionPageState extends State<CreateCollectionPage> {
late final CreateCollectionController controller =
Get.find<CreateCollectionController>();
static const Color _pageBackground = Color(0xFFC6B2E6);
static const Color _inputBorder = Color(0xFF22162F);
static const Color _saveButton = Color(0xFF8BDE88);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: _pageBackground,
body: SafeArea(
bottom: false,
child: Column(
children: [
Obx(() {
final isEditMode = controller.isEditMode.value;
return StackedInfoTopBar(
titleText: isEditMode ? 'Edit Koleksi' : 'Buat Koleksi',
infoText: isEditMode
? 'Silahkan ubah koleksi yang\nsudah kamu buat sebelumnya'
: 'Silahkan buat koleksi dari\nbuku/flashcard yang ingin\nkamu pelajari selanjutnya',
isBackButtonVisible: true,
onBackTap: () => Get.back(),
);
}),
Expanded(
child: SingleChildScrollView(
physics: const BouncingScrollPhysics(),
padding: EdgeInsets.fromLTRB(
EpicSize.calculatedSize(30),
EpicSize.calculatedSize(10),
EpicSize.calculatedSize(30),
EpicSize.calculatedSize(28),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: EpicAppSize.calculatedSize(16),
),
_FieldLabel(text: 'Nama Koleksi'),
SizedBox(
height: EpicAppSize.calculatedSize(10),
),
TextField(
controller: controller.nameCtrl,
decoration: _inputDecoration(),
),
SizedBox(
height: EpicAppSize.calculatedSize(16),
),
_FieldLabel(text: 'Tipe Koleksi'),
SizedBox(
height: EpicAppSize.calculatedSize(10),
),
Obx(
() => DropdownButtonFormField<String>(
value: controller.selectedType.value.isEmpty
? null
: controller.selectedType.value,
hint: const Text(
'Pilih satu (dropdown)',
style: TextStyle(
color: Colors.black,
fontWeight: FontWeight.w600,
),
),
items: controller.types
.map(
(t) => DropdownMenuItem<String>(
value: t,
child: Text(t),
),
)
.toList(),
onChanged: controller.isEditMode.value
? null
: controller.selectType,
icon: const Icon(Icons.keyboard_arrow_down_rounded),
style: const TextStyle(
color: Colors.black,
fontWeight: FontWeight.w600,
),
decoration: _inputDecoration(),
isExpanded: true,
dropdownColor: _pageBackground,
),
),
SizedBox(height: EpicAppSize.calculatedSize(20)),
_FieldLabel(text: 'Deskripsi Koleksi'),
SizedBox(height: EpicAppSize.calculatedSize(10)),
TextField(
controller: controller.descCtrl,
decoration: _inputDecoration(),
minLines: 5,
maxLines: 5,
),
SizedBox(height: EpicAppSize.calculatedSize(22)),
_FieldLabel(text: 'Background Koleksi'),
SizedBox(height: EpicAppSize.calculatedSize(10)),
LayoutBuilder(
builder: (context, constraints) {
final spacing = EpicSize.calculatedSize(18);
return GridView.builder(
shrinkWrap: true,
itemCount: controller.palette.length,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: spacing,
crossAxisSpacing: spacing,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
final colorModel = controller.palette[index];
return Obx(
() {
final selected =
controller.selectedIndex.value == index;
return GestureDetector(
onTap: () => controller.selectColor(index),
child: _ColorSwatch(
colorModel: colorModel,
selected: selected,
),
);
},
);
},
);
},
),
SizedBox(height: EpicSize.calculatedSize(32)),
Obx(
() => controller.isSaving.value
? const Center(child: CircularProgressIndicator())
: const SizedBox.shrink(),
),
SizedBox(height: EpicSize.sizeSmall),
Center(
child: GestureDetector(
onTap: controller.onSubmit,
child: Container(
width: EpicSize.calculatedSize(220),
height: EpicSize.calculatedSize(52),
decoration: BoxDecoration(
color: _saveButton,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFF1A9E1A),
width: 1.2,
),
boxShadow: const [
BoxShadow(
color: Color(0x3F000000),
offset: Offset(0, 4),
blurRadius: 6,
),
],
),
child: Center(
child: Text(
'Simpan',
style: TextStyle(
color: Colors.black,
fontSize: EpicAppSize.calculatedSize(22),
fontWeight: FontWeight.w900,
height: 0.9,
),
),
),
),
),
),
],
),
),
),
],
),
),
);
}
InputDecoration _inputDecoration() {
return InputDecoration(
contentPadding: EdgeInsets.symmetric(
horizontal: EpicSize.sizeSmallMedium,
vertical: EpicSize.sizeSmall,
),
filled: true,
fillColor: Colors.transparent,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _inputBorder),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _inputBorder, width: 1.5),
),
);
}
}
class _FieldLabel extends StatelessWidget {
final String text;
const _FieldLabel({required this.text});
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
color: Color(0xFF120D1A),
fontSize: EpicAppSize.calculatedSize(24),
fontWeight: FontWeight.w800,
height: 1,
),
);
}
}
class _ColorSwatch extends StatelessWidget {
final CollectionColorModel colorModel;
final bool selected;
const _ColorSwatch({required this.colorModel, required this.selected});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: EpicAppSize.calculatedSize(45),
height: EpicAppSize.calculatedSize(45),
decoration: BoxDecoration(
gradient: colorModel.gradient,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? Colors.black : Colors.black26,
width: selected ? 2.5 : 1.5,
),
boxShadow: selected
? [
BoxShadow(
color: colorModel.color.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
);
}
}

View File

@ -0,0 +1,21 @@
import 'package:epic_story_app/data/modules/book_module.dart';
import 'package:epic_story_app/data/modules/collection_module.dart';
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:get/get.dart';
import 'detail_collection_controller.dart';
class DetailCollectionBinding extends Bindings {
@override
void dependencies() {
CollectionModule();
BookModule();
Get.lazyPut<DetailCollectionController>(
() => DetailCollectionController(
collectionUsecase: Get.find<CollectionUsecase>(),
bookUsecase: Get.find<BookUsecase>(),
),
);
}
}

View File

@ -0,0 +1,154 @@
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/collection_book_entity.dart';
import 'package:epic_story_app/domain/entities/collection_detail_entity.dart';
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_args.dart';
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class DetailCollectionController extends GetxController {
var collectionId = ''.obs;
var collectionDetail = Rx<CollectionDetailEntity?>(null);
var loading = false.obs;
final CollectionUsecase collectionUsecase;
final BookUsecase bookUsecase;
DetailCollectionController({
required this.collectionUsecase,
required this.bookUsecase,
});
@override
void onInit() async {
super.onInit();
collectionId.value = Get.arguments;
await _loadDetail();
}
Future<void> _loadDetail() async {
try {
loading.value = true;
final detail = await collectionUsecase.getCollectionDetail(
collectionId: collectionId.value,
);
collectionDetail.value = detail;
} catch (e, s) {
EpicLog.exception(e, s, this, '_loadDetail');
} finally {
loading.value = false;
}
}
Future<bool> _showDeleteConfirmation({
required String title,
required String subtitle,
}) async {
final result = await Get.dialog<bool>(
AlertDialog(
title: Text(title),
content: Text(subtitle),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Get.back(result: true),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFF0000),
),
child: const Text('Hapus'),
),
],
),
barrierDismissible: true,
);
return result ?? false;
}
Future<void> deleteBookFromCollection(CollectionBookEntity book) async {
final isConfirmed = await _showDeleteConfirmation(
title: 'Hapus buku dari koleksi?',
subtitle: 'Buku "${book.title ?? '-'}" akan dihapus dari koleksi ini.',
);
if (!isConfirmed) return;
try {
loading.value = true;
await collectionUsecase.removeBookFromCollection(
collectionId: collectionId.value,
bookId: book.id,
);
await _loadDetail();
} catch (e, s) {
EpicLog.exception(e, s, this, 'deleteBookFromCollection');
} finally {
loading.value = false;
}
}
Future<void> deleteCollection() async {
final detail = collectionDetail.value;
if (detail == null || detail.id.isEmpty) return;
final isConfirmed = await _showDeleteConfirmation(
title: 'Hapus koleksi buku?',
subtitle: 'Koleksi "${detail.title ?? '-'}" akan dihapus permanen.',
);
if (!isConfirmed) return;
try {
loading.value = true;
await collectionUsecase.removeCollection(
collectionId: detail.id,
);
loading.value = false;
Get.back(result: true);
} catch (e, s) {
EpicLog.exception(e, s, this, 'deleteCollection');
loading.value = false;
}
}
Future<void> goToBookRead(CollectionBookEntity book) async {
try {
loading.value = true;
final bookEntity = await bookUsecase.getBookById(bookId: book.id);
if (bookEntity != null) {
Get.toNamed(EpicRoutes.bookRead, arguments: bookEntity);
} else {
EpicSnackBar.showWarningSnackBar('Warning', 'Buku tidak ditemukan');
}
} catch (e, s) {
EpicLog.exception(e, s, this, 'goToBookRead');
} finally {
loading.value = false;
}
}
Future<void> goToEditCollection() async {
final detail = collectionDetail.value;
if (detail == null) return;
final result = await Get.toNamed(
EpicRoutes.createCollection,
arguments: CreateCollectionArgs.edit(
type: 'Buku',
collectionId: detail.id,
title: detail.title,
desc: detail.desc,
bgColor: detail.bgColor,
),
);
if (result == true) {
await _loadDetail();
}
}
}

View File

@ -0,0 +1,410 @@
import 'package:epic_story_app/core/styles/epic_app_size.dart';
import 'package:epic_story_app/core/widgets/cards/collection_top_section.dart';
import 'package:epic_story_app/domain/entities/collection_book_entity.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'detail_collection_controller.dart';
double _s(double value) => EpicAppSize.calculatedSize(value);
class DetailCollectionPage extends StatelessWidget {
const DetailCollectionPage({super.key});
static const List<Color> _itemColors = [
Color(0xFFFFE070),
Color(0xFFFFDAA0),
Color(0xFFAAFFC0),
];
@override
Widget build(BuildContext context) {
final controller = Get.find<DetailCollectionController>();
return Scaffold(
backgroundColor: const Color(0xFFC6B2E6),
body: SafeArea(
child: Obx(() {
if (controller.loading.value) {
return const Center(child: CircularProgressIndicator());
}
final detail = controller.collectionDetail.value;
if (detail == null) {
return Center(
child: Text(
'Detail koleksi tidak tersedia',
style: TextStyle(fontSize: _s(15)),
),
);
}
final books = detail.books ?? [];
return Column(
children: [
CollectionTopSection(
title: 'Detail Koleksi',
onBackTap: Get.back,
leftIconAssetPath: 'assets/images/Book Stacks 1.png',
mascotAssetPath: 'assets/images/icon-top-bar.png',
yellowButtonAssetPath: 'assets/images/yellow-button.png',
yellowButtonText: 'Book',
titleTextSize: 39,
yellowButtonTextSize: 36,
yellowButtonWidth: 150,
yellowButtonHeight: 52,
),
Padding(
padding: EdgeInsets.fromLTRB(_s(16), _s(10), _s(16), 0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
detail.title ?? 'Koleksi Buku',
style: TextStyle(
fontSize: _s(22),
fontWeight: FontWeight.w900,
color: const Color(0xFF111111),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(_s(16), _s(6), _s(16), 0),
child: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: _s(8),
runSpacing: _s(8),
children: [
_ActionButton(
label: 'Edit Koleksi',
icon: Icons.edit,
backgroundColor: const Color(0xFFFFE070),
textColor: const Color(0xFF111111),
onTap: controller.goToEditCollection,
),
_ActionButton(
label: 'Hapus Koleksi',
icon: Icons.delete,
backgroundColor: const Color(0xFFFF0000),
textColor: Colors.white,
onTap: controller.deleteCollection,
),
],
),
),
),
Expanded(
child: books.isEmpty
? Center(
child: Text(
'Belum ada buku di koleksi ini',
style: TextStyle(
fontSize: _s(15),
fontWeight: FontWeight.w700,
color: Colors.black54,
),
),
)
: ListView.separated(
padding: EdgeInsets.fromLTRB(0, _s(8), 0, _s(20)),
itemCount: books.length,
separatorBuilder: (_, __) => SizedBox(height: _s(12)),
itemBuilder: (_, index) {
final book = books[index];
final color = _itemColors[index % _itemColors.length];
return _BookItem(
book: book,
backgroundColor: color,
onTap: () => controller.goToBookRead(book),
onDelete: () async {
await controller.deleteBookFromCollection(book);
},
);
},
),
),
],
);
}),
),
);
}
}
class _BookItem extends StatelessWidget {
final CollectionBookEntity book;
final Color backgroundColor;
final VoidCallback? onDelete;
final VoidCallback? onTap;
const _BookItem({
required this.book,
required this.backgroundColor,
this.onDelete,
this.onTap,
});
@override
Widget build(BuildContext context) {
final totalQuiz = book.totalQuiz ?? 0;
final completedQuiz = book.completedQuiz ?? 0;
final totalPages = book.nPages ?? 0;
return GestureDetector(
onTap: onTap,
child: Container(
padding: EdgeInsets.fromLTRB(_s(12), _s(12), _s(12), _s(8)),
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(color: Colors.white, width: _s(1)),
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CoverPlaceholder(coverUrl: book.coverUrl),
SizedBox(width: _s(10)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
book.title ?? '-'.toUpperCase(),
style: TextStyle(
fontSize: _s(20),
fontWeight: FontWeight.w900,
color: const Color(0xFF111111),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
GestureDetector(
onTap: onDelete,
child: Container(
width: _s(28),
height: _s(28),
decoration: BoxDecoration(
color: const Color(0xFFFF0000),
borderRadius: BorderRadius.circular(_s(4)),
),
child: Icon(
Icons.close_rounded,
size: _s(22),
color: Colors.white,
),
),
),
],
),
SizedBox(height: _s(4)),
if (book.summary != null && book.summary!.isNotEmpty)
Text(
book.summary!,
style: TextStyle(
fontSize: _s(17),
height: 1.2,
color: const Color(0xFF1A1A1A),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
SizedBox(height: _s(10)),
Text(
'Category : ${(book.category != null && book.category!.isNotEmpty) ? book.category : 'Kategori buku'}',
style: TextStyle(
fontSize: _s(17),
color: const Color(0xFF111111),
),
),
SizedBox(height: _s(18)),
],
),
),
],
),
SizedBox(height: _s(8)),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: _s(8),
vertical: _s(4),
),
decoration: BoxDecoration(
border: Border.all(
color: const Color(0xFF111111), width: _s(1)),
color: const Color(0xFFF5E9AE),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/images/book-stack2.png',
width: _s(16),
height: _s(16),
),
SizedBox(width: _s(4)),
Flexible(
child: Text(
'$completedQuiz dari $totalQuiz Kuis diselesaikan',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: _s(16),
color: const Color(0xFF111111),
),
),
),
],
),
),
),
SizedBox(width: _s(8)),
Text(
'Total Halaman : $totalPages',
style: TextStyle(
fontSize: _s(16),
fontWeight: FontWeight.w700,
color: const Color(0xFF111111),
),
),
],
),
],
),
),
);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final IconData icon;
final Color backgroundColor;
final Color textColor;
final VoidCallback? onTap;
const _ActionButton({
required this.label,
required this.icon,
required this.backgroundColor,
required this.textColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isDisabled = onTap == null;
final bgColor =
isDisabled ? backgroundColor.withOpacity(0.5) : backgroundColor;
final fgColor = isDisabled ? textColor.withOpacity(0.6) : textColor;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_s(12)),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: _s(12),
vertical: _s(7),
),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(_s(12)),
border: Border.all(color: const Color(0xFF111111), width: _s(1)),
boxShadow: [
BoxShadow(
color: const Color(0x33000000),
blurRadius: _s(6),
offset: Offset(0, _s(3)),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: _s(16), color: fgColor),
SizedBox(width: _s(6)),
Text(
label,
style: TextStyle(
fontSize: _s(14),
fontWeight: FontWeight.w800,
color: fgColor,
),
),
],
),
),
),
);
}
}
class _CoverPlaceholder extends StatelessWidget {
final String? coverUrl;
const _CoverPlaceholder({this.coverUrl});
@override
Widget build(BuildContext context) {
return Container(
width: _s(140),
height: _s(140),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_s(16)),
border: Border.all(color: const Color(0xFF111111), width: _s(1.2)),
),
alignment: Alignment.center,
child: coverUrl != null && coverUrl!.isNotEmpty
? ClipRRect(
borderRadius: BorderRadius.circular(_s(14)),
child: Image.network(
coverUrl!,
fit: BoxFit.cover,
width: _s(140),
height: _s(140),
errorBuilder: (_, __, ___) => _fallbackText(),
),
)
: _fallbackText(),
);
}
Widget _fallbackText() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.menu_book_rounded,
size: _s(26),
color: const Color(0xFF6B6B6B),
),
SizedBox(height: _s(4)),
Text(
'Cover Buku',
style: TextStyle(
fontSize: _s(11),
color: const Color(0xFF6B6B6B),
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:epic_story_app/data/modules/collection_module.dart';
import 'package:epic_story_app/data/modules/flashcard_module.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
import 'package:get/get.dart';
import 'detail_collection_flashcard_controller.dart';
class DetailCollectionFlashcardBidings extends Bindings {
@override
void dependencies() {
CollectionModule();
FlashCardModule();
Get.lazyPut(() => DetailCollectionFlashcardController(
collectionUsecase: Get.find<CollectionUsecase>(),
flashCardUsecase: Get.find<FlashCardUsecase>(),
));
}
}

View File

@ -0,0 +1,162 @@
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_detail_entity.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_detail_item_entity.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_args.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class DetailCollectionFlashcardController extends GetxController {
var collectionId = ''.obs;
var collectionDetail = Rx<FlashcardCollectionDetailEntity?>(null);
var loading = false.obs;
final CollectionUsecase collectionUsecase;
final FlashCardUsecase flashCardUsecase;
DetailCollectionFlashcardController({
required this.collectionUsecase,
required this.flashCardUsecase,
});
@override
void onInit() async {
super.onInit();
collectionId.value = Get.arguments;
await _loadDetail();
}
Future<void> _loadDetail() async {
try {
loading.value = true;
final detail = await collectionUsecase.getFlashcardCollectionDetail(
collectionId: collectionId.value,
);
collectionDetail.value = detail;
} catch (e, s) {
EpicLog.exception(e, s, this, '_loadDetail');
} finally {
loading.value = false;
}
}
Future<bool> _showDeleteConfirmation({
required String title,
required String subtitle,
}) async {
final result = await Get.dialog<bool>(
AlertDialog(
title: Text(title),
content: Text(subtitle),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Get.back(result: true),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFF0000),
),
child: const Text('Hapus'),
),
],
),
barrierDismissible: true,
);
return result ?? false;
}
Future<void> deleteBookFromCollection(
FlashCardCollectionDetailItemEntity flashcard) async {
final isConfirmed = await _showDeleteConfirmation(
title: 'Hapus flashcard dari koleksi?',
subtitle:
'Flashcard "${flashcard.title ?? '-'}" akan dihapus dari koleksi ini.',
);
if (!isConfirmed) return;
try {
loading.value = true;
await collectionUsecase.removeFlashcardFromCollection(
collectionId: collectionId.value,
flashcardId: flashcard.flashcardId!,
);
await _loadDetail();
} catch (e, s) {
EpicLog.exception(e, s, this, 'deleteBookFromCollection');
} finally {
loading.value = false;
}
}
Future<void> deleteCollection() async {
final detail = collectionDetail.value;
final id = detail?.flashcardId ?? collectionId.value;
if (id.isEmpty) return;
final isConfirmed = await _showDeleteConfirmation(
title: 'Hapus koleksi flashcard?',
subtitle: 'Koleksi "${detail?.title ?? '-'}" akan dihapus permanen.',
);
if (!isConfirmed) return;
try {
loading.value = true;
await collectionUsecase.removeFlashcardCollection(
collectionId: id,
);
loading.value = false;
Get.back(result: true);
} catch (e, s) {
EpicLog.exception(e, s, this, 'deleteCollection');
loading.value = false;
}
}
Future<void> goToEditCollection() async {
final detail = collectionDetail.value;
final id = detail?.flashcardId ?? collectionId.value;
if (id.isEmpty) return;
final result = await Get.toNamed(
EpicRoutes.createCollection,
arguments: CreateCollectionArgs.edit(
type: 'Flashcard',
collectionId: id,
title: detail?.title,
desc: detail?.desc,
bgColor: detail?.bgColor,
),
);
if (result == true) {
await _loadDetail();
}
}
Future<void> goToFlashcardRead({
required String flashcardId,
}) async {
try {
loading.value = true;
final flashcard = await flashCardUsecase.getFlashcardById(
flashcardId: flashcardId,
);
if (flashcard != null) {
Get.toNamed(EpicRoutes.flashCardRead, arguments: flashcard);
} else {
EpicSnackBar.showWarningSnackBar("Warning", "Flashcard not found");
}
} catch (e, s) {
EpicLog.exception(e, s, this, 'goToFlashcardRead');
} finally {
loading.value = false;
}
}
}

View File

@ -0,0 +1,344 @@
import 'package:epic_story_app/core/styles/epic_app_size.dart';
import 'package:epic_story_app/core/widgets/cards/collection_top_section.dart';
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_detail_item_entity.dart';
import 'package:epic_story_app/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
double _s(double value) => EpicAppSize.calculatedSize(value);
class DetailCollectionFlashcardHome
extends GetView<DetailCollectionFlashcardController> {
const DetailCollectionFlashcardHome({super.key});
static const List<int> _gradientIndexes = [2, 7, 6];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFC6B2E6),
body: SafeArea(
child: Obx(() {
if (controller.loading.value) {
return const Center(child: CircularProgressIndicator());
}
final detail = controller.collectionDetail.value;
final isDetailReady = detail != null;
final items = detail?.flashcards ?? [];
return Column(
children: [
CollectionTopSection(
title: 'Detail Koleksi',
onBackTap: Get.back,
leftIconAssetPath: 'assets/images/Book Stacks 1.png',
mascotAssetPath: 'assets/images/icon-top-bar.png',
yellowButtonAssetPath: 'assets/images/blue-button.png',
yellowButtonText: 'Flashcard',
titleTextSize: 37,
yellowButtonTextSize: 22,
yellowButtonWidth: 172,
yellowButtonHeight: 54,
),
if (detail != null)
Padding(
padding: EdgeInsets.fromLTRB(_s(16), _s(10), _s(16), 0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
detail.title ?? 'Koleksi Flashcard',
style: TextStyle(
fontSize: _s(22),
fontWeight: FontWeight.w900,
color: const Color(0xFF111111),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(_s(16), _s(6), _s(16), 0),
child: Align(
alignment: Alignment.centerRight,
child: Wrap(
spacing: _s(8),
runSpacing: _s(8),
children: [
_ActionButton(
label: 'Edit Koleksi',
icon: Icons.edit,
backgroundColor: const Color(0xFFAAFFC0),
textColor: const Color(0xFF111111),
onTap: isDetailReady
? controller.goToEditCollection
: null,
),
_ActionButton(
label: 'Hapus Koleksi',
icon: Icons.delete,
backgroundColor: const Color(0xFFFF0000),
textColor: Colors.white,
onTap:
isDetailReady ? controller.deleteCollection : null,
),
],
),
),
),
Expanded(
child: items.isEmpty
? Center(
child: Text(
'Belum ada flashcard dalam koleksi ini.',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black54,
),
),
)
: ListView.separated(
padding:
EdgeInsets.fromLTRB(_s(16), _s(12), _s(16), _s(16)),
itemBuilder: (context, index) {
final item = items[index];
final palette = collectionColorAt(
_gradientIndexes[index % _gradientIndexes.length],
);
return _FlashcardRow(
item: item,
gradient: palette.gradient,
controller: controller,
);
},
separatorBuilder: (_, __) => SizedBox(height: _s(12)),
itemCount: items.length,
),
),
],
);
}),
),
);
}
}
class _FlashcardRow extends StatelessWidget {
final FlashCardCollectionDetailItemEntity item;
final LinearGradient gradient;
final DetailCollectionFlashcardController controller;
const _FlashcardRow({
required this.item,
required this.gradient,
required this.controller,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
controller.goToFlashcardRead(flashcardId: item.flashcardId ?? '');
},
child: Container(
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(_s(20)),
border: Border.all(color: const Color(0x99FFFFFF), width: _s(1.2)),
boxShadow: [
BoxShadow(
color: Color(0x11000000),
blurRadius: _s(7),
offset: Offset(0, _s(3)),
),
],
),
child: Padding(
padding: EdgeInsets.fromLTRB(_s(12), _s(10), _s(12), _s(10)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? '-',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: _s(21),
fontWeight: FontWeight.w900,
color: const Color(0xFF111111),
),
),
SizedBox(height: _s(4)),
Text(
item.summary ?? '-',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: _s(14),
height: 1.35,
color: const Color(0xFF1A1A1A),
),
),
],
),
),
SizedBox(width: _s(8)),
GestureDetector(
onTap: () {
controller.deleteBookFromCollection(item);
},
child: Container(
width: _s(28),
height: _s(28),
decoration: BoxDecoration(
color: const Color(0xFFFF0000),
borderRadius: BorderRadius.circular(_s(4)),
),
child: Icon(
Icons.close_rounded,
size: _s(22),
color: Colors.white,
),
),
),
],
),
SizedBox(height: _s(9)),
Wrap(
spacing: _s(6),
runSpacing: _s(6),
children: [
_chip(
text: '${item.totalCards ?? 0} kartu',
icon: Icons.folder_open_outlined,
),
_chip(text: item.category ?? '-'),
_chip(
text: '${item.completedQuiz ?? 0}/${item.totalQuiz ?? 0}',
icon: Icons.assignment_outlined,
),
],
),
SizedBox(height: _s(8)),
Align(
alignment: Alignment.centerRight,
child: Text(
'',
style: TextStyle(
fontSize: _s(12),
color: const Color(0xFF111111),
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
Widget _chip({required String text, IconData? icon}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: _s(7), vertical: _s(3)),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.55),
borderRadius: BorderRadius.circular(_s(6)),
border: Border.all(color: const Color(0xFF111111), width: _s(0.9)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: _s(13), color: const Color(0xFF111111)),
SizedBox(width: _s(4)),
],
Text(
text,
style: TextStyle(
fontSize: _s(13),
fontWeight: FontWeight.w600,
color: const Color(0xFF111111),
),
),
],
),
);
}
}
class _ActionButton extends StatelessWidget {
final String label;
final IconData icon;
final Color backgroundColor;
final Color textColor;
final VoidCallback? onTap;
const _ActionButton({
required this.label,
required this.icon,
required this.backgroundColor,
required this.textColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isDisabled = onTap == null;
final bgColor =
isDisabled ? backgroundColor.withOpacity(0.5) : backgroundColor;
final fgColor = isDisabled ? textColor.withOpacity(0.6) : textColor;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(_s(12)),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: _s(12),
vertical: _s(7),
),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(_s(12)),
border: Border.all(color: const Color(0xFF111111), width: _s(1)),
boxShadow: [
BoxShadow(
color: const Color(0x33000000),
blurRadius: _s(6),
offset: Offset(0, _s(3)),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: _s(16), color: fgColor),
SizedBox(width: _s(6)),
Text(
label,
style: TextStyle(
fontSize: _s(14),
fontWeight: FontWeight.w800,
color: fgColor,
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,59 @@
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/components/collection_card.dart';
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class BookCollection extends StatelessWidget {
final FavoriteHomeController controller;
const BookCollection({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return Obx(() {
final items = controller.collections;
if (items.isEmpty) {
return ColoredBox(
color: const Color(0xFFC6B2E6),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: const [
SizedBox(height: 80),
Center(
child: Text(
'Belum ada koleksi buku',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black54,
),
),
),
],
),
);
}
return ColoredBox(
color: const Color(0xFFC6B2E6),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(height: 14),
itemBuilder: (_, index) => CollectionCard(
item: items[index],
index: index,
onTap: () {
controller.goToCollectionBookDetail(items[index]);
},
onDelete: () {
controller.deleteBookCollection(items[index]);
},
),
),
);
});
}
}

View File

@ -0,0 +1,322 @@
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
import 'package:flutter/material.dart';
class CollectionCard extends StatelessWidget {
final ChildrenCollectionEntity item;
final int index;
final VoidCallback? onTap;
final VoidCallback? onDelete;
const CollectionCard({
super.key,
required this.item,
required this.index,
required this.onTap,
this.onDelete,
});
@override
Widget build(BuildContext context) {
final categories = item.categories ?? const [];
final visibleCategories = categories.isNotEmpty
? categories.take(2).toList()
: const ['Kategori 1'];
final baseColor = collectionColorAt(item.bgColor ?? index).color;
final cardColor = Color.lerp(baseColor, Colors.white, 0.45)!;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: baseColor.withValues(alpha: 0.25),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CoverStack(covers: item.bookCovers, accentColor: baseColor),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title ?? 'Nama Koleksi Buku',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w900,
color: Color(0xFF111111),
height: 1,
),
),
const SizedBox(height: 6),
Text(
item.desc ??
'Deskripsi koleksi belum tersedia untuk saat ini.',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1D1D1D),
height: 1.25,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: visibleCategories
.map((cat) => _MetaChip(text: cat))
.toList(),
),
],
),
),
),
const SizedBox(width: 8),
_DeleteBadge(onTap: onDelete),
],
),
const SizedBox(height: 8),
_TotalBooksBadge(total: item.nBooks ?? 0),
],
),
),
);
}
}
class _CoverStack extends StatelessWidget {
final List<String>? covers;
final Color accentColor;
const _CoverStack({
this.covers,
required this.accentColor,
});
@override
Widget build(BuildContext context) {
final items = (covers ?? const <String>[])
.where((cover) => cover.trim().isNotEmpty)
.toList();
final displayed = items.take(3).toList();
final count = displayed.isEmpty ? 1 : displayed.length;
return SizedBox(
width: 110,
height: 128,
child: Stack(
clipBehavior: Clip.none,
children: [
for (int i = 0; i < count; i++)
Positioned(
left: i * 7,
top: i * 3,
child: _CoverBox(
width: 94,
height: 118,
url: displayed.isEmpty
? null
: displayed[displayed.length - 1 - i],
opacity: 1 - (i * 0.15),
accentColor: accentColor,
),
),
],
),
);
}
}
class _CoverBox extends StatelessWidget {
final double width;
final double height;
final String? url;
final double opacity;
final Color accentColor;
const _CoverBox({
required this.width,
required this.height,
required this.url,
required this.opacity,
required this.accentColor,
});
@override
Widget build(BuildContext context) {
return Opacity(
opacity: opacity,
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.84),
border: Border.all(color: const Color(0xFF111111), width: 1.2),
boxShadow: [
BoxShadow(
color: accentColor.withValues(alpha: 0.18),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: url != null
? Image.network(
url!,
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: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.menu_book_rounded,
size: 26,
color: Color(0xFF6B6B6B),
),
SizedBox(height: 4),
Text(
'Cover Buku',
style: TextStyle(
fontSize: 10,
color: Color(0xFF6B6B6B),
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
],
),
);
}
}
class _MetaChip extends StatelessWidget {
final String text;
const _MetaChip({required this.text});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF111111), width: 1),
),
child: Text(
text,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Color(0xFF111111),
height: 1.1,
),
),
);
}
}
class _TotalBooksBadge extends StatelessWidget {
final int total;
const _TotalBooksBadge({required this.total});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
border: Border.all(color: const Color(0xFF111111), width: 1),
color: Colors.transparent,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.menu_book_rounded,
size: 16,
color: Color(0xFFA35BE2),
),
const SizedBox(width: 4),
Text(
'Total Buku : $total',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF111111),
height: 1,
),
),
],
),
);
}
}
class _DeleteBadge extends StatelessWidget {
final VoidCallback? onTap;
const _DeleteBadge({this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: const Color(0xFFFF0000),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.close_rounded,
size: 22,
color: Colors.white,
),
),
);
}
}

View File

@ -0,0 +1,274 @@
import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FlashcardCollection extends StatelessWidget {
const FlashcardCollection({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<FavoriteHomeController>();
return Obx(() {
final items = controller.flashcardCollections;
if (items.isEmpty) {
return ColoredBox(
color: const Color(0xFFC6B2E6),
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: const [
SizedBox(height: 80),
Center(
child: Text(
'Belum ada koleksi flashcard',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black54,
),
),
),
],
),
);
}
return ColoredBox(
color: const Color(0xFFC6B2E6),
child: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
itemBuilder: (context, index) {
final item = items[index];
return _FlashcardCollectionCard(
item: item,
index: index,
controller: controller,
);
},
separatorBuilder: (_, __) => const SizedBox(height: 14),
itemCount: items.length,
),
);
});
}
}
class _FlashcardCollectionCard extends StatelessWidget {
final FlashCardCollectionEntity item;
final int index;
final FavoriteHomeController controller;
const _FlashcardCollectionCard({
required this.item,
required this.controller,
required this.index,
});
@override
Widget build(BuildContext context) {
final categories = item.categories ?? const [];
final totalCards = item.totalCards ?? 0;
final totalFlashcard = item.totalFlashcard ?? 0;
final paletteColor = collectionColorAt(item.bgColor ?? index);
final topColor =
Color.lerp(paletteColor.gradient.colors.last, Colors.white, 0.35)!;
final coverAsset =
_resolveCategoryImage(categories.isNotEmpty ? categories.first : null);
return GestureDetector(
onTap: () {
controller.goToCollectionFlashcardDetail(item);
},
child: Container(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
topColor,
Colors.white,
],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: paletteColor.color.withValues(alpha: 0.25),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(65, 2, 36, 0),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
item.title ?? 'Nama Koleksi Flashcard',
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w900,
color: Color(0xFF101010),
height: 1,
),
),
),
),
Align(
alignment: Alignment.topRight,
child: GestureDetector(
onTap: () {
controller.deleteFlashcardCollection(item);
},
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: const Color(0xFFFF0000),
borderRadius: BorderRadius.circular(4),
),
child: const Icon(
Icons.close_rounded,
size: 22,
color: Colors.white,
),
),
),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: paletteColor.color,
width: 3,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Image.asset(
coverAsset,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.desc ??
'Deskripsi koleksi belum tersedia untuk saat ini.',
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
height: 1.25,
color: Color(0xFF1F1F1F),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 6,
children: [
_chip(
icon: Icons.collections_bookmark_outlined,
text:
'${totalCards > 0 ? totalCards : totalFlashcard} kartu',
),
_chip(
text: categories.isNotEmpty
? categories.first
: 'Lainnya',
),
Text(
'- ${totalFlashcard}x dibaca',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF212121),
),
),
],
),
],
),
),
],
),
],
),
),
);
}
Widget _chip({required String text, IconData? icon}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF111111), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: const Color(0xFF111111)),
const SizedBox(width: 4),
],
Text(
text,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF111111),
height: 1.1,
),
),
],
),
);
}
String _resolveCategoryImage(String? category) {
final normalized = (category ?? '').toLowerCase();
if (normalized.contains('matematika') ||
normalized.contains('ipa') ||
normalized.contains('sains')) {
return 'assets/images/flashcard-matematika.png';
}
if (normalized.contains('bahasa') || normalized.contains('indo')) {
return 'assets/images/flashcard-bhs-indo.png';
}
return index.isEven
? 'assets/images/flashcard-bhs-indo.png'
: 'assets/images/flashcard-matematika.png';
}
}

View File

@ -0,0 +1,14 @@
import 'package:epic_story_app/data/modules/collection_module.dart';
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
import 'package:get/get.dart';
class FavoriteHomeBinding extends Bindings {
@override
void dependencies() {
CollectionModule();
Get.lazyPut(() => FavoriteHomeController(
collectionUsecase: Get.find<CollectionUsecase>(),
));
}
}

View File

@ -0,0 +1,151 @@
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/children_collection_entity.dart';
import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
import 'package:epic_story_app/domain/usecases/collection_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/material.dart';
import 'package:get/get.dart';
class FavoriteHomeController extends GetxController {
var collections = <ChildrenCollectionEntity>[].obs;
var flashcardCollections = <FlashCardCollectionEntity>[].obs;
final mainController = Get.find<MainController>();
final navController = Get.find<EpicNavigationController>();
var isBook = true.obs;
final CollectionUsecase collectionUsecase;
FavoriteHomeController({
required this.collectionUsecase,
});
@override
void onInit() async {
super.onInit();
await refreshData();
ever(navController.refreshCollectionHome, (value) {
refreshData(refreshRemote: true);
});
}
Future<void> refreshData({bool refreshRemote = false}) async {
try {
EpicLog.debug('Refreshing collections data');
flashcardCollections.clear();
if (refreshRemote) {
await mainController.childrenUsecase.syncChildrenData();
}
var children = await mainController.childrenUsecase.getCurrentChildren();
collections.clear();
flashcardCollections.clear();
collections.addAll(children?.collections ?? []);
flashcardCollections.addAll(children?.flashcardCollections ?? []);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'refreshData');
}
}
Future<void> onRefresh() async {
await refreshData(refreshRemote: true);
}
Future<bool> _showDeleteConfirmation({
required String title,
required String subtitle,
}) async {
final result = await Get.dialog<bool>(
AlertDialog(
title: Text(title),
content: Text(subtitle),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Batal'),
),
FilledButton(
onPressed: () => Get.back(result: true),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFF0000),
),
child: const Text('Hapus'),
),
],
),
barrierDismissible: true,
);
return result ?? false;
}
Future<void> deleteBookCollection(
ChildrenCollectionEntity data,
) async {
try {
if (data.id.isEmpty) return;
final isConfirmed = await _showDeleteConfirmation(
title: 'Hapus koleksi buku?',
subtitle: 'Koleksi "${data.title ?? '-'}" akan dihapus permanen.',
);
if (!isConfirmed) return;
await collectionUsecase.removeCollection(
collectionId: data.id,
);
await refreshData(refreshRemote: true);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'deleteBookCollection');
}
}
Future<void> deleteFlashcardCollection(
FlashCardCollectionEntity data,
) async {
try {
if (data.collectionId.isEmpty) return;
final isConfirmed = await _showDeleteConfirmation(
title: 'Hapus koleksi flashcard?',
subtitle: 'Koleksi "${data.title ?? '-'}" akan dihapus permanen.',
);
if (!isConfirmed) return;
await collectionUsecase.removeFlashcardCollection(
collectionId: data.collectionId,
);
await refreshData(refreshRemote: true);
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'deleteFlashcardCollection');
}
}
void goToCreateCollection() async {
await Get.toNamed(
EpicRoutes.createCollection,
);
await refreshData(refreshRemote: true);
}
void goToCollectionBookDetail(
ChildrenCollectionEntity data,
) async {
await Get.toNamed(
EpicRoutes.detailCollection,
arguments: data.id,
);
await refreshData(refreshRemote: true);
}
void goToCollectionFlashcardDetail(
FlashCardCollectionEntity data,
) async {
await Get.toNamed(
EpicRoutes.detailCollectionFlashcard,
arguments: data.collectionId,
);
await refreshData(refreshRemote: true);
}
}

View File

@ -0,0 +1,209 @@
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/components/book_collection.dart';
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/components/flashcard_collection.dart';
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class FavoriteHomePage extends StatelessWidget {
const FavoriteHomePage({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<FavoriteHomeController>();
return Scaffold(
backgroundColor: Colors.white,
floatingActionButton: FloatingActionButton(
onPressed: () {
controller.goToCreateCollection();
},
backgroundColor: Colors.transparent,
elevation: 0,
highlightElevation: 0,
child: Image.asset(
'assets/images/subway_add.png',
width: 56,
height: 56,
fit: BoxFit.contain,
),
),
body: Obx(() {
return Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
height: 170,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF7E32AB),
Color(0xFFE0CEFF),
],
stops: [0.0, 1.0],
),
),
),
),
Positioned(
top: 60,
left: 16,
right: 16,
child: _TypeSwitcher(controller: controller),
),
Padding(
padding: const EdgeInsets.only(top: 156),
child: RefreshIndicator(
onRefresh: controller.onRefresh,
child: controller.isBook.value
? BookCollection(controller: controller)
: const FlashcardCollection(),
),
),
],
);
}),
);
}
}
class _TypeSwitcher extends StatelessWidget {
final FavoriteHomeController controller;
const _TypeSwitcher({required this.controller});
@override
Widget build(BuildContext context) {
final bool isBookActive = controller.isBook.value;
return Container(
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFECB63F), Color(0xFF0088FF)],
),
border: Border.all(color: Colors.black87, width: 1.4),
borderRadius: BorderRadius.circular(24),
boxShadow: const [
BoxShadow(
color: Color(0x29000000),
blurRadius: 10,
offset: Offset(0, 3),
),
],
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Stack(
fit: StackFit.expand,
children: [
AnimatedAlign(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
alignment:
isBookActive ? Alignment.centerLeft : Alignment.centerRight,
child: FractionallySizedBox(
widthFactor: 0.5,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: Image.asset(
_activeAssetPath(isBookActive),
fit: BoxFit.fill,
),
),
),
),
),
),
Row(
children: [
Expanded(
child: _item(
label: 'Buku',
value: true,
active: isBookActive,
),
),
Expanded(
child: _item(
label: 'Flashcard',
value: false,
active: !isBookActive,
),
),
],
),
],
),
),
);
}
String _activeAssetPath(bool isBookActive) {
if (isBookActive) {
return 'assets/images/button-book-active.png';
}
return 'assets/images/button-flashcard-acive.png';
}
Widget _item({
required String label,
required bool value,
required bool active,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => controller.isBook.value = value,
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
style: TextStyle(
color: active ? Colors.transparent : Colors.white,
fontSize: 30,
fontWeight: FontWeight.w900,
height: 1,
shadows: active
? null
: const [
Shadow(
color: Color(0xFF171717),
offset: Offset(1.2, 1.2),
blurRadius: 0,
),
Shadow(
color: Color(0xB3000000),
offset: Offset(0, 0),
blurRadius: 5,
),
],
),
child: Text(label),
),
),
),
);
}
}