feat: add auth, utils, and navigation features

- Auth screens: choose class, date of birth, gender
- Navigation and splash screen
- Main controller

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Achmad Baihaqi 2026-06-28 23:06:29 +07:00
parent 037711ede7
commit edcee652d2
27 changed files with 1966 additions and 0 deletions

View File

@ -0,0 +1,17 @@
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/auth/choose_class/choose_class_controller.dart';
import 'package:get/get.dart';
class ChooseClassBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
Get.lazyPut(
() => ChooseClassController(
childrenUsecase: Get.find<ChildrenUsecase>(),
),
);
}
}

View File

@ -0,0 +1,52 @@
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/domain/entities/children_entity.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:get/get.dart';
class ChooseClassController extends GetxController {
var selectedClass = 0.obs;
late ChildrenEntity argument;
final ChildrenUsecase childrenUsecase;
ChooseClassController({
required this.childrenUsecase,
});
@override
void onInit() {
super.onInit();
argument = Get.arguments;
ever(selectedClass, (classValue) {
EpicLog.info("Selected class changed: $classValue");
});
}
void save() async {
if (selectedClass.value == 0) {
Get.snackbar('Error', 'Pilih kelas terlebih dahulu');
return;
}
var currentChildren = await childrenUsecase.getCurrentChildren();
if (currentChildren == null) return;
var newChildren = currentChildren.copyWith(
gender: argument.gender,
dateBirth: argument.dateBirth,
gradeClass: selectedClass.value,
updatedAt: DateTime.now(),
);
EpicLog.info("gender : ${newChildren.gender}");
EpicLog.info("date birth : ${newChildren.dateBirth}");
EpicLog.info("grade class : ${newChildren.gradeClass}");
await childrenUsecase.addOrUpdateChildrenLocal(children: newChildren);
await childrenUsecase.addOrUpdateChildrenRemote(children: newChildren);
Get.toNamed(EpicRoutes.navigation);
}
void selectClass(int code) {
selectedClass.value = code;
}
}

View File

@ -0,0 +1,137 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/core/constants/ui/app_size.dart';
import 'package:epic_story_app/core/widgets/default_ui/epic_button.dart';
import 'package:epic_story_app/feature/auth/choose_class/choose_class_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class ChooseClassPage extends StatelessWidget {
const ChooseClassPage({super.key});
@override
Widget build(BuildContext context) {
final ChooseClassController controller = Get.find();
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: SizedBox(
height: EpicSize.screenHeight * 0.9,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
AppSize.spacing7XLHeight(),
Text(
"Pilih Kelas Kamu",
style: EpicSize.headlineLarge,
),
AppSize.spacing2XLHeight(),
Obx(() => EpicButton(
text: "Kelas 1",
width: EpicSize.screenWidth * 0.75,
height: 50,
backgroundColor: controller.selectedClass.value == 1
? Colors.red
: Colors.red.withOpacity(0.3),
onPressed: () {
controller.selectClass(1);
},
)),
AppSize.spacingMHeight(),
Obx(() => EpicButton(
text: "Kelas 2",
width: EpicSize.screenWidth * 0.75,
height: 50,
backgroundColor: controller.selectedClass.value == 2
? Colors.teal
: Colors.teal.withOpacity(0.3),
onPressed: () {
controller.selectClass(2);
},
)),
AppSize.spacingMHeight(),
Obx(() => EpicButton(
text: "Kelas 3",
width: EpicSize.screenWidth * 0.75,
height: 50,
backgroundColor: controller.selectedClass.value == 3
? Colors.orange
: Colors.orange.withOpacity(0.3),
onPressed: () {
controller.selectClass(3);
},
)),
AppSize.spacingMHeight(),
Obx(() => EpicButton(
text: "Kelas 4",
width: EpicSize.screenWidth * 0.75,
height: 50,
backgroundColor: controller.selectedClass.value == 4
? Colors.green
: Colors.green.withOpacity(0.3),
onPressed: () {
controller.selectClass(4);
},
)),
AppSize.spacingMHeight(),
Obx(() => EpicButton(
text: "Kelas 5",
width: EpicSize.screenWidth * 0.75,
height: 50,
backgroundColor: controller.selectedClass.value == 5
? Colors.blueAccent
: Colors.blueAccent.withOpacity(0.3),
onPressed: () {
controller.selectClass(5);
},
)),
AppSize.spacingMHeight(),
Obx(() => EpicButton(
text: "Kelas 6",
width: EpicSize.screenWidth * 0.75,
height: 50,
backgroundColor: controller.selectedClass.value == 6
? Colors.purple
: Colors.purple.withOpacity(0.3),
onPressed: () {
controller.selectClass(6);
},
)),
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Align(
alignment: Alignment.centerLeft,
child: EpicButton(
text: "Previous",
onPressed: () {
Get.back();
},
),
),
Align(
alignment: Alignment.centerLeft,
child: EpicButton(
text: "Save",
onPressed: () {
controller.save();
},
),
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:epic_story_app/feature/auth/choose_date_birth/choose_date_birth_controller.dart';
import 'package:get/get.dart';
class ChooseDateBirthBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut(() => ChooseDateBirthController());
}
}

View File

@ -0,0 +1,28 @@
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/domain/entities/children_entity.dart';
import 'package:get/get.dart';
class ChooseDateBirthController extends GetxController {
var selectedDate = DateTime.now().obs;
late ChildrenEntity argument;
@override
void onInit() {
super.onInit();
argument = Get.arguments as ChildrenEntity;
ever(selectedDate, (date) {
EpicLog.info("Selected date changed: $date");
});
}
void goToGradeClass() {
var children = argument.copyWith(
dateBirth: selectedDate.value,
);
Get.toNamed(
EpicRoutes.chooseClass,
arguments: children,
);
}
}

View File

@ -0,0 +1,88 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/core/constants/ui/app_size.dart';
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/widgets/default_ui/epic_button.dart';
import 'package:epic_story_app/core/widgets/default_ui/epic_date_picker.dart';
import 'package:epic_story_app/feature/auth/choose_date_birth/choose_date_birth_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class ChooseDateBirthPage extends StatelessWidget {
const ChooseDateBirthPage({super.key});
@override
Widget build(BuildContext context) {
final ChooseDateBirthController controller = Get.find();
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: SizedBox(
height: EpicSize.screenHeight * 0.9,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
children: [
AppSize.spacing7XLHeight(),
Text(
"Masukan tangal lahir kamu",
style: EpicSize.headlineLarge,
),
AppSize.spacing2XLHeight(),
Obx(() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: EpicDatePicker(
label: 'Tanggal Lahir',
hint: 'Pilih tanggal lahir Kamu',
initialDate: DateTime.now(),
firstDate: DateTime(2003),
lastDate: controller.selectedDate.value,
onDateSelected: (date) {
controller.selectedDate.value =
date ?? DateTime.now();
},
dateFormat: 'dd MMM yyyy',
errorText: null,
clearable: true,
),
);
})
],
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Align(
alignment: Alignment.centerLeft,
child: EpicButton(
text: "Previous",
onPressed: () {
Get.back();
},
),
),
Align(
alignment: Alignment.centerLeft,
child: EpicButton(
text: "Next",
onPressed: () {
controller.goToGradeClass();
},
),
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,9 @@
import 'package:epic_story_app/feature/auth/choose_gender/choose_gender_controller.dart';
import 'package:get/get.dart';
class ChooseGenderBinding extends Bindings{
@override
void dependencies() {
Get.lazyPut(() => ChooseGenderController());
}
}

View File

@ -0,0 +1,26 @@
import 'package:epic_story_app/core/routes/epic_routes.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
import 'package:epic_story_app/domain/entities/children_entity.dart';
import 'package:get/get.dart';
class ChooseGenderController extends GetxController{
var selectedGender = ''.obs;
void goToDateBirth() {
if (selectedGender.isEmpty) {
EpicSnackBar.showWarningSnackBar("Warning", "Gender belum dipilih!");
return;
}
var children = ChildrenEntity(gender: selectedGender.value);
EpicLog.info("Navigating to DateBirthPage with: $children");
Get.toNamed(
EpicRoutes.chooseDateBirth,
arguments: children,
);
}
}

View File

@ -0,0 +1,62 @@
import 'package:epic_story_app/core/constants/size/epic_size.dart';
import 'package:epic_story_app/core/constants/ui/app_size.dart';
import 'package:epic_story_app/core/widgets/default_ui/epic_button.dart';
import 'package:epic_story_app/feature/auth/choose_gender/choose_gender_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class ChooseGenderPage extends StatelessWidget {
const ChooseGenderPage({super.key});
@override
Widget build(BuildContext context) {
final ChooseGenderController controller = Get.find();
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: Column(
children: [
Text(
"Pilih Jenis Kelamin",
style: EpicSize.headlineLarge,
),
AppSize.spacing2XLHeight(),
EpicButton(
text: "Laki-Laki",
backgroundColor: Colors.lightBlue,
width: EpicSize.screenWidth * 0.8,
height: 100,
prefixIcon: const Icon(
Icons.male,
color: Colors.white,
),
onPressed: () {
controller.selectedGender.value = 'Laki-Laki';
controller.goToDateBirth();
},
),
AppSize.spacingLHeight(),
EpicButton(
text: "Perempuan",
backgroundColor: Colors.pink,
fontWeight: FontWeight.bold,
height: 100,
width: EpicSize.screenWidth * 0.8,
prefixIcon: const Icon(
Icons.female,
color: Colors.white,
),
onPressed: () {
controller.selectedGender.value = 'Perempuan';
controller.goToDateBirth();
},
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,326 @@
import 'dart:typed_data';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:epic_story_app/core/services/firebase_service.dart';
import 'package:epic_story_app/core/services/google_auth_service.dart';
import 'package:epic_story_app/core/services/isar_service.dart';
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:epic_story_app/core/utils/epic_snackbar.dart';
import 'package:epic_story_app/core/widgets/dialogs/loading_dialog.dart';
import 'package:epic_story_app/core/widgets/dialogs/reward_photo_viewer_dialog.dart';
import 'package:epic_story_app/domain/entities/children_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/flashcard_usecase.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:gal/gal.dart';
import 'package:get/get.dart';
import 'package:google_sign_in/google_sign_in.dart';
class MainController extends GetxController {
final FirebaseService firebaseService = Get.find<FirebaseService>();
final authService = Get.find<GoogleAuthService>();
final isarService = Get.find<IsarService>();
final RxBool _isLoadingPage = false.obs;
int _loadingDialogToken = 0;
bool get isLoadingPage => _isLoadingPage.value;
var childrenData = Rxn<ChildrenEntity>();
final ChildrenUsecase childrenUsecase;
final FlashCardUsecase flashCardUsecase;
final BookUsecase bookUsecase;
MainController({
required this.childrenUsecase,
required this.flashCardUsecase,
required this.bookUsecase,
});
@override
void onInit() {
super.onInit();
// Safety net for hot-restart/resume where a modal barrier can remain visible.
WidgetsBinding.instance.addPostFrameCallback((_) {
hideLoadingPage(force: true);
});
}
Future<bool> isConnectedToInternet() async {
try {
var connectivityResult = await Connectivity().checkConnectivity();
// Check if device has network connectivity
if (connectivityResult.contains(ConnectivityResult.mobile) ||
connectivityResult.contains(ConnectivityResult.wifi)) {
// Additional check: try to reach a reliable server
try {
return true;
// final result = await InternetAddress.lookup('google.com');
// if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) {
// EpicLog.debug('✅ Internet connection: Available');
// return true;
// }
} catch (e) {
EpicLog.debug('❌ Internet connection: DNS lookup failed - $e');
return false;
}
} else {
EpicLog.debug('❌ Internet connection: No network connectivity');
return false;
}
} catch (e) {
EpicLog.debug('❌ Internet connection: Error checking connectivity - $e');
return false;
}
}
void showLoadingPage() {
if (!_isLoadingPage.value && !(Get.isDialogOpen ?? false)) {
_isLoadingPage.value = true;
final token = ++_loadingDialogToken;
try {
Get.dialog(
const UnconstrainedBox(
child: Material(
color: Colors.transparent,
child: LoadingDialog(),
),
),
barrierDismissible: false, // Prevent dismissing by tapping outside
barrierColor: Colors.black.withOpacity(0.5),
useSafeArea: true,
);
Future.delayed(
const Duration(seconds: 10),
() {
if (_isLoadingPage.value && token == _loadingDialogToken) {
hideLoadingPage();
}
},
);
} catch (e) {
_isLoadingPage.value = false;
EpicLog.debug('Error showing loading dialog: $e');
}
}
}
void hideLoadingPage({bool force = false}) {
if (!_isLoadingPage.value && !force) return;
_isLoadingPage.value = false;
if (Get.isDialogOpen ?? false) {
try {
if (Get.isSnackbarOpen) {
Get.closeCurrentSnackbar();
}
Get.back();
} catch (e) {
EpicLog.debug('Error closing loading dialog: $e');
}
}
}
void showRewardPhotoViewer({
required List<String?> imageUrls,
int initialIndex = 0,
}) {
final urls = imageUrls
.whereType<String>()
.map((url) => url.trim())
.where((url) => url.isNotEmpty)
.toList(growable: false);
if (urls.isEmpty) {
EpicSnackBar.showWarningSnackBar(
'Peringatan',
'Gambar reward tidak tersedia.',
);
return;
}
final safeInitialIndex = initialIndex.clamp(0, urls.length - 1).toInt();
Get.dialog(
RewardPhotoViewerDialog(
imageUrls: urls,
initialIndex: safeInitialIndex,
onDownload: downloadRewardImage,
),
barrierDismissible: true,
barrierColor: Colors.black.withOpacity(0.85),
useSafeArea: true,
);
}
Future<void> downloadRewardImage(String imageUrl) async {
if (!GetPlatform.isAndroid && !GetPlatform.isIOS) {
EpicSnackBar.showWarningSnackBar(
'Tidak Didukung',
'Fitur simpan gambar hanya tersedia di Android/iOS.',
);
return;
}
try {
EpicSnackBar.showPendingSnackBar(
'Download',
'Menyimpan gambar reward ke perangkat...',
showLog: false,
);
final response = await Dio().get<List<int>>(
imageUrl,
options: Options(responseType: ResponseType.bytes),
);
final bytes = response.data;
if (bytes == null || bytes.isEmpty) {
EpicSnackBar.showErrorSnackBar(
'Gagal',
'Gagal mengunduh gambar reward.',
);
return;
}
final hasAccess = await Gal.hasAccess();
if (!hasAccess) {
final isGranted = await Gal.requestAccess();
if (!isGranted) {
EpicSnackBar.showWarningSnackBar(
'Izin Ditolak',
'Aplikasi butuh izin galeri untuk menyimpan gambar reward.',
);
return;
}
}
final fileName = 'reward_${DateTime.now().millisecondsSinceEpoch}';
await Gal.putImageBytes(
Uint8List.fromList(bytes),
name: fileName,
album: 'Epic Story',
);
EpicSnackBar.showSuccessSnackBar(
'Berhasil',
'Gambar reward berhasil disimpan di galeri.',
showLog: false,
);
} on MissingPluginException catch (e, s) {
EpicLog.exception(e, s, this, 'downloadRewardImage - missing plugin');
EpicSnackBar.showWarningSnackBar(
'Perlu Restart Aplikasi',
'Plugin simpan gambar belum aktif. Tutup aplikasi lalu jalankan ulang penuh.',
);
} on GalException catch (e, s) {
EpicLog.exception(e, s, this, 'downloadRewardImage - gal exception');
if (e.type == GalExceptionType.accessDenied) {
EpicSnackBar.showWarningSnackBar(
'Izin Ditolak',
'Akses galeri ditolak. Izinkan akses galeri untuk menyimpan reward.',
);
return;
}
EpicSnackBar.showErrorSnackBar(
'Gagal',
'Tidak bisa menyimpan gambar reward. Coba lagi.',
);
} catch (e, s) {
EpicLog.exception(e, s, this, 'downloadRewardImage');
EpicSnackBar.showErrorSnackBar(
'Gagal',
'Terjadi kesalahan saat menyimpan gambar reward.',
);
}
}
Future<bool> loginGoogle({
GoogleSignInAccount? googleSignIn,
}) async {
try {
showLoadingPage();
final bool isConnected = await isConnectedToInternet();
if (!isConnected) {
EpicLog.debug(
'loginGoogle - Google Login Error: No internet connection');
EpicSnackBar.showErrorSnackBar(
"Failure", "Please check your internet connection.");
return false;
}
GoogleSignInAccount? googleUser = googleSignIn;
if (googleUser == null) {
await authService.signOut();
googleUser = await authService.signIn();
if (googleUser == null) {
EpicLog.debug(
'loginGoogle - Google Login Error: User cancelled sign in');
return false;
}
}
if (!await firebaseService.loginWithGoogle(
googleSignIn: googleUser, ignoreAnonymous: false)) {
EpicLog.debug(
'loginGoogle - Google Login Error: Firebase authentication failed');
EpicSnackBar.showErrorSnackBar(
"Failure", "Failed to authenticate with Firebase.");
}
await isarService.clearAllData();
final remoteStudent = await childrenUsecase.getChildrenRemoteModel();
childrenData.value = remoteStudent;
EpicLog.debug('loginGoogle - Google Login: Login successful');
return true;
} catch (e, s) {
EpicLog.exception(e, s, this,
'loginGoogle - Error occurred while logging in with Google');
return false;
} finally {
hideLoadingPage();
}
}
Future<void> studentLogout() async {
try {
showLoadingPage();
await Future.wait([
childrenUsecase.logout(),
firebaseService.logout(),
authService.signOut(),
isarService.clearAllData(),
]);
EpicLog.debug('✅ Logout: User logged out successfully');
} catch (e, s) {
EpicLog.exception(e, s, this, 'studentLogout');
} finally {
hideLoadingPage();
}
}
Future<void> testFetchFlashcardHistories({
bool refreshLocal = false,
}) async {
try {
EpicLog.debug('controller getFlashcardHistories');
final histories = await childrenUsecase.testGetFlashcardHistories(
refreshLocal: refreshLocal,
);
EpicLog.debug('controller getFlashcardHistories - result: $histories');
} catch (e, s) {
EpicLog.exception(e, s, this, 'testFetchFlashcardHistories');
}
}
}

View File

@ -0,0 +1,108 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:epic_story_app/core/constants/api_key.dart';
import 'package:epic_story_app/data/models/remotes/gemini_response.dart';
Future<String> generate({required String prompt}) async {
try {
final Dio _dio = Dio();
// generate response from gemini api
final response = await _dio.post(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
queryParameters: {
"key": ApiKey.gemini,
},
data: json.encode(
{
"contents": [
{
"role": "user",
"parts": [
{"text": prompt}
]
},
],
"generationConfig": {
"temperature": 1,
"topK": 0,
"topP": 0.95,
"maxOutputTokens": 8192,
"stopSequences": []
},
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE"
}
]
},
),
options: Options(
headers: {'Content-Type': 'application/json'},
validateStatus: (status) {
return status == HttpStatus.ok ||
status == HttpStatus.badRequest ||
status == HttpStatus.internalServerError ||
status == HttpStatus.notFound;
},
),
);
// get payload from response
final dynamic payload = response.data;
// check response is successfully or not
if (response.statusCode == HttpStatus.ok) {
// get prompt result
GeminiResponse geminiResponse = GeminiResponse.fromJson(payload);
var geminiPayload =
geminiResponse.candidates?[0].content?.parts?[0].text ?? '{}';
return geminiPayload;
} else {
return "error";
}
} on DioException catch (ex) {
return "exception : ${ex.toString()}";
}
}
void main() async {
var prompt = """
Teks:
"Aku ada sepeda mainan. Ayah bisa mengendarai sepeda besar. Aku berharap bisa mengendarai sepeda dan ikut ayah bekerja. Ini motor. Pakai motor bisa melaju lebih cepat dari sepeda. Ada mobil di jalan juga. Motor dapat mengejar mobil. Aku tidak sabar sampai aku cukup umur untuk mengendarai mobil. Aku akan bisa pergi ke tempat yang aku inginkan. Apa itu di sebelah? Ada bis besar."
Instruksi:
Buat hanya 1 soal.
Soal harus berdasarkan isi teks.
Sediakan 4 pilihan jawaban (A, B, C, D).
Hanya 1 jawaban yang benar.
Gunakan format JSON berikut dan pastikan JSON valid.
Jangan tambahkan penjelasan apa pun di luar JSON.
Format output:
{
"question": "Pertanyaan berdasarkan teks",
"choices": [
"Pilihan A",
"Pilihan B",
"Pilihan C",
"Pilihan D"
],
"correctedIndex": 0
}
""";
var response = await generate(prompt: prompt);
print("$response");
}

View File

@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:epic_story_app/core/constants/api_key.dart';
import 'package:epic_story_app/data/models/remotes/gemini_response.dart';
String _normalizeToJsonString(String input) {
// Try to extract JSON content even if wrapped in code fences or extra text.
String candidate = input;
final fencedMatch =
RegExp(r"```(?:json)?\s*([\s\S]*?)\s*```", multiLine: true)
.firstMatch(input);
if (fencedMatch != null) {
candidate = fencedMatch.group(1) ?? candidate;
}
String? _tryDecode(String text) {
try {
final decoded = json.decode(text);
return json.encode(decoded);
} catch (_) {
return null;
}
}
final start = candidate.indexOf('{');
final end = candidate.lastIndexOf('}');
if (start != -1 && end != -1 && end > start) {
final slice = candidate.substring(start, end + 1);
final normalized = _tryDecode(slice);
if (normalized != null) return normalized;
}
final normalized = _tryDecode(candidate);
if (normalized != null) return normalized;
return json.encode({"error": "invalid_json", "raw": input.trim()});
}
Future<String> generate({required String prompt}) async {
try {
final Dio _dio = Dio();
// generate response from gemini api
final response = await _dio.post(
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
queryParameters: {
"key": ApiKey.gemini,
},
data: json.encode(
{
"contents": [
{
"role": "user",
"parts": [
{"text": prompt}
]
},
],
"generationConfig": {
"temperature": 1,
"topK": 0,
"topP": 0.95,
"maxOutputTokens": 8192,
"stopSequences": []
},
"safetySettings": [
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
{
"category": "HARM_CATEGORY_HATE_SPEECH",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
"threshold": "BLOCK_NONE"
},
{
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
"threshold": "BLOCK_NONE"
}
]
},
),
options: Options(
headers: {'Content-Type': 'application/json'},
validateStatus: (status) {
return status == HttpStatus.ok ||
status == HttpStatus.badRequest ||
status == HttpStatus.internalServerError ||
status == HttpStatus.notFound;
},
),
);
// get payload from response
final dynamic payload = response.data;
// check response is successfully or not
if (response.statusCode == HttpStatus.ok) {
// get prompt result
GeminiResponse geminiResponse = GeminiResponse.fromJson(payload);
var geminiPayload =
geminiResponse.candidates?[0].content?.parts?[0].text ?? '{}';
return _normalizeToJsonString(geminiPayload);
} else {
return json.encode({
"error": "bad_response",
"statusCode": response.statusCode,
"payload": payload,
});
}
} on DioException catch (ex) {
return json.encode({"error": "exception", "message": ex.toString()});
}
}
void main() async {
var prompt = """
Teks:
"Aku ada sepeda mainan. Ayah bisa mengendarai sepeda besar. Aku berharap bisa mengendarai sepeda dan ikut ayah bekerja. Ini motor. Pakai motor bisa melaju lebih cepat dari sepeda. Ada mobil di jalan juga. Motor dapat mengejar mobil. Aku tidak sabar sampai aku cukup umur untuk mengendarai mobil. Aku akan bisa pergi ke tempat yang aku inginkan. Apa itu di sebelah? Ada bis besar."
Instruksi:
Buat hanya 1 soal.
Soal harus berdasarkan isi teks.
Sediakan 4 pilihan jawaban (A, B, C, D).
Hanya 1 jawaban yang benar.
Gunakan format JSON berikut dan pastikan JSON valid.
Jangan tambahkan penjelasan apa pun di luar JSON.
Format output:
{
"question": "Pertanyaan berdasarkan teks",
"quizType": 1,
"choices": [
"Pilihan A",
"Pilihan B",
"Pilihan C",
"Pilihan D"
],
"correctedIndex": 0
}
""";
var response = await generate(prompt: prompt);
print("$response");
}

View File

@ -0,0 +1,64 @@
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:epic_story_app/feature/ai_tutors/presentation/ai_tutor_controller.dart';
import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_controller.dart';
import 'package:epic_story_app/feature/home/home_controller.dart';
import 'package:epic_story_app/feature/profiles/presentation/profile_controller.dart';
import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:get/get.dart';
class EpicNavigationBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
CollectionModule();
BookModule();
FlashCardModule();
Get.lazyPut<HomeController>(
() => HomeController(
collectionUsecase: Get.find<CollectionUsecase>(),
childrenUsecase: Get.find<ChildrenUsecase>(),
flashCardUsecase: Get.find<FlashCardUsecase>(),
bookUsecase: Get.find<BookUsecase>(),
),
);
Get.lazyPut(() => AiTutorController());
Get.lazyPut(() => BookHomeController(
childrenUsecase: Get.find<ChildrenUsecase>(),
bookUsecase: Get.find<BookUsecase>(),
collectionUsecase: Get.find<CollectionUsecase>(),
));
Get.lazyPut(() => FlashCardHomeController(
flashCardUsecase: Get.find<FlashCardUsecase>(),
childrenUsecase: Get.find<ChildrenUsecase>(),
));
Get.lazyPut(() => FavoriteHomeController(
collectionUsecase: Get.find<CollectionUsecase>(),
));
Get.lazyPut(() => ProfileController(
childrenUsecase: Get.find<ChildrenUsecase>(),
));
Get.lazyPut<RewardHomeController>(
() => RewardHomeController(
collectionUsecase: Get.find<CollectionUsecase>(),
childrenUsecase: Get.find<ChildrenUsecase>(),
flashCardUsecase: Get.find<FlashCardUsecase>(),
bookUsecase: Get.find<BookUsecase>(),
),
);
Get.lazyPut(() => EpicNavigationController());
}
}

View File

@ -0,0 +1,28 @@
import 'package:epic_story_app/core/utils/epic_log.dart';
import 'package:get/get.dart';
class EpicNavigationController extends GetxController {
EpicNavigationController();
var isBook = false.obs;
RxInt selectedIndex = 0.obs;
// var refreshRewardHome = 0.obs;
var refresRewardFlashcard = 0.obs;
var refreshRewardBook = 0.obs;
var refreshCollectionHome = 0.obs;
var selectedCategory = "".obs;
void changePage(int index) {
selectedIndex.value = index;
}
@override
void onInit() {
super.onInit();
ever(selectedCategory, (value) {
EpicLog.debug("Selected category: $value");
});
}
}

View File

@ -0,0 +1,141 @@
import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_page.dart';
import 'package:epic_story_app/feature/home/home_page.dart';
import 'package:epic_story_app/feature/profiles/presentation/profile_page.dart';
import 'package:epic_story_app/feature/rewards/reward_home/reward_home_page.dart';
import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class EpicNavigationPage extends StatelessWidget {
const EpicNavigationPage({super.key});
static const Color _navGradientTop = Color(0xFF7E32AB);
static const Color _navGradientBottom = Color(0xFF331445);
static const List<_NavItemData> _navItems = [
_NavItemData(
title: 'Home',
inactiveIcon: 'assets/images/ic-nav-home-inactive.png',
activeIcon: 'assets/images/ic-nav-home-active.png',
),
_NavItemData(
title: 'Koleksi',
inactiveIcon: 'assets/images/ic-nav-koleksi-inactive.png',
activeIcon: 'assets/images/ic-nav-koleksi-active.png',
),
_NavItemData(
title: 'Hadiah',
inactiveIcon: 'assets/images/ic-nav-hadian-inactive.png',
activeIcon: 'assets/images/ic-nav-hadiah-active.png',
),
_NavItemData(
title: 'Profil',
inactiveIcon: 'assets/images/ic-nav-profile-inactive.png',
activeIcon: 'assets/images/ic-nav-profile-active.png',
),
];
@override
Widget build(BuildContext context) {
final EpicNavigationController controller = Get.find();
return Scaffold(
body: Obx(() => IndexedStack(
index: controller.selectedIndex.value,
children: const [
HomePage(),
FavoriteHomePage(),
RewardHomePage(),
ProfilePage(),
],
)),
bottomNavigationBar: Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [_navGradientTop, _navGradientBottom],
stops: [0.5, 1.0],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(26),
topRight: Radius.circular(26),
),
),
child: SafeArea(
top: false,
child: Obx(() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_navItems.length, (index) {
final _NavItemData item = _navItems[index];
final bool isActive = controller.selectedIndex.value == index;
return Padding(
padding: EdgeInsets.only(left: index == 0 ? 0 : 8),
child: _buildNavItem(
isActive: isActive,
activeIconPath: item.activeIcon,
inactiveIconPath: item.inactiveIcon,
title: item.title,
onTap: () => controller.changePage(index),
),
);
}),
);
}),
),
),
);
}
Widget _buildNavItem({
required bool isActive,
required String activeIconPath,
required String inactiveIconPath,
required String title,
required VoidCallback onTap,
}) {
final String iconPath = isActive ? activeIconPath : inactiveIconPath;
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
width: isActive ? 108 : 64,
height: 64,
alignment: Alignment.center,
child: Semantics(
label: title,
button: true,
selected: isActive,
child: Image.asset(
iconPath,
key: ValueKey<String>('${title.toLowerCase()}_$isActive'),
width: isActive ? 108 : 64,
height: isActive ? 64 : 64,
fit: BoxFit.contain,
filterQuality: FilterQuality.high,
),
),
),
),
);
}
}
class _NavItemData {
const _NavItemData({
required this.title,
required this.inactiveIcon,
required this.activeIcon,
});
final String title;
final String inactiveIcon;
final String activeIcon;
}

View File

@ -0,0 +1,10 @@
import 'package:get/get.dart';
import 'read_pdf_test_controller.dart';
class ReadPdfTestBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<ReadPdfTestController>(() => ReadPdfTestController());
}
}

View File

@ -0,0 +1,169 @@
import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:syncfusion_flutter_pdf/pdf.dart';
import 'package:flutter_tts/flutter_tts.dart';
class ReadPdfTestController extends GetxController {
// Visible viewer controller
final PdfViewerController mainPdfController = PdfViewerController();
// Key to force rebuilding the SfPdfViewer when switching source (kept if needed)
final Rx<UniqueKey> viewerKey = UniqueKey().obs;
// Current page (reactive)
final RxInt currentPage = 1.obs;
// New: store extracted text for current page
final RxString currentPageText = ''.obs;
// In-memory PDF bytes (raw file) - optional for future use
Uint8List? preloadedBytes;
final RxBool isPreloaded = false.obs;
// Preloading state (loading raw bytes)
final RxBool isPreloading = false.obs;
// Flutter TTS instance
final FlutterTts _flutterTts = FlutterTts();
// Optional: track speaking state
final RxBool isSpeaking = false.obs;
// Helper to jump to a specific page on the main viewer
void jumpToPage(int page) {
try {
mainPdfController.jumpToPage(page);
} catch (_) {}
}
// Helper to go to next page on the main viewer
void nextPage() {
try {
mainPdfController.nextPage();
} catch (_) {}
}
// Helper to go to previous page on the main viewer
void previousPage() {
try {
mainPdfController.previousPage();
} catch (_) {}
}
// Loads raw bytes from assets (keeps for fallback)
Future<void> preloadRawBytes() async {
if (isPreloaded.value || isPreloading.value) return;
isPreloading.value = true;
try {
final data = await rootBundle.load('assets/books/others/transportation.pdf');
preloadedBytes = data.buffer.asUint8List();
isPreloaded.value = true;
} catch (e) {
isPreloaded.value = false;
} finally {
isPreloading.value = false;
}
}
// Extract text for a 1-based page number. Returns null on failure.
Future<String?> extractTextFromPage(int pageNumber) async {
if (pageNumber < 1) return null;
if (preloadedBytes == null) {
await preloadRawBytes();
}
if (preloadedBytes == null) return null;
PdfDocument? doc;
try {
doc = PdfDocument(inputBytes: preloadedBytes!);
// PdfTextExtractor supports extracting by page index (0-based).
final extractor = PdfTextExtractor(doc);
// Attempt to extract just that page
String text;
try {
text = extractor.extractText(startPageIndex: pageNumber - 1, endPageIndex: pageNumber - 1);
} catch (_) {
// Fallback: extract whole document and try to approximate
text = extractor.extractText();
}
return text;
} catch (e) {
return null;
} finally {
try {
doc?.dispose();
} catch (_) {}
}
}
// Print current page text to console and save to variable (no dialog)
Future<void> printCurrentPageText() async {
final page = currentPage.value;
final text = await extractTextFromPage(page);
// Collapse all whitespace (newlines/tabs/multiple spaces) into single spaces
final cleaned = (text == null || text.trim().isEmpty)
? ''
: text.replaceAll(RegExp(r'\s+'), ' ').trim();
final output = cleaned.isEmpty ? 'No text found on page $page.' : cleaned;
// Save the whole-page text (one line) to the reactive variable
currentPageText.value = output;
// speak the cleaned page text (skip if no real text)
if (cleaned.isNotEmpty) {
await speakText(cleaned);
}
// Print to console only
// ignore: avoid_print
print('PDF page $page text:\n$output');
}
@override
void onInit() {
super.onInit();
// Initialize TTS settings; adjust language/rate/pitch as needed
() async {
try {
await _flutterTts.setLanguage('id-ID'); // or 'en-US'
await _flutterTts.setSpeechRate(0.45);
await _flutterTts.setVolume(1.0);
await _flutterTts.setPitch(1.0);
// Optional: set handlers
_flutterTts.setStartHandler(() {
isSpeaking.value = true;
});
_flutterTts.setCompletionHandler(() {
isSpeaking.value = false;
});
_flutterTts.setErrorHandler((msg) {
isSpeaking.value = false;
});
} catch (_) {}
}();
}
// Speak given text using flutter_tts
Future<void> speakText(String text) async {
if (text.trim().isEmpty) return;
try {
// stop any current speech, then speak
await _flutterTts.stop();
await _flutterTts.speak(text);
} catch (_) {}
}
@override
void onClose() {
try {
_flutterTts.stop();
} catch (_) {}
try {
mainPdfController.dispose();
} catch (_) {}
preloadedBytes = null;
super.onClose();
}
}

View File

@ -0,0 +1,63 @@
import 'package:epic_story_app/feature/utils/read_pdf_test/read_pdf_test_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
class ReadPdfTestPage extends StatelessWidget {
const ReadPdfTestPage({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<ReadPdfTestController>();
const assetPath = 'assets/books/others/transportation.pdf';
return Scaffold(
appBar: AppBar(
title: Obx(() => Text('Page ${controller.currentPage.value}')),
actions: [
Obx(() => IconButton(
tooltip: 'Previous page',
icon: const Icon(Icons.arrow_back),
onPressed: controller.currentPage.value > 1 ? () => controller.previousPage() : null,
)),
IconButton(
tooltip: 'Next page',
icon: const Icon(Icons.arrow_forward),
onPressed: () => controller.nextPage(),
),
IconButton(
tooltip: 'First page',
icon: const Icon(Icons.first_page),
onPressed: () => controller.jumpToPage(1),
),
// New: print/extract text on current page
IconButton(
tooltip: 'Print page text',
icon: const Icon(Icons.print),
onPressed: () => controller.printCurrentPageText(),
),
],
),
body: SafeArea(
child: Stack(
children: [
// Visible main viewer (single page layout)
SfPdfViewer.asset(
assetPath,
key: controller.viewerKey.value,
controller: controller.mainPdfController,
pageLayoutMode: PdfPageLayoutMode.single,
pageSpacing: 0,
onPageChanged: (PdfPageChangedDetails details) {
controller.currentPage.value = details.newPageNumber;
},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {
// Tidak ada preload, hanya memuat halaman yang terlihat
},
),
],
),
),
);
}
}

View File

@ -0,0 +1,17 @@
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/utils/splash_screen/splash_screen_controller.dart';
import 'package:get/get.dart';
class SplashScreenBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
Get.lazyPut(
() => SplashScreenController(
childrenUsecase: Get.find<ChildrenUsecase>(),
),
);
}
}

View File

@ -0,0 +1,35 @@
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/usecases/children_usecase.dart';
import 'package:get/get.dart';
class SplashScreenController extends GetxController {
final ChildrenUsecase childrenUsecase;
SplashScreenController({
required this.childrenUsecase,
});
@override
void onInit() async {
super.onInit();
await Future.delayed(const Duration(seconds: 2));
bool isLogged = await childrenUsecase.isLogged();
if (isLogged) {
try {
await childrenUsecase
.syncChildrenData()
.timeout(const Duration(seconds: 6));
} catch (ex, s) {
EpicLog.exception(ex, s, this, 'onInit.syncChildrenData');
}
if (await childrenUsecase.isEmptyProfileData() == true) {
Get.offAllNamed(EpicRoutes.navigation);
} else {
Get.offAllNamed(EpicRoutes.navigation);
}
} else {
Get.offAllNamed(EpicRoutes.welcome);
}
}
}

View File

@ -0,0 +1,27 @@
import 'package:epic_story_app/feature/utils/splash_screen/splash_screen_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class SplashScreenPage extends StatelessWidget {
const SplashScreenPage({super.key});
@override
Widget build(BuildContext context) {
final SplashScreenController controller = Get.find();
return const Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Text(
"This is Splash Screen",
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
);
}
}

View File

@ -0,0 +1,10 @@
import 'package:epic_story_app/feature/utils/test_tts/test_tts_controller.dart';
import 'package:get/get.dart';
class TestTtsBinding extends Bindings{
@override
void dependencies() {
Get.lazyPut(() => TestTtsController());
}
}

View File

@ -0,0 +1,81 @@
import 'package:get/get.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:flutter/services.dart';
import 'package:google_speech/google_speech.dart';
class TestTtsController extends GetxController {
final FlutterTts flutterTts = FlutterTts();
var isPlaying = false.obs;
var voices = <Map>[].obs;
var selectedVoice = Rxn<Map>();
@override
void onInit() {
super.onInit();
_initTts();
}
void _initTts() async {
flutterTts.setLanguage("id-ID");
flutterTts.setPitch(1.0);
flutterTts.setSpeechRate(0.5);
// Ambil semua suara filter hanya id-ID
var v = await flutterTts.getVoices;
var filtered = List<Map>.from(v).where((e) => e["locale"] == "id-ID").toList();
voices.assignAll(filtered);
if (voices.isNotEmpty) {
selectedVoice.value = voices.first;
await setVoice(voices.first);
}
// Listener event TTS
flutterTts.setStartHandler(() {
isPlaying.value = true;
});
flutterTts.setCompletionHandler(() {
isPlaying.value = false;
});
flutterTts.setCancelHandler(() {
isPlaying.value = false;
});
}
Future<void> setVoice(Map voice) async {
selectedVoice.value = voice;
await flutterTts.setVoice({
"name": voice["name"],
"locale": voice["locale"],
});
}
Future<void> speak(String text) async {
if (text.isEmpty) return;
await flutterTts.speak(text);
}
Future<void> stop() async {
await flutterTts.stop();
isPlaying.value = false;
}
Future<void> speakWithGoogleTTS(String text) async {
// final serviceAccount = ServiceAccount.fromString(
// '${(await rootBundle.loadString('assets/test_service_account.json'))}');
//
// final speechToText = EndlessStreamingService.viaServiceAccount(serviceAccount);
//
// final responseStream = speechToText.endlessStream;
//
// speechToText.endlessStreamingRecognize(
// StreamingRecognitionConfig(config: config, interimResults: true),
// _audioStream!);
//
// responseStream.listen((data) {...});
}
}

View File

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'test_tts_controller.dart';
class TetsTtsPage extends StatelessWidget {
TetsTtsPage({super.key});
final controller = Get.find<TestTtsController>();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text("TTS Bahasa Indonesia")),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Dropdown suara Indo
Obx(() {
if (controller.voices.isEmpty) {
return Text("Loading suara Indonesia...");
}
return DropdownButton<Map>(
isExpanded: true,
value: controller.selectedVoice.value,
items: controller.voices.map((v) {
return DropdownMenuItem<Map>(
value: v,
child: Text("${v['name']}"),
);
}).toList(),
onChanged: (val) {
if (val != null) controller.setVoice(val);
},
);
}),
SizedBox(height: 20),
// Dummy teks Indo
Text("Contoh teks Indonesia"),
SizedBox(height: 10),
Obx(() => ElevatedButton(
onPressed: controller.isPlaying.value
? controller.stop
: () => controller.speak("""Semut Kecil dan Belalang yang Malas
Di padang rumput yang luas, hiduplah seekor Semut Kecil yang sangat rajin bernama Kiki dan seekor Belalang yang suka bermalas-malasan bernama Lala.
Saat musim panas tiba, Kiki sibuk sekali. Ia setiap hari berjalan membawa butiran makanan kecil, mengumpulkannya di sarang untuk persediaan musim dingin. "Ayo, Kiki, istirahat sebentar! Kenapa kamu harus bekerja keras di hari secerah ini?" seru Lala, sambil rebahan santai dan memetik gitar daunnya.
"Aku harus mengumpulkan makanan sekarang, Lala," jawab Kiki dengan napas terengah-engah. "Musim dingin akan segera datang, dan saat itu tidak ada makanan di luar."
"Ah, musim dingin masih lama! Santai saja," balas Lala sambil tertawa dan kembali bernyanyi. Lala menghabiskan hari-harinya dengan bermain dan bersenang-senang, tidak peduli dengan pekerjaan Kiki.
Beberapa minggu kemudian, udara mulai terasa dingin. Daun-daun berguguran. Musim Dingin benar-benar datang.
Lala mulai kedinginan dan kelaparan. Ia tidak punya makanan sama sekali. Dengan tubuh menggigil, ia berjalan ke sarang Kiki.
"Kiki, temanku," kata Lala pelan. "Aku kedinginan dan lapar. Aku tidak punya makanan. Bisakah aku meminta sedikit persediaanmu?"
Kiki, yang sedang duduk hangat di antara tumpukan makanan, merasa iba. "Aku sudah bilang padamu, Lala, kita harus bersiap. Tapi baiklah, aku akan membaginya. Lain kali, kamu harus belajar untuk bekerja keras dan merencanakan masa depan."
Lala sangat berterima kasih. Sejak hari itu, Lala belajar dari Kiki. Saat musim panas berikutnya datang, Lala tidak hanya bernyanyi, tetapi juga ikut membantu Kiki mengumpulkan makanan. Mereka bekerja bersama-sama, dan mereka pun menjadi sahabat baik.
Pesan moral: Kerja keras dan persiapan itu penting. Selain itu, menolong teman yang kesusahan adalah perbuatan baik."""),
child: Text(controller.isPlaying.value ? "Stop" : "Speak"),
)),
],
),
),
);
}
}

View File

@ -0,0 +1,15 @@
import 'package:epic_story_app/data/modules/children_module.dart';
import 'package:epic_story_app/domain/usecases/children_usecase.dart';
import 'package:epic_story_app/feature/utils/welcome/welcome_controller.dart';
import 'package:get/get.dart';
class WelcomeBinding extends Bindings {
@override
void dependencies() {
ChildrenModule();
Get.lazyPut(() => WelcomeController(
childrenUsecase: Get.find<ChildrenUsecase>(),
));
}
}

View File

@ -0,0 +1,36 @@
import 'package:epic_story_app/core/routes/epic_routes.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:get/get.dart';
import '../../../core/utils/epic_log.dart';
class WelcomeController extends GetxController {
final ChildrenUsecase childrenUsecase;
final mainController = Get.find<MainController>();
WelcomeController({required this.childrenUsecase});
void signInWithGoogle() async {
try {
EpicLog.debug("Starting Google Sign-In process");
final signInFire = await mainController.loginGoogle();
if (signInFire) {
mainController.showLoadingPage();
}
final result = await childrenUsecase.isEmptyProfileData();
if (signInFire) {
if (result == true) {
await Get.offAllNamed(EpicRoutes.navigation);
} else {
await Get.offAllNamed(EpicRoutes.navigation);
}
// Wait until navigation completes, then hide the loading page
mainController.hideLoadingPage();
}
} catch (ex, s) {
mainController.hideLoadingPage();
EpicLog.exception(ex, s, this, 'signInWithGoogle');
}
}
}

View File

@ -0,0 +1,188 @@
import 'package:epic_story_app/feature/utils/welcome/welcome_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class WelcomePage extends StatelessWidget {
const WelcomePage({super.key});
static const Color _backgroundColor = Color(0xFFE0CEFF);
static const Color _buttonColor = Color(0xFF7A1FA0);
static const Color _blurLayerColor = Color(0xFF272052);
@override
Widget build(BuildContext context) {
final WelcomeController controller = Get.find();
return Scaffold(
backgroundColor: _backgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final double width = constraints.maxWidth;
final double height = constraints.maxHeight;
return Stack(
children: [
Positioned(
left: -width * 0.35,
top: height * 0.1,
child: _buildBlurCircle(
size: width * 0.95,
opacity: 0.22,
),
),
Positioned(
right: -width * 0.42,
top: height * 0.2,
child: _buildBlurCircle(
size: width * 1.1,
opacity: 0.18,
),
),
Positioned(
top: height * 0.02,
left: width * 0.06,
child: Image.asset(
'assets/images/book-stack.png',
width: width * 0.26,
),
),
Positioned(
top: height * 0.02,
right: width * 0.06,
child: Image.asset(
'assets/images/coin-right.png',
width: width * 0.2,
),
),
Positioned(
top: height * 0.46,
right: width * 0.08,
child: Image.asset(
'assets/images/coin-left.png',
width: width * 0.09,
),
),
Positioned(
top: height * 0.61,
left: width * 0.08,
child: Image.asset(
'assets/images/coin-left.png',
width: width * 0.11,
),
),
Align(
alignment: const Alignment(0, -0.24),
child: Image.asset(
'assets/images/book-and-world.png',
width: width * 0.82,
),
),
Positioned(
right: width * 0.14,
bottom: height * 0.18,
child: Image.asset(
'assets/images/book-stack.png',
width: width * 0.12,
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.fromLTRB(
width * 0.1,
0,
width * 0.1,
height * 0.08,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome\nto Epic Story Mobile!',
style: TextStyle(
color: Colors.white,
fontSize: width * 0.07,
fontWeight: FontWeight.w700,
height: 1.05,
),
),
const SizedBox(height: 10),
Text(
'Play, Learn, and Explore with Exciting\nQuizzes!',
style: TextStyle(
color: Colors.white.withOpacity(0.95),
fontSize: width * 0.045,
fontWeight: FontWeight.w500,
height: 1.35,
),
),
const SizedBox(height: 28),
SizedBox(
width: double.infinity,
height: 76,
child: ElevatedButton(
onPressed: controller.signInWithGoogle,
style: ElevatedButton.styleFrom(
backgroundColor: _buttonColor,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(40),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/images/logo-google.png',
width: 38,
height: 38,
),
const SizedBox(width: 12),
Text(
'Login Google',
style: TextStyle(
fontSize: width * 0.072,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
],
);
},
),
),
);
}
Widget _buildBlurCircle({
required double size,
required double opacity,
}) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: _blurLayerColor.withOpacity(opacity),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _blurLayerColor.withOpacity(opacity + 0.22),
blurRadius: size * 0.45,
spreadRadius: size * 0.05,
),
],
),
);
}
}