feat: add home, profiles, and rewards features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
edcee652d2
commit
17ca14c40c
|
|
@ -0,0 +1,334 @@
|
|||
import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
|
||||
import 'package:epic_story_app/feature/home/enums/home_state.dart';
|
||||
import 'package:epic_story_app/feature/home/home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/state_manager.dart';
|
||||
|
||||
class CategoryStateWidget extends StatelessWidget {
|
||||
final HomeController controller;
|
||||
|
||||
static const Color _mainBackground = Color(0xFFE0CEFF);
|
||||
|
||||
const CategoryStateWidget({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final bool isBookMode = controller.navController.isBook.value;
|
||||
final categories = _displayedCategories(controller.categories);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: _mainBackground,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
StackedInfoTopBar(
|
||||
titleText: isBookMode ? 'Buku' : 'Flashcard',
|
||||
infoText:
|
||||
'Silahkan pilih mata pelajaran apa yang ingin kamu pelajari',
|
||||
isBackButtonVisible: true,
|
||||
onBackTap: () {
|
||||
controller.state.value = HomeState.home;
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 68, 18, 20),
|
||||
child: ListView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
children: [
|
||||
// _buildAllCategoryButton(
|
||||
// onTap: () {
|
||||
// controller.state.value = HomeState.content;
|
||||
// controller.navController.selectedCategory.value =
|
||||
// 'Semua';
|
||||
// },
|
||||
// ),
|
||||
if (categories.isEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildEmptyCategory(),
|
||||
] else ...[
|
||||
const SizedBox(height: 16),
|
||||
...List.generate(categories.length, (index) {
|
||||
final category = categories[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
index == categories.length - 1 ? 0 : 16),
|
||||
child: _buildCategoryCard(
|
||||
category: category,
|
||||
index: index,
|
||||
isBookMode: isBookMode,
|
||||
onTap: () {
|
||||
controller.state.value = HomeState.content;
|
||||
controller.navController.selectedCategory
|
||||
.value = category;
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
List<String> _displayedCategories(List<String> categories) {
|
||||
final filtered = categories
|
||||
.where((category) =>
|
||||
category.trim().isNotEmpty && category.toLowerCase() != 'semua')
|
||||
.toList();
|
||||
|
||||
if (filtered.isNotEmpty) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
return categories
|
||||
.where((category) => category.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Widget _buildCategoryCard({
|
||||
required String category,
|
||||
required int index,
|
||||
required bool isBookMode,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
if (isBookMode) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 126,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFF262626), width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.asset(
|
||||
_resolveBookAsset(category, index),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, _, __) => _buildFallbackLabel(
|
||||
category,
|
||||
textColor: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 126,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: _flashcardGradient(index),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFF1F1F1F), width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 108,
|
||||
height: 108,
|
||||
padding: const EdgeInsets.all(4),
|
||||
alignment: Alignment.center,
|
||||
child: Image.asset(
|
||||
_resolveFlashcardAsset(category, index),
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) => const Icon(
|
||||
Icons.auto_stories,
|
||||
size: 44,
|
||||
color: Color(0xFF303030),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
category,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF111111),
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFallbackLabel(
|
||||
String label, {
|
||||
required Color textColor,
|
||||
}) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAllCategoryButton({
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 58,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF7E32AB),
|
||||
Color(0xFF4D1E68),
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: const Color(0xFF311045), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7E32AB).withOpacity(0.38),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.grid_view_rounded,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'Semua',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyCategory() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFD2B6EA)),
|
||||
),
|
||||
child: const Text(
|
||||
'Tidak ada kategori',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Color(0xFF4D4060),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveBookAsset(String category, int index) {
|
||||
final normalized = category.toLowerCase();
|
||||
|
||||
if (normalized.contains('matematika')) {
|
||||
return 'assets/images/book-matematika.png';
|
||||
}
|
||||
if (normalized.contains('bahasa') || normalized.contains('indo')) {
|
||||
return 'assets/images/book-bhs-indo.png';
|
||||
}
|
||||
|
||||
return index.isEven
|
||||
? 'assets/images/book-bhs-indo.png'
|
||||
: 'assets/images/book-matematika.png';
|
||||
}
|
||||
|
||||
String _resolveFlashcardAsset(String category, int index) {
|
||||
final normalized = category.toLowerCase();
|
||||
|
||||
if (normalized.contains('matematika')) {
|
||||
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';
|
||||
}
|
||||
|
||||
LinearGradient _flashcardGradient(int index) {
|
||||
if (index % 2 == 0) {
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFFFFE588),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFFFFFFFF),
|
||||
Color(0xFF77B4FF),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_page.dart';
|
||||
import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_page.dart';
|
||||
import 'package:epic_story_app/feature/home/enums/home_state.dart';
|
||||
import 'package:epic_story_app/feature/home/home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/state_manager.dart';
|
||||
|
||||
class HomeContentState extends StatelessWidget {
|
||||
final HomeController controller;
|
||||
|
||||
static const Color _mainBackground = Color(0xFFE0CEFF);
|
||||
|
||||
const HomeContentState({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
final bool isBookMode = controller.navController.isBook.value;
|
||||
final String selectedCategory =
|
||||
controller.navController.selectedCategory.value;
|
||||
final String activeCategory =
|
||||
selectedCategory.isEmpty ? 'Semua' : selectedCategory;
|
||||
final String infoText = 'Mata Pelajaran :\n$activeCategory';
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: _mainBackground,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
StackedInfoTopBar(
|
||||
titleText: isBookMode ? 'Buku' : 'Flashcard',
|
||||
infoText: infoText,
|
||||
isBackButtonVisible: true,
|
||||
onBackTap: () {
|
||||
controller.state.value = HomeState.category;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(14, 0, 14, 0),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE0CEFF),
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: isBookMode
|
||||
? const BookHomePage()
|
||||
: const FlashCardHomePage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:epic_story_app/feature/home/enums/home_state.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../home_controller.dart';
|
||||
|
||||
class HomeStateWidget extends StatelessWidget {
|
||||
const HomeStateWidget({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final HomeController controller;
|
||||
|
||||
static const Color _mainBackground = Color(0xFFE0CEFF);
|
||||
static const Color _bookCardBg = Color(0xFFFFCF25);
|
||||
static const Color _flashcardCardBg = Color(0xFF1A6EFC);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: _mainBackground,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Obx(
|
||||
() => StackedInfoTopBar(
|
||||
titleText: 'Tipe Belajar',
|
||||
infoText: 'Silahkan pilih tipe belajar\nyang kamu mau',
|
||||
isBackButtonVisible: controller.state.value != HomeState.home,
|
||||
onBackTap: controller.onBack,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 70, 18, 120),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
'Pilih Tipe Belajar Kamu',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.black,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0x40000000),
|
||||
offset: Offset(0, 1),
|
||||
blurRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
_buildBookCard(),
|
||||
const SizedBox(height: 24),
|
||||
_buildFlashcardCard(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBookCard() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
controller.navController.isBook.value = true;
|
||||
controller.state.value = HomeState.category;
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 142,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 25),
|
||||
decoration: BoxDecoration(
|
||||
color: _bookCardBg,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/tipe-book.png',
|
||||
width: 128,
|
||||
height: 128,
|
||||
),
|
||||
const SizedBox(width: 8), // atur jarak di sini
|
||||
const Text(
|
||||
'Buku',
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 38,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFlashcardCard() {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
controller.navController.isBook.value = false;
|
||||
controller.state.value = HomeState.category;
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 142,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 25),
|
||||
decoration: BoxDecoration(
|
||||
color: _flashcardCardBg,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.16),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 1,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/tipe-flashcard.png',
|
||||
width: 168,
|
||||
height: 82,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'Flashcard',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:zoom_tap_animation/zoom_tap_animation.dart';
|
||||
|
||||
class HomeType extends StatelessWidget {
|
||||
const HomeType({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ZoomTapAnimation(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 150,
|
||||
width: 300,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.black,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
enum HomeState {
|
||||
home,
|
||||
category,
|
||||
content
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:epic_story_app/data/modules/book_module.dart';
|
||||
import 'package:epic_story_app/data/modules/children_module.dart';
|
||||
import 'package:epic_story_app/data/modules/collection_module.dart';
|
||||
import 'package:epic_story_app/data/modules/flashcard_module.dart';
|
||||
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'home_controller.dart';
|
||||
|
||||
class HomeBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
ChildrenModule();
|
||||
CollectionModule();
|
||||
FlashCardModule();
|
||||
BookModule();
|
||||
Get.lazyPut<HomeController>(
|
||||
() => HomeController(
|
||||
collectionUsecase: Get.find<CollectionUsecase>(),
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
flashCardUsecase: Get.find<FlashCardUsecase>(),
|
||||
bookUsecase: Get.find<BookUsecase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
import 'package:epic_story_app/core/utils/epic_log.dart';
|
||||
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
|
||||
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
|
||||
import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_controller.dart';
|
||||
import 'package:epic_story_app/feature/home/components/category_state_widget.dart';
|
||||
import 'package:epic_story_app/feature/home/components/home_content_state.dart';
|
||||
import 'package:epic_story_app/feature/home/components/home_state_widget.dart';
|
||||
import 'package:epic_story_app/feature/home/enums/home_state.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class HomeController extends GetxController {
|
||||
final FlashCardUsecase flashCardUsecase;
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
final BookUsecase bookUsecase;
|
||||
final CollectionUsecase collectionUsecase;
|
||||
|
||||
HomeController({
|
||||
required this.flashCardUsecase,
|
||||
required this.childrenUsecase,
|
||||
required this.bookUsecase,
|
||||
required this.collectionUsecase,
|
||||
});
|
||||
|
||||
var isLoading = false.obs;
|
||||
var state = HomeState.home.obs;
|
||||
|
||||
final mainController = Get.find<MainController>();
|
||||
final navController = Get.find<EpicNavigationController>();
|
||||
final flashcardController = Get.find<FlashCardHomeController>();
|
||||
final bookController = Get.find<BookHomeController>();
|
||||
|
||||
final categories = <String>[].obs;
|
||||
Worker? _bookModeWorker;
|
||||
int _switchTicket = 0;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
||||
_bookModeWorker = ever<bool>(navController.isBook, (value) {
|
||||
_syncContentForMode(value);
|
||||
});
|
||||
|
||||
_syncContentForMode(navController.isBook.value);
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_bookModeWorker?.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> _syncContentForMode(
|
||||
bool isBookMode, {
|
||||
bool forceReload = false,
|
||||
}) async {
|
||||
final ticket = ++_switchTicket;
|
||||
final shouldLoad = forceReload ||
|
||||
(isBookMode
|
||||
? bookController.books.isEmpty
|
||||
: flashcardController.flashcards.isEmpty);
|
||||
|
||||
if (shouldLoad) {
|
||||
isLoading.value = true;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isBookMode) {
|
||||
if (forceReload || bookController.books.isEmpty) {
|
||||
await bookController.loadBooks();
|
||||
}
|
||||
if (_isStale(ticket)) return;
|
||||
categories.assignAll(bookController.categories);
|
||||
} else {
|
||||
if (forceReload || flashcardController.flashcards.isEmpty) {
|
||||
await flashcardController.fetchFlashcards();
|
||||
}
|
||||
if (_isStale(ticket)) return;
|
||||
categories.assignAll(flashcardController.categories);
|
||||
}
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, this, '_syncContentForMode');
|
||||
} finally {
|
||||
if (!_isStale(ticket)) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isStale(int ticket) => isClosed || ticket != _switchTicket;
|
||||
|
||||
Future<void> refreshCurrentView() async {
|
||||
await _syncContentForMode(
|
||||
navController.isBook.value,
|
||||
forceReload: true,
|
||||
);
|
||||
}
|
||||
|
||||
Widget getState() {
|
||||
switch (state.value) {
|
||||
case HomeState.home:
|
||||
return HomeStateWidget(controller: this);
|
||||
case HomeState.category:
|
||||
return CategoryStateWidget(controller: this);
|
||||
case HomeState.content:
|
||||
return HomeContentState(controller: this);
|
||||
}
|
||||
}
|
||||
|
||||
void onBack() {
|
||||
isLoading.value = false;
|
||||
if (state.value == HomeState.home) return;
|
||||
if (state.value == HomeState.category) {
|
||||
state.value = HomeState.home;
|
||||
} else if (state.value == HomeState.content) {
|
||||
state.value = HomeState.category;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'home_controller.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.find<HomeController>();
|
||||
|
||||
return Obx(
|
||||
() => PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
controller.onBack();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFE0CEFF),
|
||||
body: controller.isLoading.value
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: controller.getState(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import 'package:epic_story_app/data/modules/children_module.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/feature/profiles/presentation/profile_controller.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ProfileBinding extends Bindings{
|
||||
@override
|
||||
void dependencies() {
|
||||
ChildrenModule();
|
||||
|
||||
Get.lazyPut(() => ProfileController(
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:epic_story_app/core/routes/epic_routes.dart';
|
||||
import 'package:epic_story_app/domain/entities/children_entity.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ProfileController extends GetxController {
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
|
||||
ProfileController({
|
||||
required this.childrenUsecase,
|
||||
});
|
||||
|
||||
final mainController = Get.find<MainController>();
|
||||
|
||||
var childrenData = Rx<ChildrenEntity?>(null);
|
||||
|
||||
@override
|
||||
void onInit() async {
|
||||
super.onInit();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
childrenData.value = await childrenUsecase.getCurrentChildren();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
Get.defaultDialog(
|
||||
title: "Konfirmasi",
|
||||
middleText: "Yakin mau logout?",
|
||||
textConfirm: "Ya",
|
||||
textCancel: "Batal",
|
||||
confirmTextColor: Colors.white,
|
||||
onConfirm: () async {
|
||||
await mainController.studentLogout();
|
||||
Get.offAllNamed(EpicRoutes.welcome);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
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/domain/entities/children_entity.dart';
|
||||
import 'package:epic_story_app/feature/profiles/presentation/profile_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
static const Color _pageBackground = Color(0xFFC6B2E6);
|
||||
static const Color _profileBox = Color(0xFFB400FF);
|
||||
static const Color _logoutButton = Color(0xFFFF0000);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ProfileController controller = Get.find();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _pageBackground,
|
||||
body: SafeArea(
|
||||
child: Obx(
|
||||
() {
|
||||
final ChildrenEntity? children = controller.childrenData.value;
|
||||
if (children == null) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Color(0xFF7E32AB),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final photoUrl = children.photoPicture;
|
||||
final initials = _initials(children.fullName ?? '');
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 146,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
height: EpicAppSize.calculatedSize(95),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 125, 8),
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
bottom: Radius.circular(18),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Color(0xFF2C0B3A),
|
||||
Color(0xFF7E32AB),
|
||||
],
|
||||
stops: [0.1, 1.0],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: EpicAppSize.calculatedSize(50)),
|
||||
Image.asset(
|
||||
'assets/images/coin-gancet.png',
|
||||
width: EpicAppSize.calculatedSize(60),
|
||||
height: EpicAppSize.calculatedSize(60),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
SizedBox(width: EpicAppSize.calculatedSize(8)),
|
||||
Text(
|
||||
'Profil Kamu',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: EpicAppSize.calculatedSize(30),
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color(0x3D000000),
|
||||
blurRadius: 2,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: -12,
|
||||
right: 6,
|
||||
child: Image.asset(
|
||||
'assets/images/icon-top-bar.png',
|
||||
width: EpicAppSize.calculatedSize(100),
|
||||
height: EpicAppSize.calculatedSize(100),
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(24, 6, 24, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 240,
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: _profileBox,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x29000000),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 56,
|
||||
backgroundColor: const Color(0xFFC8A2E8),
|
||||
backgroundImage:
|
||||
photoUrl != null && photoUrl.isNotEmpty
|
||||
? NetworkImage(photoUrl)
|
||||
: null,
|
||||
child: photoUrl == null || photoUrl.isEmpty
|
||||
? Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
children.fullName ?? 'Nama tidak tersedia',
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EpicSize.headlineLarge.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: EpicAppSize.calculatedSize(30),
|
||||
height: 0.95,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
children.email ?? '-',
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: EpicAppSize.calculatedSize(18),
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 68),
|
||||
SizedBox(
|
||||
width: 122,
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: controller.logout,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _logoutButton,
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
// side: const BorderSide(
|
||||
// color: Colors.white,
|
||||
// width: 1.1,
|
||||
// ),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Logout',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: EpicAppSize.calculatedSize(22),
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _initials(String name) {
|
||||
final parts = name.trim().split(RegExp(r'\s+'));
|
||||
if (parts.isEmpty) return '??';
|
||||
final first = parts.first.isNotEmpty ? parts.first[0] : '';
|
||||
final last = parts.length > 1 && parts.last.isNotEmpty ? parts.last[0] : '';
|
||||
final result = (first + last).toUpperCase();
|
||||
return result.isEmpty ? '??' : result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/components/reward_claim_button.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class BookReward extends StatelessWidget {
|
||||
const BookReward({super.key, required this.controller});
|
||||
|
||||
final RewardHomeController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final books = controller.books;
|
||||
if (books.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 18),
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(
|
||||
child: Text(
|
||||
'Belum ada hadiah buku yang tersedia.',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 18),
|
||||
itemCount: books.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final book = books[index];
|
||||
final history = controller.historyForBook(book.bookId);
|
||||
return GestureDetector(
|
||||
onTap: () => controller.goToBookRead(book),
|
||||
child: _BookRewardCard(
|
||||
itemIndex: index,
|
||||
book: book,
|
||||
history: history,
|
||||
onClaim: () => controller.openBookRewardClaim(book),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _BookRewardCard extends StatefulWidget {
|
||||
const _BookRewardCard({
|
||||
required this.itemIndex,
|
||||
required this.book,
|
||||
required this.history,
|
||||
required this.onClaim,
|
||||
});
|
||||
|
||||
final int itemIndex;
|
||||
final BookEntity book;
|
||||
final BookHistoryEntity? history;
|
||||
final VoidCallback onClaim;
|
||||
|
||||
@override
|
||||
State<_BookRewardCard> createState() => _BookRewardCardState();
|
||||
}
|
||||
|
||||
class _BookRewardCardState extends State<_BookRewardCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final MainController mainController = Get.find<MainController>();
|
||||
|
||||
late final AnimationController _btnScaleController;
|
||||
late final Animation<double> _btnScale;
|
||||
bool _animating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_btnScaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 950),
|
||||
);
|
||||
_btnScale = Tween<double>(begin: 0.96, end: 1.04).animate(
|
||||
CurvedAnimation(parent: _btnScaleController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_btnScaleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncButtonAnimation(bool shouldAnimate) {
|
||||
if (shouldAnimate && !_animating) {
|
||||
_btnScaleController.repeat(reverse: true);
|
||||
_animating = true;
|
||||
} else if (!shouldAnimate && _animating) {
|
||||
_btnScaleController.stop();
|
||||
_btnScaleController.value = 0.5;
|
||||
_animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final book = widget.book;
|
||||
final history = widget.history;
|
||||
final isColorOne = widget.itemIndex.isEven;
|
||||
|
||||
final title = history?.title ?? book.title ?? 'Judul Buku';
|
||||
final summary = book.summary ?? '-';
|
||||
final category = history?.category ?? book.category ?? '-';
|
||||
final totalPages = history?.totalPages ?? book.totalPages ?? 0;
|
||||
final isFinished = history?.isFinished == true;
|
||||
final isClaimed = (history?.claimedRewards?.isNotEmpty ?? false);
|
||||
|
||||
final rewardImages = book.rewards ?? [];
|
||||
final rewardsClaimed = history?.claimedRewards ?? [];
|
||||
final claimedThumbs = rewardsClaimed
|
||||
.where((i) => i >= 0 && i < rewardImages.length)
|
||||
.map((i) => rewardImages[i])
|
||||
.take(3)
|
||||
.toList();
|
||||
|
||||
final shouldAnimateButton = !isClaimed && isFinished;
|
||||
_syncButtonAnimation(shouldAnimateButton);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: isColorOne
|
||||
? const [Color(0xFFFFDAA0), Color(0xFFFFFFFF)]
|
||||
: const [Color(0xFFAAFFC0), Color(0xFFFFFFFF)],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.6), width: 1.1),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BookCover(url: history?.bookCover ?? book.bookCover),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Category : $category',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Total Halaman : $totalPages',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black87,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
summary,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black87,
|
||||
height: 1.25,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (claimedThumbs.isNotEmpty)
|
||||
Row(
|
||||
children: List.generate(claimedThumbs.length, (i) {
|
||||
final isLast = i == claimedThumbs.length - 1;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: isLast ? 0 : 10),
|
||||
child: _Thumb(
|
||||
url: claimedThumbs[i],
|
||||
onTap: () {
|
||||
mainController.showRewardPhotoViewer(
|
||||
imageUrls: claimedThumbs,
|
||||
initialIndex: i,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (shouldAnimateButton) ...[
|
||||
const SizedBox(height: 3),
|
||||
ScaleTransition(
|
||||
scale: _btnScale,
|
||||
child: RewardClaimButton(onPressed: widget.onClaim),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
isClaimed ? 'Hadiah sudah diklaim' : 'Hadiah belum diklaim',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Image.asset(
|
||||
isClaimed
|
||||
? 'assets/images/reward-claimed.png'
|
||||
: 'assets/images/reward-not-claimed.png',
|
||||
width: 16,
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookCover extends StatelessWidget {
|
||||
const _BookCover({required this.url});
|
||||
|
||||
final String? url;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 110,
|
||||
height: 150,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.6), width: 1.0),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: url == null
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Cover Buku',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const Center(
|
||||
child:
|
||||
Icon(Icons.broken_image_outlined, color: Colors.black54),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumb extends StatelessWidget {
|
||||
const _Thumb({required this.url, required this.onTap});
|
||||
|
||||
final String? url;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
Border.all(color: Colors.black.withOpacity(0.6), width: 1.0),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: url == null
|
||||
? const Icon(Icons.image_outlined, color: Colors.black54)
|
||||
: Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
|
||||
import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/components/reward_claim_button.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class FlashcardReward extends StatelessWidget {
|
||||
const FlashcardReward({super.key, required this.controller});
|
||||
|
||||
final RewardHomeController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(() {
|
||||
if (controller.isLoading.value) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final cards = controller.flashcards;
|
||||
if (cards.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 18),
|
||||
children: const [
|
||||
SizedBox(height: 80),
|
||||
Center(
|
||||
child: Text(
|
||||
'Belum ada hadiah yang tersedia.',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 18),
|
||||
itemCount: cards.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final flashcard = cards[index];
|
||||
final history = controller.historyFor(flashcard.flashcardId);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
controller.goToFlashcardRead(flashcard);
|
||||
},
|
||||
child: _RewardCard(
|
||||
itemIndex: index,
|
||||
flashcard: flashcard,
|
||||
history: history,
|
||||
onClaim: () => controller.openRewardClaim(flashcard),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _RewardCard extends StatefulWidget {
|
||||
const _RewardCard({
|
||||
required this.itemIndex,
|
||||
required this.flashcard,
|
||||
required this.history,
|
||||
required this.onClaim,
|
||||
});
|
||||
|
||||
final int itemIndex;
|
||||
final FlashcardEntity flashcard;
|
||||
final FlashcardHistoryEntity? history;
|
||||
final VoidCallback onClaim;
|
||||
|
||||
@override
|
||||
State<_RewardCard> createState() => _RewardCardState();
|
||||
}
|
||||
|
||||
class _RewardCardState extends State<_RewardCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final MainController mainController = Get.find<MainController>();
|
||||
|
||||
late final AnimationController _btnScaleController;
|
||||
late final Animation<double> _btnScale;
|
||||
bool _animating = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_btnScaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 950),
|
||||
);
|
||||
_btnScale = Tween<double>(begin: 0.96, end: 1.04).animate(
|
||||
CurvedAnimation(parent: _btnScaleController, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_btnScaleController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _syncButtonAnimation(bool shouldAnimate) {
|
||||
if (shouldAnimate && !_animating) {
|
||||
_btnScaleController.repeat(reverse: true);
|
||||
_animating = true;
|
||||
} else if (!shouldAnimate && _animating) {
|
||||
_btnScaleController.stop();
|
||||
_btnScaleController.value = 0.5; // center at scale ~1.0
|
||||
_animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final flashcard = widget.flashcard;
|
||||
final history = widget.history;
|
||||
final isColorOne = widget.itemIndex.isEven;
|
||||
|
||||
final title = flashcard.title ?? 'Judul Flashcard';
|
||||
final summary = flashcard.summary ?? '';
|
||||
final category = flashcard.category ?? '-';
|
||||
final totalCards = flashcard.totalCards ?? 0;
|
||||
final totalQuiz = history?.totalQuiz ?? flashcard.totalQuiz ?? 0;
|
||||
final completedQuiz = history?.completedQuiz ?? 0;
|
||||
final claimed = (history?.claimedRewards?.isNotEmpty ?? false);
|
||||
final rewards = flashcard.rewards ?? [];
|
||||
final rewardsClaimed = history?.claimedRewards ?? [];
|
||||
final claimedThumbs = rewardsClaimed
|
||||
.where((i) => i >= 0 && i < rewards.length)
|
||||
.map((i) => rewards[i])
|
||||
.take(3)
|
||||
.toList();
|
||||
final isFinished = history?.isFinished == true;
|
||||
|
||||
final shouldAnimateButton = !claimed && isFinished;
|
||||
_syncButtonAnimation(shouldAnimateButton);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: isColorOne
|
||||
? const [Color(0xFFFFE588), Color(0xFFFFFFFF)]
|
||||
: const [Color(0xFF4EFF4F), Color(0xFFFFFFFF)],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.6), width: 1.1),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
summary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.black87,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_Chip(label: '$totalCards kartu'),
|
||||
_Chip(label: '$completedQuiz/$totalQuiz'),
|
||||
_Chip(label: category),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (claimedThumbs.isNotEmpty)
|
||||
Row(
|
||||
children: List.generate(claimedThumbs.length, (i) {
|
||||
final isLast = i == claimedThumbs.length - 1;
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: isLast ? 0 : 10),
|
||||
child: _Thumb(
|
||||
url: claimedThumbs[i],
|
||||
onTap: () {
|
||||
mainController.showRewardPhotoViewer(
|
||||
imageUrls: claimedThumbs,
|
||||
initialIndex: i,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (shouldAnimateButton) ...[
|
||||
const SizedBox(height: 3),
|
||||
ScaleTransition(
|
||||
scale: _btnScale,
|
||||
child: RewardClaimButton(onPressed: widget.onClaim),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
claimed ? 'Hadiah sudah diklaim' : 'Hadiah belum diklaim',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Image.asset(
|
||||
claimed
|
||||
? 'assets/images/reward-claimed.png'
|
||||
: 'assets/images/reward-not-claimed.png',
|
||||
width: 16,
|
||||
height: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
const _Chip({required this.label});
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.black.withOpacity(0.7), width: 1.1),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Thumb extends StatelessWidget {
|
||||
const _Thumb({required this.url, required this.onTap});
|
||||
final String? url;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border:
|
||||
Border.all(color: Colors.black.withOpacity(0.6), width: 1.0),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: url == null
|
||||
? const Icon(Icons.image_outlined, color: Colors.black54)
|
||||
: Image.network(
|
||||
url!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.black54,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class RewardClaimButton extends StatelessWidget {
|
||||
const RewardClaimButton({super.key, required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 41,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFFFF1F1F),
|
||||
width: 1.6,
|
||||
),
|
||||
foregroundColor: Colors.black,
|
||||
backgroundColor: const Color(0xFFFFE588),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: const Text(
|
||||
'Dapatkan Reward',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RewardTypeSwitcher extends StatelessWidget {
|
||||
const RewardTypeSwitcher({super.key, required this.controller});
|
||||
|
||||
final RewardHomeController 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import 'package:epic_story_app/data/modules/book_module.dart';
|
||||
import 'package:epic_story_app/data/modules/children_module.dart';
|
||||
import 'package:epic_story_app/data/modules/collection_module.dart';
|
||||
import 'package:epic_story_app/data/modules/flashcard_module.dart';
|
||||
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'reward_home_controller.dart';
|
||||
|
||||
class RewardHomeBinding extends Bindings {
|
||||
@override
|
||||
void dependencies() {
|
||||
ChildrenModule();
|
||||
CollectionModule();
|
||||
FlashCardModule();
|
||||
BookModule();
|
||||
Get.lazyPut<RewardHomeController>(
|
||||
() => RewardHomeController(
|
||||
collectionUsecase: Get.find<CollectionUsecase>(),
|
||||
childrenUsecase: Get.find<ChildrenUsecase>(),
|
||||
flashCardUsecase: Get.find<FlashCardUsecase>(),
|
||||
bookUsecase: Get.find<BookUsecase>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:epic_story_app/core/routes/epic_routes.dart';
|
||||
import 'package:epic_story_app/core/utils/epic_log.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
|
||||
import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
|
||||
import 'package:epic_story_app/domain/usecases/book_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
|
||||
import 'package:epic_story_app/domain/usecases/flashcard_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:get/get.dart';
|
||||
|
||||
class RewardHomeController extends GetxController {
|
||||
final CollectionUsecase collectionUsecase;
|
||||
final ChildrenUsecase childrenUsecase;
|
||||
final FlashCardUsecase flashCardUsecase;
|
||||
final BookUsecase bookUsecase;
|
||||
|
||||
RewardHomeController({
|
||||
required this.collectionUsecase,
|
||||
required this.childrenUsecase,
|
||||
required this.flashCardUsecase,
|
||||
required this.bookUsecase,
|
||||
});
|
||||
|
||||
final navController = Get.find<EpicNavigationController>();
|
||||
final mainController = Get.find<MainController>();
|
||||
|
||||
var isBook = true.obs;
|
||||
var isLoading = false.obs;
|
||||
|
||||
final flashcards = <FlashcardEntity>[].obs;
|
||||
final historyFlashcards = <FlashcardHistoryEntity>[].obs;
|
||||
|
||||
final books = <BookEntity>[].obs;
|
||||
final historyBooks = <BookHistoryEntity>[].obs;
|
||||
|
||||
List<BookEntity> _allBooks = [];
|
||||
List<FlashcardEntity> _allFlashcards = [];
|
||||
|
||||
StreamSubscription<List<BookHistoryEntity>>? _bookHistorySub;
|
||||
StreamSubscription<List<FlashcardHistoryEntity>>? _flashcardHistorySub;
|
||||
|
||||
@override
|
||||
void onInit() async {
|
||||
super.onInit();
|
||||
await _fetchBaseData();
|
||||
_subscribeToHistories();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_bookHistorySub?.cancel();
|
||||
_flashcardHistorySub?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> _fetchBaseData() async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
final fetchedBooks = await bookUsecase.getBooks();
|
||||
final fetchedFlashcards = await flashCardUsecase.getFlashcards();
|
||||
_allBooks = fetchedBooks.books;
|
||||
_allFlashcards = fetchedFlashcards;
|
||||
} catch (ex, s) {
|
||||
EpicLog.exception(ex, s, 'Error fetching base data');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribeToHistories() {
|
||||
_bookHistorySub = childrenUsecase.streamBookHistories().listen(
|
||||
(histories) {
|
||||
EpicLog.debug('streamBookHistories update: ${histories.length}');
|
||||
final finished = histories.where((e) => e.isFinished == true);
|
||||
historyBooks.assignAll(finished);
|
||||
books.assignAll(
|
||||
_allBooks.where(
|
||||
(book) => finished.any((h) => h.bookId == book.bookId),
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (ex, s) {
|
||||
EpicLog.exception(ex, s, 'streamBookHistories error');
|
||||
},
|
||||
);
|
||||
|
||||
_flashcardHistorySub = childrenUsecase.streamFlashcardHistories().listen(
|
||||
(histories) {
|
||||
EpicLog.debug('streamFlashcardHistories update: ${histories.length}');
|
||||
final finished = histories.where((e) => e.isFinished == true);
|
||||
historyFlashcards.assignAll(finished);
|
||||
flashcards.assignAll(
|
||||
_allFlashcards.where(
|
||||
(fc) => finished.any((h) => h.flashcardId == fc.flashcardId),
|
||||
),
|
||||
);
|
||||
sortedHistories();
|
||||
},
|
||||
onError: (ex, s) {
|
||||
EpicLog.exception(ex, s, 'streamFlashcardHistories error');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void sortedHistories() {
|
||||
historyFlashcards.sort((a, b) =>
|
||||
b.lastReadAt?.compareTo(
|
||||
a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ??
|
||||
0);
|
||||
}
|
||||
|
||||
FlashcardHistoryEntity? historyFor(String? flashcardId) {
|
||||
if (flashcardId == null) return null;
|
||||
return historyFlashcards.firstWhereOrNull(
|
||||
(h) => h.flashcardId == flashcardId,
|
||||
);
|
||||
}
|
||||
|
||||
BookHistoryEntity? historyForBook(String? bookId) {
|
||||
if (bookId == null) return null;
|
||||
return historyBooks.firstWhereOrNull(
|
||||
(h) => h.bookId == bookId,
|
||||
);
|
||||
}
|
||||
|
||||
void openRewardClaim(FlashcardEntity flashcard) async {
|
||||
bool result = await Get.toNamed(
|
||||
EpicRoutes.flashcardReward,
|
||||
arguments: flashcard,
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// history update will arrive automatically via stream
|
||||
}
|
||||
}
|
||||
|
||||
void goToFlashcardRead(FlashcardEntity flashcard) async {
|
||||
await Get.toNamed(
|
||||
EpicRoutes.flashCardRead,
|
||||
arguments: flashcard,
|
||||
);
|
||||
}
|
||||
|
||||
void goToBookRead(BookEntity book) async {
|
||||
await Get.toNamed(
|
||||
EpicRoutes.bookRead,
|
||||
arguments: book,
|
||||
);
|
||||
// history update will arrive automatically via stream
|
||||
}
|
||||
|
||||
void openBookRewardClaim(BookEntity book) async {
|
||||
await Get.toNamed(
|
||||
EpicRoutes.bookReward,
|
||||
arguments: book,
|
||||
);
|
||||
// history update will arrive automatically via stream
|
||||
}
|
||||
|
||||
Future<void> refreshActiveTab() async {
|
||||
// base data (books/flashcards) can be re-fetched manually if needed;
|
||||
// history is always up-to-date via stream
|
||||
await _fetchBaseData();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import 'package:epic_story_app/feature/rewards/reward_home/components/book_reward.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/components/flashcard_reward.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/components/reward_type_switcher.dart';
|
||||
import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class RewardHomePage extends StatelessWidget {
|
||||
const RewardHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = Get.find<RewardHomeController>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Color(0xFFE0CEFF),
|
||||
// floatingActionButton: ElevatedButton(
|
||||
// onPressed: () {
|
||||
// controller.mainController.testFetchFlashcardHistories(
|
||||
// refreshLocal: false,
|
||||
// );
|
||||
// },
|
||||
// child: Text("Rev"),
|
||||
// ),
|
||||
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: RewardTypeSwitcher(controller: controller),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 156),
|
||||
child: RefreshIndicator(
|
||||
onRefresh: controller.refreshActiveTab,
|
||||
child: controller.isBook.value
|
||||
? BookReward(
|
||||
controller: controller,
|
||||
)
|
||||
: FlashcardReward(
|
||||
controller: controller,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue