feat: add favorites, AI tutors, and references features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
1ab2705f46
commit
5e698ae78c
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
class AiTutorController extends GetxController{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue