From 17ca14c40cbf4c582f1bb499d721f439b26d9838 Mon Sep 17 00:00:00 2001 From: Achmad Baihaqi Date: Sun, 28 Jun 2026 23:06:37 +0700 Subject: [PATCH] feat: add home, profiles, and rewards features Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../components/category_state_widget.dart | 334 +++++++++++++++++ .../home/components/home_content_state.dart | 65 ++++ .../home/components/home_state_widget.dart | 171 +++++++++ lib/feature/home/components/home_type.dart | 41 +++ lib/feature/home/enums/home_state.dart | 5 + lib/feature/home/home_binding.dart | 29 ++ lib/feature/home/home_controller.dart | 125 +++++++ lib/feature/home/home_page.dart | 28 ++ .../presentation/profile_binding.dart | 15 + .../presentation/profile_controller.dart | 40 ++ .../profiles/presentation/profile_page.dart | 220 +++++++++++ .../reward_home/components/book_reward.dart | 347 ++++++++++++++++++ .../components/flashcard_reward.dart | 310 ++++++++++++++++ .../components/reward_claim_button.dart | 37 ++ .../components/reward_type_switcher.dart | 139 +++++++ .../reward_home/reward_home_binding.dart | 29 ++ .../reward_home/reward_home_controller.dart | 171 +++++++++ .../rewards/reward_home/reward_home_page.dart | 71 ++++ 18 files changed, 2177 insertions(+) create mode 100644 lib/feature/home/components/category_state_widget.dart create mode 100644 lib/feature/home/components/home_content_state.dart create mode 100644 lib/feature/home/components/home_state_widget.dart create mode 100644 lib/feature/home/components/home_type.dart create mode 100644 lib/feature/home/enums/home_state.dart create mode 100644 lib/feature/home/home_binding.dart create mode 100644 lib/feature/home/home_controller.dart create mode 100644 lib/feature/home/home_page.dart create mode 100644 lib/feature/profiles/presentation/profile_binding.dart create mode 100644 lib/feature/profiles/presentation/profile_controller.dart create mode 100644 lib/feature/profiles/presentation/profile_page.dart create mode 100644 lib/feature/rewards/reward_home/components/book_reward.dart create mode 100644 lib/feature/rewards/reward_home/components/flashcard_reward.dart create mode 100644 lib/feature/rewards/reward_home/components/reward_claim_button.dart create mode 100644 lib/feature/rewards/reward_home/components/reward_type_switcher.dart create mode 100644 lib/feature/rewards/reward_home/reward_home_binding.dart create mode 100644 lib/feature/rewards/reward_home/reward_home_controller.dart create mode 100644 lib/feature/rewards/reward_home/reward_home_page.dart diff --git a/lib/feature/home/components/category_state_widget.dart b/lib/feature/home/components/category_state_widget.dart new file mode 100644 index 0000000..153011a --- /dev/null +++ b/lib/feature/home/components/category_state_widget.dart @@ -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 _displayedCategories(List 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), + ], + ); + } +} diff --git a/lib/feature/home/components/home_content_state.dart b/lib/feature/home/components/home_content_state.dart new file mode 100644 index 0000000..c44ee6a --- /dev/null +++ b/lib/feature/home/components/home_content_state.dart @@ -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(), + ), + ), + ], + ), + ), + ); + }); + } +} diff --git a/lib/feature/home/components/home_state_widget.dart b/lib/feature/home/components/home_state_widget.dart new file mode 100644 index 0000000..4b9f5b7 --- /dev/null +++ b/lib/feature/home/components/home_state_widget.dart @@ -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, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/home/components/home_type.dart b/lib/feature/home/components/home_type.dart new file mode 100644 index 0000000..eb05ef7 --- /dev/null +++ b/lib/feature/home/components/home_type.dart @@ -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, + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/home/enums/home_state.dart b/lib/feature/home/enums/home_state.dart new file mode 100644 index 0000000..a445d02 --- /dev/null +++ b/lib/feature/home/enums/home_state.dart @@ -0,0 +1,5 @@ +enum HomeState { + home, + category, + content +} \ No newline at end of file diff --git a/lib/feature/home/home_binding.dart b/lib/feature/home/home_binding.dart new file mode 100644 index 0000000..51d5507 --- /dev/null +++ b/lib/feature/home/home_binding.dart @@ -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( + collectionUsecase: Get.find(), + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/home/home_controller.dart b/lib/feature/home/home_controller.dart new file mode 100644 index 0000000..a1665bd --- /dev/null +++ b/lib/feature/home/home_controller.dart @@ -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(); + final navController = Get.find(); + final flashcardController = Get.find(); + final bookController = Get.find(); + + final categories = [].obs; + Worker? _bookModeWorker; + int _switchTicket = 0; + + @override + void onInit() { + super.onInit(); + + _bookModeWorker = ever(navController.isBook, (value) { + _syncContentForMode(value); + }); + + _syncContentForMode(navController.isBook.value); + } + + @override + void onClose() { + _bookModeWorker?.dispose(); + super.onClose(); + } + + Future _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 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; + } + } +} diff --git a/lib/feature/home/home_page.dart b/lib/feature/home/home_page.dart new file mode 100644 index 0000000..c6e6a84 --- /dev/null +++ b/lib/feature/home/home_page.dart @@ -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(); + + 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(), + ), + ), + ); + } +} diff --git a/lib/feature/profiles/presentation/profile_binding.dart b/lib/feature/profiles/presentation/profile_binding.dart new file mode 100644 index 0000000..b8048ce --- /dev/null +++ b/lib/feature/profiles/presentation/profile_binding.dart @@ -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(), + )); + } +} \ No newline at end of file diff --git a/lib/feature/profiles/presentation/profile_controller.dart b/lib/feature/profiles/presentation/profile_controller.dart new file mode 100644 index 0000000..5925a78 --- /dev/null +++ b/lib/feature/profiles/presentation/profile_controller.dart @@ -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(); + + var childrenData = Rx(null); + + @override + void onInit() async { + super.onInit(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + childrenData.value = await childrenUsecase.getCurrentChildren(); + }); + } + + Future 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); + }, + ); + } +} diff --git a/lib/feature/profiles/presentation/profile_page.dart b/lib/feature/profiles/presentation/profile_page.dart new file mode 100644 index 0000000..e7ff46a --- /dev/null +++ b/lib/feature/profiles/presentation/profile_page.dart @@ -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; + } +} diff --git a/lib/feature/rewards/reward_home/components/book_reward.dart b/lib/feature/rewards/reward_home/components/book_reward.dart new file mode 100644 index 0000000..b6307f3 --- /dev/null +++ b/lib/feature/rewards/reward_home/components/book_reward.dart @@ -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(); + + late final AnimationController _btnScaleController; + late final Animation _btnScale; + bool _animating = false; + + @override + void initState() { + super.initState(); + _btnScaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + _btnScale = Tween(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, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/flashcard_reward.dart b/lib/feature/rewards/reward_home/components/flashcard_reward.dart new file mode 100644 index 0000000..5459db2 --- /dev/null +++ b/lib/feature/rewards/reward_home/components/flashcard_reward.dart @@ -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(); + + late final AnimationController _btnScaleController; + late final Animation _btnScale; + bool _animating = false; + + @override + void initState() { + super.initState(); + _btnScaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + _btnScale = Tween(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, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/reward_claim_button.dart b/lib/feature/rewards/reward_home/components/reward_claim_button.dart new file mode 100644 index 0000000..5ee4241 --- /dev/null +++ b/lib/feature/rewards/reward_home/components/reward_claim_button.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/reward_type_switcher.dart b/lib/feature/rewards/reward_home/components/reward_type_switcher.dart new file mode 100644 index 0000000..346d06a --- /dev/null +++ b/lib/feature/rewards/reward_home/components/reward_type_switcher.dart @@ -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), + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/reward_home_binding.dart b/lib/feature/rewards/reward_home/reward_home_binding.dart new file mode 100644 index 0000000..7b6bd2f --- /dev/null +++ b/lib/feature/rewards/reward_home/reward_home_binding.dart @@ -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( + collectionUsecase: Get.find(), + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/reward_home_controller.dart b/lib/feature/rewards/reward_home/reward_home_controller.dart new file mode 100644 index 0000000..53507fc --- /dev/null +++ b/lib/feature/rewards/reward_home/reward_home_controller.dart @@ -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(); + final mainController = Get.find(); + + var isBook = true.obs; + var isLoading = false.obs; + + final flashcards = [].obs; + final historyFlashcards = [].obs; + + final books = [].obs; + final historyBooks = [].obs; + + List _allBooks = []; + List _allFlashcards = []; + + StreamSubscription>? _bookHistorySub; + StreamSubscription>? _flashcardHistorySub; + + @override + void onInit() async { + super.onInit(); + await _fetchBaseData(); + _subscribeToHistories(); + } + + @override + void onClose() { + _bookHistorySub?.cancel(); + _flashcardHistorySub?.cancel(); + super.onClose(); + } + + Future _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 refreshActiveTab() async { + // base data (books/flashcards) can be re-fetched manually if needed; + // history is always up-to-date via stream + await _fetchBaseData(); + } +} diff --git a/lib/feature/rewards/reward_home/reward_home_page.dart b/lib/feature/rewards/reward_home/reward_home_page.dart new file mode 100644 index 0000000..2db533c --- /dev/null +++ b/lib/feature/rewards/reward_home/reward_home_page.dart @@ -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(); + + 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, + ), + ), + ), + ], + ); + }), + ); + } +}