feat: add home, profiles, and rewards features

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Achmad Baihaqi 2026-06-28 23:06:37 +07:00
parent edcee652d2
commit 17ca14c40c
18 changed files with 2177 additions and 0 deletions

View File

@ -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),
],
);
}
}

View File

@ -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(),
),
),
],
),
),
);
});
}
}

View File

@ -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,
),
),
],
),
),
);
}
}

View File

@ -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,
),
),
),
),
);
}
}

View File

@ -0,0 +1,5 @@
enum HomeState {
home,
category,
content
}

View File

@ -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>(),
),
);
}
}

View File

@ -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;
}
}
}

View File

@ -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(),
),
),
);
}
}

View File

@ -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>(),
));
}
}

View File

@ -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);
},
);
}
}

View File

@ -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;
}
}

View File

@ -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,
),
),
),
),
),
);
}
}

View File

@ -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,
),
),
),
),
),
);
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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),
),
),
),
);
}
}

View File

@ -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>(),
),
);
}
}

View File

@ -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();
}
}

View File

@ -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,
),
),
),
],
);
}),
);
}
}