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