diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2078306
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,47 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ related
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# The .vscode folder contains launch configuration and tasks you configure in
+# VS Code which you may wish to be included in version control, so this line
+# is commented out by default.
+#.vscode/
+
+# Flutter/Dart/Pub related
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins-dependencies
+.pub-cache/
+.pub/
+/build/
+/coverage/
+
+# Symbolication related
+app.*.symbols
+
+# Obfuscation related
+app.*.map.json
+
+# Android Studio will place build artifacts here
+/android/app/debug
+/android/app/profile
+/android/app/release
+
+.vscode/templates
diff --git a/.metadata b/.metadata
new file mode 100644
index 0000000..3d40ea9
--- /dev/null
+++ b/.metadata
@@ -0,0 +1,33 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "d693b4b9dbac2acd4477aea4555ca6dcbea44ba2"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
+ base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
+ - platform: android
+ create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
+ base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
+ - platform: ios
+ create_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
+ base_revision: d693b4b9dbac2acd4477aea4555ca6dcbea44ba2
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/Prototype Epic Story/1.1 Login.png b/Prototype Epic Story/1.1 Login.png
new file mode 100644
index 0000000..39de0ed
Binary files /dev/null and b/Prototype Epic Story/1.1 Login.png differ
diff --git a/Prototype Epic Story/Baca - koleksi.png b/Prototype Epic Story/Baca - koleksi.png
new file mode 100644
index 0000000..87712ee
Binary files /dev/null and b/Prototype Epic Story/Baca - koleksi.png differ
diff --git a/Prototype Epic Story/Baca Materi 2.png b/Prototype Epic Story/Baca Materi 2.png
new file mode 100644
index 0000000..2818112
Binary files /dev/null and b/Prototype Epic Story/Baca Materi 2.png differ
diff --git a/Prototype Epic Story/Baca Materi.png b/Prototype Epic Story/Baca Materi.png
new file mode 100644
index 0000000..1a4f732
Binary files /dev/null and b/Prototype Epic Story/Baca Materi.png differ
diff --git a/Prototype Epic Story/Beranda - Select Mapel (Buku).png b/Prototype Epic Story/Beranda - Select Mapel (Buku).png
new file mode 100644
index 0000000..b80ee50
Binary files /dev/null and b/Prototype Epic Story/Beranda - Select Mapel (Buku).png differ
diff --git a/Prototype Epic Story/Beranda - Select Mapel (Flashcard).png b/Prototype Epic Story/Beranda - Select Mapel (Flashcard).png
new file mode 100644
index 0000000..c1e6ed7
Binary files /dev/null and b/Prototype Epic Story/Beranda - Select Mapel (Flashcard).png differ
diff --git a/Prototype Epic Story/Beranda - Select Tipe Belajar.png b/Prototype Epic Story/Beranda - Select Tipe Belajar.png
new file mode 100644
index 0000000..084fdeb
Binary files /dev/null and b/Prototype Epic Story/Beranda - Select Tipe Belajar.png differ
diff --git a/Prototype Epic Story/Buat Koleksi.png b/Prototype Epic Story/Buat Koleksi.png
new file mode 100644
index 0000000..f3a88eb
Binary files /dev/null and b/Prototype Epic Story/Buat Koleksi.png differ
diff --git a/Prototype Epic Story/Detail Koleksi Buku.png b/Prototype Epic Story/Detail Koleksi Buku.png
new file mode 100644
index 0000000..577133b
Binary files /dev/null and b/Prototype Epic Story/Detail Koleksi Buku.png differ
diff --git a/Prototype Epic Story/Detail Koleksi Flashcard.png b/Prototype Epic Story/Detail Koleksi Flashcard.png
new file mode 100644
index 0000000..35f2dc0
Binary files /dev/null and b/Prototype Epic Story/Detail Koleksi Flashcard.png differ
diff --git a/Prototype Epic Story/Flashcard Pelajaran.png b/Prototype Epic Story/Flashcard Pelajaran.png
new file mode 100644
index 0000000..e5e08db
Binary files /dev/null and b/Prototype Epic Story/Flashcard Pelajaran.png differ
diff --git a/Prototype Epic Story/Hasil Kuis.png b/Prototype Epic Story/Hasil Kuis.png
new file mode 100644
index 0000000..340b99c
Binary files /dev/null and b/Prototype Epic Story/Hasil Kuis.png differ
diff --git a/Prototype Epic Story/History Flashcard.png b/Prototype Epic Story/History Flashcard.png
new file mode 100644
index 0000000..357a111
Binary files /dev/null and b/Prototype Epic Story/History Flashcard.png differ
diff --git a/Prototype Epic Story/History Pelajaran.png b/Prototype Epic Story/History Pelajaran.png
new file mode 100644
index 0000000..919937a
Binary files /dev/null and b/Prototype Epic Story/History Pelajaran.png differ
diff --git a/Prototype Epic Story/History Reward Book.png b/Prototype Epic Story/History Reward Book.png
new file mode 100644
index 0000000..9e42188
Binary files /dev/null and b/Prototype Epic Story/History Reward Book.png differ
diff --git a/Prototype Epic Story/History Reward Flashcard.png b/Prototype Epic Story/History Reward Flashcard.png
new file mode 100644
index 0000000..eac0588
Binary files /dev/null and b/Prototype Epic Story/History Reward Flashcard.png differ
diff --git a/Prototype Epic Story/Home Pelajaran.png b/Prototype Epic Story/Home Pelajaran.png
new file mode 100644
index 0000000..ffaf622
Binary files /dev/null and b/Prototype Epic Story/Home Pelajaran.png differ
diff --git a/Prototype Epic Story/Klaim Reward 2.png b/Prototype Epic Story/Klaim Reward 2.png
new file mode 100644
index 0000000..7c6a04f
Binary files /dev/null and b/Prototype Epic Story/Klaim Reward 2.png differ
diff --git a/Prototype Epic Story/Koleksi Buku.png b/Prototype Epic Story/Koleksi Buku.png
new file mode 100644
index 0000000..95c496a
Binary files /dev/null and b/Prototype Epic Story/Koleksi Buku.png differ
diff --git a/Prototype Epic Story/Koleksi Flashcard.png b/Prototype Epic Story/Koleksi Flashcard.png
new file mode 100644
index 0000000..28c5614
Binary files /dev/null and b/Prototype Epic Story/Koleksi Flashcard.png differ
diff --git a/Prototype Epic Story/Kuis fc 3.png b/Prototype Epic Story/Kuis fc 3.png
new file mode 100644
index 0000000..72c97e3
Binary files /dev/null and b/Prototype Epic Story/Kuis fc 3.png differ
diff --git a/Prototype Epic Story/Kuis fc.png b/Prototype Epic Story/Kuis fc.png
new file mode 100644
index 0000000..485dbd1
Binary files /dev/null and b/Prototype Epic Story/Kuis fc.png differ
diff --git a/Prototype Epic Story/Kuis fs 2.png b/Prototype Epic Story/Kuis fs 2.png
new file mode 100644
index 0000000..783096b
Binary files /dev/null and b/Prototype Epic Story/Kuis fs 2.png differ
diff --git a/Prototype Epic Story/Materi - Koleksi.png b/Prototype Epic Story/Materi - Koleksi.png
new file mode 100644
index 0000000..beb30ae
Binary files /dev/null and b/Prototype Epic Story/Materi - Koleksi.png differ
diff --git a/Prototype Epic Story/Profil (1).png b/Prototype Epic Story/Profil (1).png
new file mode 100644
index 0000000..281cb9a
Binary files /dev/null and b/Prototype Epic Story/Profil (1).png differ
diff --git a/Prototype Epic Story/Prototype Epic Story.mp4 b/Prototype Epic Story/Prototype Epic Story.mp4
new file mode 100644
index 0000000..6d406fb
Binary files /dev/null and b/Prototype Epic Story/Prototype Epic Story.mp4 differ
diff --git a/Prototype Epic Story/splashscreen.png b/Prototype Epic Story/splashscreen.png
new file mode 100644
index 0000000..a46eb76
Binary files /dev/null and b/Prototype Epic Story/splashscreen.png differ
diff --git a/analysis_options.yaml b/analysis_options.yaml
new file mode 100644
index 0000000..0d29021
--- /dev/null
+++ b/analysis_options.yaml
@@ -0,0 +1,28 @@
+# This file configures the analyzer, which statically analyzes Dart code to
+# check for errors, warnings, and lints.
+#
+# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
+# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
+# invoked from the command line by running `flutter analyze`.
+
+# The following line activates a set of recommended lints for Flutter apps,
+# packages, and plugins designed to encourage good coding practices.
+include: package:flutter_lints/flutter.yaml
+
+linter:
+ # The lint rules applied to this project can be customized in the
+ # section below to disable rules from the `package:flutter_lints/flutter.yaml`
+ # included above or to enable additional rules. A list of all available lints
+ # and their documentation is published at https://dart.dev/lints.
+ #
+ # Instead of disabling a lint rule for the entire project in the
+ # section below, it can also be suppressed for a single line of code
+ # or a specific dart file by using the `// ignore: name_of_lint` and
+ # `// ignore_for_file: name_of_lint` syntax on the line or in the file
+ # producing the lint.
+ rules:
+ # avoid_print: false # Uncomment to disable the `avoid_print` rule
+ # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
+
+# Additional information about this file can be found at
+# https://dart.dev/guides/language/analysis-options
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..279ab68
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id("com.android.application")
+ // START: FlutterFire Configuration
+ id("com.google.gms.google-services")
+ id("com.google.firebase.crashlytics")
+ // END: FlutterFire Configuration
+ id("kotlin-android")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "org.haqiachd.epic_story_app.epic_story_app"
+ compileSdk = 36
+ ndkVersion = "28.1.13356709"
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ defaultConfig {
+ applicationId = "org.haqiachd.epic_story_app.epic_story_app"
+ minSdk = 27
+ targetSdk = 36
+ versionCode = 100
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..bbe7e84
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/org/haqiachd/epic_story_app/epic_story_app/MainActivity.kt b/android/app/src/main/kotlin/org/haqiachd/epic_story_app/epic_story_app/MainActivity.kt
new file mode 100644
index 0000000..0af8dbb
--- /dev/null
+++ b/android/app/src/main/kotlin/org/haqiachd/epic_story_app/epic_story_app/MainActivity.kt
@@ -0,0 +1,5 @@
+package org.haqiachd.epic_story_app.epic_story_app
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..dbee657
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
new file mode 100644
index 0000000..2127a1c
--- /dev/null
+++ b/android/build/reports/problems/problems-report.html
@@ -0,0 +1,663 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ Gradle Configuration Cache
+
+
+
+
+
+
+ Loading...
+
+
+
+
+
+
+
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..f018a61
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+android.enableJetifier=true
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..ac3b479
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..9d2e323
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,30 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "8.9.1" apply false
+ // START: FlutterFire Configuration
+ id("com.google.gms.google-services") version("4.3.15") apply false
+ id("com.google.firebase.crashlytics") version("2.8.1") apply false
+ // END: FlutterFire Configuration
+ id("org.jetbrains.kotlin.android") version "2.1.0" apply false
+}
+
+include(":app")
diff --git a/assets/books/others/transportation.pdf b/assets/books/others/transportation.pdf
new file mode 100644
index 0000000..18a1928
--- /dev/null
+++ b/assets/books/others/transportation.pdf
@@ -0,0 +1,98 @@
+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.
+Aku berharap bisa mengemudikan bis!
+Bus bisa bawa lebih dari sepuluh orang.
+Oh, tidak! Ada jalan rusak di depan.
+Pekerja lagi perbaiki jalan dengan
+eskavator.
+Sekarang aku bisa mengendarai
+eskavator juga.
+
+Oh, ada pantai di sana.
+Ada yang sedang mengendarai
+jet ski.
+Kata orang itu, aku boleh menukar
+eskavator dengan jet ski.
+Kami bertukar!
+Orang itu mengendarai eskavator
+dan aku pakai jet ski.
+Jet ski sangat cepat.
+Aku bisa pakai ini untuk sampai ke
+perahu itu.
+Aku akan bayar ongkos
+perahu nanti, ya.
+
+Aku akan bawa perahu
+ini dengan santai.
+Aku akan bawa ke kapal
+besar di tengah itu.
+Aku sampai di kapal besar!
+Di sinilah tempat ayah bekerja.
+Aku bayangkan bagaimana aku bisa
+sampai ke tempat ayah.
+Ayah tertawa mendengar cerita
+karangan aku itu.
+Aku pakai begitu lebih dari
+satu jenis transportasi.
+
+Transportasi bisa
+membantu kita untuk
+berpindah tempat dengan
+cepat.
+
+Kamu paling suka
+transportasi yang mana?
+ Buku-buku Bookbot merupakan buku yang mengajarkan pada anak tentang huruf, bunyi, dan
+ kata-kata sederhana berdasarkan urutan bunyi atau buku fonik berkualitas yang diperkenalkan
+oleh Cakupan dan Urutan dalam Bookbot. Buku-buku ini bisa didapatkan secara cuma-cuma di
+
+ perpustakaan Bookbot.
+
+ Kami sangat senang dapat menawarkannya pada Anda untuk mengunduh dan mencetaknya
+tanpa biaya apa pun. Namun, kami akan sangat menghargai jika Anda dapat memberikan tautan
+
+ balik ke halaman situs web kami di bookbot.id/buku-anak-gratis pada situs web sekolah Anda
+ agar lebih banyak anak dan orang tua yang dapat menikmati dan belajar dari buku-buku gratis
+
+ yang kami sediakan.
+
+Kami percaya bahwa dengan menciptakan pustaka buku-buku fonik dengan jumlah koleksi yang
+ besar akan menginspirasi dan menumbuhkan kepercayaan diri anak dalam proses mereka
+ belajar membaca. Anda dapat membaca lebih banyak tentang misi kami di bookbot.id.
+
+Diadaptasi dari “Transportasi” oleh Bookbot dan Anda bebas untuk menyalin dan mendistribusikan
+Bookbot. Dilisensikan di bawah CC BY-NC 4.0. materi dalam bentuk dan format apa pun, asalkan
+ Anda mematuhi syarat-syarat lisensi yang
+Teks telah diubah dan disesuaikan untuk memenuhi ditetapkan dalam CC BY-NC 4.0.
+kebutuhan pembaca dengan berbagai tingkat
+kemampuan membaca, juga membantu dalam Harap berikan atribusi (penilaian) yang sesuai
+pengembangan keterampilan membaca mereka. dengan memberikan tautan ke Bookbot.
+Ilustrasi telah dimodifikasi sesuai dengan prasyarat
+khusus yang ditetapkan oleh tingkat bacaan.
+Adaptasi ini belum menerima persetujuan resmi dari
+penulis asli ataupun ilustrator.
+
+ bookbot.id
+ Copyright © 2024 by Bookbot
+
diff --git a/assets/images/book-bhs-indo.png b/assets/images/book-bhs-indo.png
new file mode 100644
index 0000000..d60105d
Binary files /dev/null and b/assets/images/book-bhs-indo.png differ
diff --git a/assets/images/book-matematika.png b/assets/images/book-matematika.png
new file mode 100644
index 0000000..078e8e2
Binary files /dev/null and b/assets/images/book-matematika.png differ
diff --git a/firebase.json b/firebase.json
new file mode 100644
index 0000000..7990f77
--- /dev/null
+++ b/firebase.json
@@ -0,0 +1 @@
+{"flutter":{"platforms":{"android":{"default":{"projectId":"sokrates-8ba0f","appId":"1:129883258098:android:f7ca2cb3e3530f644aa46b","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"sokrates-8ba0f","configurations":{"android":"1:129883258098:android:f7ca2cb3e3530f644aa46b","ios":"1:129883258098:ios:26bd125fcc33acf94aa46b"}}}}}}
\ No newline at end of file
diff --git a/firestore.rules b/firestore.rules
new file mode 100644
index 0000000..34ab4bd
--- /dev/null
+++ b/firestore.rules
@@ -0,0 +1,30 @@
+rules_version = '2';
+
+service cloud.firestore {
+ match /databases/{database}/documents {
+
+ match /childrens/{userId} {
+ allow read, write: if request.auth != null
+ && request.auth.uid == userId;
+
+ match /{document=**} {
+ allow read, write: if request.auth != null
+ && request.auth.uid == userId;
+ }
+ }
+
+ match /books/{bookId} {
+ allow read, write: if request.auth != null;
+ }
+
+ match /flashcards/{flashcardId} {
+ allow read, write: if request.auth != null;
+ }
+
+ match /metadata/{docId} {
+ allow read: if request.auth != null;
+ allow write: if true;
+ }
+
+ }
+}
diff --git a/improve_quiz.md b/improve_quiz.md
new file mode 100644
index 0000000..c4f81b4
--- /dev/null
+++ b/improve_quiz.md
@@ -0,0 +1,398 @@
+Explore quiz models and mappers
+
+Searched for files matching `**/QuizChoice*`, no matches
+
+Searched for files matching `**/QuizFillBlank*`, no matches
+
+Searched for files matching `**/QuizGroup*`, no matches
+
+Searched for files matching `**/quiz*remote*model*`, 6 matches
+
+Searched for files matching `**/quiz*entity*`, 6 matches
+
+Searched for files matching `**/quiz*mapper*`, 7 matches
+
+Searched for files matching `**/gemini*response*`, 1 match
+
+Searched for files matching `**/api_key*`, 1 match
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/quiz/quiz_group_remote_model.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/quiz/quiz_choice_remote_model.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz/quiz_fill_blank_mapper.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz/quiz_group_mapper.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz/quiz_choice_mapper.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/domain/entities/quiz/quiz_choice_entity.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/domain/entities/quiz/quiz_fill_blank_entity.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/domain/entities/quiz/quiz_group_entity.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/core/constants/api_key.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/gemini_response.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/datasources/remotes/quiz_remote_data_source.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz_fill_blank_mapper.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/quiz/quiz_choice_remote_model.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/quiz/quiz_group_remote_model.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/remotes/gemini_response.dart#1-1), lines 1 to 120
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz/quiz_choice_mapper.dart)
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz/quiz_fill_blank_mapper.dart#1-1), lines 1 to 120
+
+Read [](file:///c%3A/dev/flutter-project/epic_story_app/lib/data/models/mappers/quiz/quiz_group_mapper.dart)
+
+## Analisis Fitur Generate Quiz
+
+---
+
+### 🔴 1. CRITICAL
+
+#### 1.1 Tidak Ada Validasi Hasil Quiz Setelah Parsing
+
+**Masalah:** Setelah Gemini mengembalikan JSON dan berhasil di-parse ke `RemoteModel`, tidak ada validasi apakah data quiz-nya **logis dan lengkap**. Semua field nullable, sehingga quiz kosong/rusak bisa lolos sampai ke UI.
+
+**Dampak:**
+- Quiz pilihan ganda tanpa `question`, tanpa `choices`, atau `correctedIndex` di luar range → crash atau UX rusak
+- Fill blank dengan jumlah blank ≠ 4 atau option tanpa `correctBlankId` → quiz tidak bisa diselesaikan
+- Group quiz dengan option mereferensi `correctGroupId` yang tidak ada di `groups` → jawaban selalu salah
+
+**Rekomendasi:** Tambahkan validasi di controller setelah parsing, sebelum quiz dipakai:
+
+```dart
+// Tambahkan di masing-masing _tryToGenerate... method
+
+// Contoh untuk multiple choice:
+Future _tryToGenerateMultipleChoiceQuiz({
+ required String fullText,
+}) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.multipleChoicePrompt(text: fullText),
+ );
+ if (quizJson == null) return null;
+
+ final parsed = jsonDecode(quizJson) as Map;
+ if (parsed.containsKey('error')) return null; // ← dari _normalizeToJsonString
+
+ var quizModel = QuizChoiceRemoteModel.fromJson(parsed);
+
+ // === VALIDASI ===
+ if (quizModel.question == null ||
+ quizModel.choices == null ||
+ quizModel.choices!.length != 4 ||
+ quizModel.correctedIndex == null ||
+ quizModel.correctedIndex! < 0 ||
+ quizModel.correctedIndex! >= quizModel.choices!.length) {
+ EpicLog.debug('_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
+ return null;
+ }
+
+ var quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
+ }
+ return null;
+}
+```
+
+```dart
+// Validasi fill blank:
+if (quizModel.sentence == null ||
+ quizModel.options == null ||
+ quizModel.options!.length != 4 ||
+ quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
+ quizModel.options!.any((o) => o.value == null || o.correctBlankId == null)) {
+ EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
+ return null;
+}
+```
+
+```dart
+// Validasi group quiz:
+final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
+if (quizModel.groups == null ||
+ quizModel.groups!.length != 2 ||
+ quizModel.options == null ||
+ quizModel.options!.length != 8 ||
+ quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
+ EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
+ return null;
+}
+```
+
+---
+
+#### 1.2 `_normalizeToJsonString` Mengembalikan Error JSON yang Di-parse Seolah Valid
+
+**Masalah:** Ketika normalisasi gagal, fungsi mengembalikan:
+```dart
+return json.encode({"error": "invalid_json", "raw": input.trim()});
+```
+Caller (`_tryToGenerateMultipleChoiceQuiz` dll) tidak mengecek apakah JSON ini mengandung `"error"`. `fromJson` tetap jalan — hasilnya model dengan semua field `null`.
+
+**Dampak:** Quiz rusak masuk ke sistem tanpa terdeteksi.
+
+**Rekomendasi:** Return `null` dari `generate()` jika normalisasi gagal:
+
+```dart
+// Di generate(), ganti:
+return _normalizeToJsonString(geminiPayload);
+
+// Menjadi:
+final normalized = _normalizeToJsonString(geminiPayload);
+if (normalized == null) {
+ EpicLog.debug("QuizRemoteDataSource - generate - failed to normalize JSON");
+ return null;
+}
+return normalized;
+```
+
+Dan ubah `_normalizeToJsonString` return `String?`:
+```dart
+String? _normalizeToJsonString(String input) {
+ // ... existing logic ...
+
+ // Pada baris terakhir, ganti return error JSON menjadi:
+ EpicLog.debug("_normalizeToJsonString - failed to parse: ${input.substring(0, input.length.clamp(0, 200))}");
+ return null;
+}
+```
+
+---
+
+### 🟡 2. IMPORTANT
+
+#### 2.1 Handling Error 429 (Too Many Requests) Tidak Ada
+
+**Masalah:** Kode hanya handle `503 (serviceUnavailable)` untuk model switching. Status `429 (tooManyRequests)` — yang jauh lebih umum pada Gemini pay-as-you-go — **langsung return null** tanpa retry atau fallback.
+
+**Dampak:** Saat rate limited, quiz gagal generate padahal model lain mungkin masih available.
+
+**Rekomendasi:**
+
+```dart
+final bool isHighDemand =
+ response.statusCode == HttpStatus.serviceUnavailable;
+final bool isRateLimited =
+ response.statusCode == HttpStatus.tooManyRequests; // 429
+final bool canSwitchModel = attempt < maxModelSwitches &&
+ attempt + 1 < modelFallbackOrder.length;
+
+if ((isHighDemand || isRateLimited) && canSwitchModel) {
+ EpicLog.debug(
+ "QuizRemoteDataSource - generate - model $modelName unavailable (${response.statusCode}), switching model");
+ continue;
+}
+```
+
+---
+
+#### 2.2 Model Fallback List Berisi Model yang Mungkin Tidak Ada
+
+**Masalah:** `gemini-flash-lite-latest` dan `gemini-3.1-flash-lite-preview` bukan nama model resmi Gemini. Kalau model tidak exist, API return `404` → langsung `return null`, padahal model berikutnya mungkin valid.
+
+**Dampak:** Fallback chain berhenti di model invalid, model berikutnya tidak dicoba.
+
+**Rekomendasi:** Tambahkan handling `404` untuk skip ke model berikutnya, dan gunakan model name yang valid:
+
+```dart
+const List modelFallbackOrder = [
+ 'gemini-2.5-flash',
+ 'gemini-2.5-flash-lite',
+ 'gemini-2.0-flash-lite', // model yang valid
+ 'gemini-1.5-flash', // stable fallback
+];
+
+// Di loop, tambahkan:
+final bool isModelNotFound = response.statusCode == HttpStatus.notFound;
+
+if ((isHighDemand || isRateLimited || isModelNotFound) && canSwitchModel) {
+ EpicLog.debug(
+ "QuizRemoteDataSource - generate - model $modelName error (${response.statusCode}), switching");
+ continue;
+}
+```
+
+---
+
+#### 2.3 Prompt Terlalu Boros — Khususnya Fill Blank dan Group
+
+**Masalah:** Prompt fill blank = ~700 kata, group = ~600 kata. Banyak aturan yang **redundan** atau bisa disederhanakan. Pada pay-as-you-go, input tokens menambah biaya.
+
+**Dampak:** Biaya API lebih tinggi per request. Semakin panjang prompt, semakin banyak input token yang dihitung.
+
+**Rekomendasi — contoh fill blank yang lebih ringkas (~40% lebih pendek):**
+
+```dart
+static String fillBlankPrompt({required String text}) {
+ return """
+Materi:
+"$text"
+
+Buat 1 soal isian rumpang. Kalimat harus mandiri tanpa merujuk materi. Gunakan gaya buku pelajaran anak.
+
+Aturan:
+- Tepat 4 blank (b1-b4), jawaban 1-2 kata, unik, kata penting saja.
+- Tepat 4 options (o1-o4), tiap option punya correctBlankId yang cocok.
+- Sertakan explanation. Output JSON saja, tanpa teks lain.
+
+Format:
+{
+ "quizType": 2,
+ "sentence": [
+ {"type": "text", "value": "..."},
+ {"type": "blank", "id": "b1"},
+ ...
+ ],
+ "options": [
+ {"id": "o1", "value": "...", "correctBlankId": "b1"},
+ ...
+ ],
+ "explanation": "..."
+}
+""";
+}
+```
+
+---
+
+#### 2.4 `responseMimeType` Tidak Diset → Gemini Bisa Wrap Output dengan Markdown
+
+**Masalah:** Tanpa `responseMimeType: "application/json"` di `generationConfig`, Gemini sering membungkus JSON dengan ` ```json ... ``` `. Makanya `_normalizeToJsonString` perlu strip code fence.
+
+**Dampak:** Extra processing yang tidak perlu + risiko gagal parse jika format fence berubah.
+
+**Rekomendasi:** Tambahkan `responseMimeType` agar Gemini langsung return raw JSON:
+
+```dart
+"generationConfig": {
+ "temperature": 1,
+ "topK": 0,
+ "topP": 0.95,
+ "maxOutputTokens": 8192,
+ "responseMimeType": "application/json", // ← tambah ini
+ "stopSequences": []
+},
+```
+
+> Note: Ini didukung di model `gemini-1.5-flash` ke atas. Dengan ini, `_normalizeToJsonString` masih jadi safety net tapi seharusnya jarang dibutuhkan.
+
+---
+
+#### 2.5 Dio Instance Dibuat Ulang Setiap `generate()` Call
+
+**Masalah:** `final Dio dio = Dio(...)` ada di dalam method. Setiap generate quiz dipanggil (bisa 3-5x per flashcard set), Dio instance baru dibuat.
+
+**Dampak:** Minor memory overhead, tapi lebih penting: tidak bisa share connection pool atau interceptor.
+
+**Rekomendasi:** Jadikan field di class:
+
+```dart
+class QuizRemoteDataSource {
+ final FirebaseService fireService = Get.find();
+
+ final Dio _dio = Dio(
+ BaseOptions(
+ connectTimeout: const Duration(seconds: 10),
+ sendTimeout: const Duration(seconds: 12),
+ receiveTimeout: const Duration(seconds: 18),
+ ),
+ );
+
+ // ... gunakan _dio di generate()
+```
+
+---
+
+### 🟢 3. OPTIONAL IMPROVEMENT
+
+#### 3.1 `generationConfig.temperature: 1` Terlalu Tinggi untuk Structured Output
+
+**Masalah:** Temperature 1 mendorong kreativitas tinggi. Untuk structured JSON output, ini meningkatkan kemungkinan format yang menyimpang dari template.
+
+**Dampak:** Kadang Gemini bisa menghasilkan JSON yang creative tapi malah salah struktur.
+
+**Rekomendasi:** Turunkan ke `0.7` atau `0.8`:
+```dart
+"temperature": 0.7,
+```
+
+---
+
+#### 3.2 `finishReason` Tidak Dicek
+
+**Masalah:** Gemini bisa return status 200 tapi dengan `finishReason: "SAFETY"` atau `"MAX_TOKENS"` — artinya output terpotong atau diblokir. Saat ini kode langsung ambil `text` tanpa cek.
+
+**Dampak:** JSON terpotong → parse gagal atau data tidak lengkap.
+
+**Rekomendasi:**
+```dart
+if (response.statusCode == HttpStatus.ok) {
+ GeminiResponse geminiResponse = GeminiResponse.fromJson(payload);
+
+ final finishReason = geminiResponse.candidates?[0].finishReason;
+ if (finishReason != null && finishReason != 'STOP') {
+ EpicLog.debug(
+ "QuizRemoteDataSource - generate - unexpected finishReason: $finishReason");
+ // bisa return null atau tetap coba parse
+ }
+
+ var geminiPayload = geminiResponse.candidates?[0].content?.parts?[0].text ?? '{}';
+ // ...
+}
+```
+
+---
+
+#### 3.3 Prompt Tidak Menentukan Bahasa Response
+
+**Masalah:** Prompt ditulis dalam Bahasa Indonesia tapi tidak eksplisit minta response dalam bahasa tertentu. Gemini kadang campur bahasa.
+
+**Rekomendasi:** Tambahkan di akhir setiap prompt:
+```
+- Gunakan Bahasa Indonesia untuk semua konten soal.
+```
+
+---
+
+#### 3.4 Duplikat Mapper File
+
+**Masalah:** Ada file quiz_fill_blank_mapper.dart di `mappers/` DAN `mappers/quiz/` — kemungkinan leftover.
+
+**Rekomendasi:** Hapus duplikat jika tidak ada yang mengimport-nya.
+
+---
+
+### Ringkasan Prioritas
+
+| # | Kategori | Item | Effort |
+|---|---|---|---|
+| 1.1 | 🔴 Critical | Validasi struktur quiz setelah parse | Medium |
+| 1.2 | 🔴 Critical | `_normalizeToJsonString` return `null` bukan error JSON | Kecil |
+| 2.1 | 🟡 Important | Handle 429 (rate limit) | Kecil |
+| 2.2 | 🟡 Important | Validasi/fix model name + handle 404 | Kecil |
+| 2.3 | 🟡 Important | Prompt lebih ringkas | Medium |
+| 2.4 | 🟡 Important | Set `responseMimeType: "application/json"` | Kecil |
+| 2.5 | 🟡 Important | Dio instance jadi class field | Kecil |
+| 3.1 | 🟢 Optional | Turunkan temperature | Kecil |
+| 3.2 | 🟢 Optional | Cek `finishReason` | Kecil |
+| 3.3 | 🟢 Optional | Eksplisit bahasa di prompt | Kecil |
+| 3.4 | 🟢 Optional | Hapus duplikat mapper | Kecil |
\ No newline at end of file
diff --git a/ios/.gitignore b/ios/.gitignore
new file mode 100644
index 0000000..7a7f987
--- /dev/null
+++ b/ios/.gitignore
@@ -0,0 +1,34 @@
+**/dgph
+*.mode1v3
+*.mode2v3
+*.moved-aside
+*.pbxuser
+*.perspectivev3
+**/*sync/
+.sconsign.dblite
+.tags*
+**/.vagrant/
+**/DerivedData/
+Icon?
+**/Pods/
+**/.symlinks/
+profile
+xcuserdata
+**/.generated/
+Flutter/App.framework
+Flutter/Flutter.framework
+Flutter/Flutter.podspec
+Flutter/Generated.xcconfig
+Flutter/ephemeral/
+Flutter/app.flx
+Flutter/app.zip
+Flutter/flutter_assets/
+Flutter/flutter_export_environment.sh
+ServiceDefinitions.json
+Runner/GeneratedPluginRegistrant.*
+
+# Exceptions to above rules.
+!default.mode1v3
+!default.mode2v3
+!default.pbxuser
+!default.perspectivev3
diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 0000000..1dc6cf7
--- /dev/null
+++ b/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,26 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+ MinimumOSVersion
+ 13.0
+
+
diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig
new file mode 100644
index 0000000..592ceee
--- /dev/null
+++ b/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..ce9100b
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,616 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = org.haqiachd.epicstoryapp.epicStoryApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = org.haqiachd.epicstoryapp.epicStoryApp.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = org.haqiachd.epicstoryapp.epicStoryApp.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = org.haqiachd.epicstoryapp.epicStoryApp.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = org.haqiachd.epicstoryapp.epicStoryApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = org.haqiachd.epicstoryapp.epicStoryApp;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 0000000..e3773d4
--- /dev/null
+++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..1d526a1
--- /dev/null
+++ b/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 0000000..18d9810
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 0000000..f9b0d7c
--- /dev/null
+++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift
new file mode 100644
index 0000000..6266644
--- /dev/null
+++ b/ios/Runner/AppDelegate.swift
@@ -0,0 +1,13 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ GeneratedPluginRegistrant.register(with: self)
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..d36b1fa
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 0000000..dc9ada4
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 0000000..7353c41
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 0000000..6ed2d93
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 0000000..4cd7b00
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 0000000..fe73094
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 0000000..321773c
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 0000000..797d452
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 0000000..502f463
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 0000000..0ec3034
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 0000000..e9f5fea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 0000000..84ac32a
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 0000000..8953cba
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 0000000..0467bf1
Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 0000000..0bedcf2
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 0000000..9da19ea
Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 0000000..89c2725
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 0000000..f2e259c
--- /dev/null
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 0000000..f3c2851
--- /dev/null
+++ b/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
new file mode 100644
index 0000000..505fd5e
--- /dev/null
+++ b/ios/Runner/Info.plist
@@ -0,0 +1,53 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Epic Story App
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ epic_story_app
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ CADisableMinimumFrameDurationOnPhone
+
+ UIApplicationSupportsIndirectInputEvents
+
+ NSPhotoLibraryAddUsageDescription
+ Aplikasi membutuhkan akses untuk menyimpan gambar reward ke galeri.
+ NSPhotoLibraryUsageDescription
+ Aplikasi membutuhkan akses galeri untuk fitur penyimpanan reward.
+
+
diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 0000000..308a2a5
--- /dev/null
+++ b/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 0000000..86a7c3b
--- /dev/null
+++ b/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/lib/feature/ai_tutors/presentation/ai_tutor_binding.dart b/lib/feature/ai_tutors/presentation/ai_tutor_binding.dart
new file mode 100644
index 0000000..4233db3
--- /dev/null
+++ b/lib/feature/ai_tutors/presentation/ai_tutor_binding.dart
@@ -0,0 +1,9 @@
+import 'package:epic_story_app/feature/ai_tutors/presentation/ai_tutor_controller.dart';
+import 'package:get/get.dart';
+
+class AiTutorBinding extends Bindings {
+ @override
+ void dependencies() {
+ Get.lazyPut(() => AiTutorController());
+ }
+}
diff --git a/lib/feature/ai_tutors/presentation/ai_tutor_controller.dart b/lib/feature/ai_tutors/presentation/ai_tutor_controller.dart
new file mode 100644
index 0000000..335b54a
--- /dev/null
+++ b/lib/feature/ai_tutors/presentation/ai_tutor_controller.dart
@@ -0,0 +1,5 @@
+import 'package:get/get.dart';
+
+class AiTutorController extends GetxController{
+ //
+}
\ No newline at end of file
diff --git a/lib/feature/ai_tutors/presentation/ai_tutor_page.dart b/lib/feature/ai_tutors/presentation/ai_tutor_page.dart
new file mode 100644
index 0000000..5c43844
--- /dev/null
+++ b/lib/feature/ai_tutors/presentation/ai_tutor_page.dart
@@ -0,0 +1,24 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:epic_story_app/feature/ai_tutors/presentation/ai_tutor_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class AiTutorPage extends StatelessWidget {
+ const AiTutorPage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+
+ final AiTutorController controller = Get.find();
+
+ return Scaffold(
+ backgroundColor: Colors.purple.withOpacity(0.4),
+ body: Center(
+ child: Text(
+ "AI Tutor Home",
+ style: EpicSize.headlineLarge,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/auth/choose_class/choose_class_binding.dart b/lib/feature/auth/choose_class/choose_class_binding.dart
new file mode 100644
index 0000000..3ba27cf
--- /dev/null
+++ b/lib/feature/auth/choose_class/choose_class_binding.dart
@@ -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(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/auth/choose_class/choose_class_controller.dart b/lib/feature/auth/choose_class/choose_class_controller.dart
new file mode 100644
index 0000000..1eefe60
--- /dev/null
+++ b/lib/feature/auth/choose_class/choose_class_controller.dart
@@ -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;
+ }
+}
diff --git a/lib/feature/auth/choose_class/choose_class_page.dart b/lib/feature/auth/choose_class/choose_class_page.dart
new file mode 100644
index 0000000..8382fd1
--- /dev/null
+++ b/lib/feature/auth/choose_class/choose_class_page.dart
@@ -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();
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/auth/choose_date_birth/choose_date_birth_binding.dart b/lib/feature/auth/choose_date_birth/choose_date_birth_binding.dart
new file mode 100644
index 0000000..4b98eb8
--- /dev/null
+++ b/lib/feature/auth/choose_date_birth/choose_date_birth_binding.dart
@@ -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());
+ }
+}
diff --git a/lib/feature/auth/choose_date_birth/choose_date_birth_controller.dart b/lib/feature/auth/choose_date_birth/choose_date_birth_controller.dart
new file mode 100644
index 0000000..db6a045
--- /dev/null
+++ b/lib/feature/auth/choose_date_birth/choose_date_birth_controller.dart
@@ -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,
+ );
+ }
+}
diff --git a/lib/feature/auth/choose_date_birth/choose_date_birth_page.dart b/lib/feature/auth/choose_date_birth/choose_date_birth_page.dart
new file mode 100644
index 0000000..0c4fd91
--- /dev/null
+++ b/lib/feature/auth/choose_date_birth/choose_date_birth_page.dart
@@ -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();
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/auth/choose_gender/choose_gender_binding.dart b/lib/feature/auth/choose_gender/choose_gender_binding.dart
new file mode 100644
index 0000000..184d57b
--- /dev/null
+++ b/lib/feature/auth/choose_gender/choose_gender_binding.dart
@@ -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());
+ }
+}
\ No newline at end of file
diff --git a/lib/feature/auth/choose_gender/choose_gender_controller.dart b/lib/feature/auth/choose_gender/choose_gender_controller.dart
new file mode 100644
index 0000000..68f80f9
--- /dev/null
+++ b/lib/feature/auth/choose_gender/choose_gender_controller.dart
@@ -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,
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/lib/feature/auth/choose_gender/choose_gender_page.dart b/lib/feature/auth/choose_gender/choose_gender_page.dart
new file mode 100644
index 0000000..8fb1ace
--- /dev/null
+++ b/lib/feature/auth/choose_gender/choose_gender_page.dart
@@ -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();
+ },
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_histories/book_histories_binding.dart b/lib/feature/books/presentation/book_histories/book_histories_binding.dart
new file mode 100644
index 0000000..0242f25
--- /dev/null
+++ b/lib/feature/books/presentation/book_histories/book_histories_binding.dart
@@ -0,0 +1,12 @@
+import 'package:get/get.dart';
+
+import 'book_histories_controller.dart';
+
+class BookHistoriesBinding extends Bindings {
+ @override
+ void dependencies() {
+ Get.lazyPut(
+ () => BookHistoriesController(),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/feature/books/presentation/book_histories/book_histories_controller.dart b/lib/feature/books/presentation/book_histories/book_histories_controller.dart
new file mode 100644
index 0000000..acff7a4
--- /dev/null
+++ b/lib/feature/books/presentation/book_histories/book_histories_controller.dart
@@ -0,0 +1,38 @@
+import 'package:epic_story_app/feature/books/presentation/book_histories/state/book_histories_state.dart';
+import 'package:get/get.dart';
+
+import '../../../../domain/entities/book_histories_entity.dart';
+import '../book_home/book_home_controller.dart';
+
+class BookHistoriesController extends GetxController {
+ final bookHomeController = Get.find();
+
+ late BookHistoriesState selectedState;
+ var histories = [].obs;
+
+ void openFromHistory(BookHistoryEntity history) {
+ bookHomeController.gotoBookReadFromHistory(history);
+ }
+
+ var title = ''.obs;
+
+ @override
+ void onInit() {
+ super.onInit();
+ selectedState = Get.arguments;
+ switch (selectedState) {
+ case BookHistoriesState.allHistories:
+ histories.value = bookHomeController.histories;
+ title.value = 'Semua Riwayat';
+ break;
+ case BookHistoriesState.learnHistories:
+ histories.value = bookHomeController.uncompletedHistories;
+ title.value = 'Buku yang belum kamu selesaikan';
+ break;
+ case BookHistoriesState.quizHistories:
+ histories.value = bookHomeController.completedHistories;
+ title.value = 'Kuis yang belum kamu kerjakan';
+ break;
+ }
+ }
+}
diff --git a/lib/feature/books/presentation/book_histories/book_histories_page.dart b/lib/feature/books/presentation/book_histories/book_histories_page.dart
new file mode 100644
index 0000000..34902e6
--- /dev/null
+++ b/lib/feature/books/presentation/book_histories/book_histories_page.dart
@@ -0,0 +1,249 @@
+import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
+import 'package:epic_story_app/domain/entities/book_entity.dart';
+import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import 'book_histories_controller.dart';
+
+class BookHistoriesPage extends GetView {
+ const BookHistoriesPage({super.key});
+
+ static const Color _mainBackground = Color(0xFFE0CEFF);
+
+ static const List _gradientCycle = [
+ Color(0xFFFFDAA0),
+ Color(0xFFAAFFC0),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ final histories = controller.histories;
+ final selectedCategory =
+ controller.bookHomeController.selectedCategory.value;
+ final activeCategory =
+ selectedCategory.isEmpty ? 'Semua' : selectedCategory;
+ final bookById = {
+ for (final book in controller.bookHomeController.books)
+ if (book.bookId != null) book.bookId!: book,
+ };
+
+ return Scaffold(
+ backgroundColor: _mainBackground,
+ body: SafeArea(
+ bottom: false,
+ child: Column(
+ children: [
+ StackedInfoTopBar(
+ titleText: 'Riwayat Buku',
+ infoText: 'Mata Pelajran :\n$activeCategory',
+ isBackButtonVisible: true,
+ onBackTap: () => Get.back(),
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: histories.isEmpty
+ ? const Center(
+ child: Text(
+ 'Belum ada riwayat buku.',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF2B2B2B),
+ ),
+ ),
+ )
+ : ListView.separated(
+ padding: EdgeInsets.zero,
+ itemCount: histories.length,
+ separatorBuilder: (_, __) =>
+ const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ final history = histories[index];
+ final linkedBook = history.bookId == null
+ ? null
+ : bookById[history.bookId!];
+
+ return _HistoryItem(
+ item: history,
+ index: index,
+ linkedBook: linkedBook,
+ gradientCycle: _gradientCycle,
+ onTap: () => controller.openFromHistory(history),
+ );
+ },
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ });
+ }
+}
+
+class _HistoryItem extends StatelessWidget {
+ const _HistoryItem({
+ required this.item,
+ required this.index,
+ required this.linkedBook,
+ required this.gradientCycle,
+ required this.onTap,
+ });
+
+ final BookHistoryEntity item;
+ final int index;
+ final BookEntity? linkedBook;
+ final List gradientCycle;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ gradientCycle[index % gradientCycle.length],
+ const Color(0xFFFFFFFF),
+ ],
+ stops: const [0.0, 1.0],
+ ),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: Colors.white, width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.16),
+ blurRadius: 12,
+ offset: const Offset(0, 5),
+ ),
+ ],
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildBookImage(
+ coverUrl: item.bookCover,
+ category: linkedBook?.category ?? item.category,
+ width: 144,
+ height: 164,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ 'Category : ${item.category ?? linkedBook?.category ?? 'Kategori buku'}',
+ style: const TextStyle(
+ fontSize: 12,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Total Halaman : ${item.totalPages ?? 0}',
+ style: const TextStyle(
+ fontSize: 12,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ linkedBook?.summary ?? '-',
+ maxLines: 4,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 14,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Image.asset(
+ (item.isFinished ?? false)
+ ? 'assets/images/ic-book-selesai.png'
+ : 'assets/images/ic-book-belum-selesai.png',
+ width: 78,
+ fit: BoxFit.contain,
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildBookImage({
+ required String? coverUrl,
+ required String? category,
+ required double width,
+ required double height,
+ }) {
+ final fallbackAsset = _resolveFallbackBookAsset(category, index);
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: SizedBox(
+ width: width,
+ height: height,
+ child: coverUrl != null && coverUrl.trim().isNotEmpty
+ ? Image.network(
+ coverUrl,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => Image.asset(
+ fallbackAsset,
+ fit: BoxFit.cover,
+ ),
+ )
+ : Image.asset(
+ fallbackAsset,
+ fit: BoxFit.cover,
+ ),
+ ),
+ );
+ }
+
+ String _resolveFallbackBookAsset(String? category, int index) {
+ final normalized = (category ?? '').toLowerCase();
+
+ if (normalized.contains('matematika') || normalized.contains('ipa')) {
+ 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-matematika.png'
+ : 'assets/images/book-bhs-indo.png';
+ }
+}
diff --git a/lib/feature/books/presentation/book_histories/state/book_histories_state.dart b/lib/feature/books/presentation/book_histories/state/book_histories_state.dart
new file mode 100644
index 0000000..5becee3
--- /dev/null
+++ b/lib/feature/books/presentation/book_histories/state/book_histories_state.dart
@@ -0,0 +1,5 @@
+enum BookHistoriesState {
+ allHistories,
+ learnHistories,
+ quizHistories,
+}
\ No newline at end of file
diff --git a/lib/feature/books/presentation/book_home/book_home_binding.dart b/lib/feature/books/presentation/book_home/book_home_binding.dart
new file mode 100644
index 0000000..6e393c8
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/book_home_binding.dart
@@ -0,0 +1,21 @@
+import 'package:epic_story_app/data/modules/book_module.dart';
+import 'package:epic_story_app/data/modules/collection_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/feature/books/presentation/book_home/book_home_controller.dart';
+import 'package:get/get.dart';
+
+class BookHomeBinding extends Bindings {
+ @override
+ void dependencies() {
+ BookModule();
+ CollectionModule();
+ CollectionModule();
+ Get.lazyPut(() => BookHomeController(
+ collectionUsecase: Get.find(),
+ bookUsecase: Get.find(),
+ childrenUsecase: Get.find(),
+ ));
+ }
+}
diff --git a/lib/feature/books/presentation/book_home/book_home_controller.dart b/lib/feature/books/presentation/book_home/book_home_controller.dart
new file mode 100644
index 0000000..5901080
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/book_home_controller.dart
@@ -0,0 +1,189 @@
+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/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/feature/books/presentation/book_histories/state/book_histories_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:get/get.dart';
+
+class BookHomeController extends GetxController {
+ final BookUsecase bookUsecase;
+ final CollectionUsecase collectionUsecase;
+ final ChildrenUsecase childrenUsecase;
+
+ final MainController mainController = Get.find();
+ var histories = [].obs;
+
+ BookHomeController({
+ required this.bookUsecase,
+ required this.collectionUsecase,
+ required this.childrenUsecase,
+ });
+
+ final categories = [].obs;
+ final selectedCategory = 'Semua'.obs;
+
+ var isLoading = false.obs;
+ final RxList books = [].obs;
+
+ final navController = Get.find();
+
+ List get filteredBooks {
+ if (selectedCategory.value == 'Semua') return books;
+ return books
+ .where((book) => (book.category ?? 'Lainnya') == selectedCategory.value)
+ .toList();
+ }
+
+ List get uncompletedHistories {
+ return histories.where((h) {
+ final total = (h.totalPages ?? 0)-2;
+ final pagesRead = h.readPages ?? 0;
+ return pagesRead < total;
+ }).toList();
+ }
+
+ List get completedHistories {
+ return histories.where((h) {
+ final total = (h.totalPages ?? 0)-2;
+ final pagesRead = h.readPages ?? 0;
+ final isFinished = h.isFinished ?? false;
+ return pagesRead == total && !isFinished;
+ }).toList();
+ }
+
+ List get filteredHistory {
+ if (selectedCategory.value == 'Semua') return histories;
+ return histories
+ .where((history) =>
+ (history.category ?? 'Lainnya') == selectedCategory.value)
+ .toList();
+ }
+
+ @override
+ void onInit() async {
+ super.onInit();
+ ever(navController.selectedCategory, (value) {
+ selectedCategory.value = value.isEmpty ? 'Semua' : value;
+ });
+ // await loadBooks();
+ }
+
+ Future loadBooks() async {
+ try {
+ isLoading.value = true;
+ final bookData = await bookUsecase.getBooks(limit: 20);
+ final bookHistories = await childrenUsecase.getBookHistories();
+ books.clear();
+ books.addAll(bookData.books);
+ histories.clear();
+ histories.addAll(bookHistories);
+ sortedHistories();
+ _buildCategories();
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'loadBooks');
+ } finally {
+ isLoading.value = false;
+ EpicLog.debug("homeController - Finished fetching books : $isLoading");
+ }
+ }
+
+ void sortedHistories() {
+ histories.sort((a, b) =>
+ b.lastReadAt?.compareTo(
+ a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ??
+ 0);
+ }
+
+ void _buildCategories() {
+ final uniqueCategories = {
+ 'Semua',
+ ...books.map((e) => e.category ?? 'Lainnya'),
+ };
+
+ categories.assignAll(uniqueCategories.toList());
+
+ if (categories.isNotEmpty) {
+ final current = navController.selectedCategory.value;
+ selectedCategory.value = current.isNotEmpty ? current : categories.first;
+ }
+ }
+
+ Future addBookToCollection({
+ required String collectionId,
+ required String bookId,
+ }) async {
+ try {
+ mainController.showLoadingPage();
+ await collectionUsecase.addBookToCollection(
+ collectionId: collectionId,
+ bookId: bookId,
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'addBookToCollection');
+ } finally {
+ mainController.hideLoadingPage();
+ }
+ }
+
+ Future reactBook({
+ required String bookId,
+ required String emoticon,
+ bool isIncrement = true,
+ }) async {
+ try {
+ mainController.showLoadingPage();
+ await mainController.childrenUsecase.reactBook(
+ bookId: bookId,
+ emoticon: emoticon,
+ isIncrement: isIncrement,
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'reactBook');
+ } finally {
+ mainController.hideLoadingPage();
+ }
+ }
+
+ void goToBookRead(BookEntity book) async {
+ await Get.toNamed(
+ EpicRoutes.bookRead,
+ arguments: book,
+ );
+ final updatedHistories = await childrenUsecase.getBookHistories(
+ refreshLocal: true,
+ );
+ histories.clear();
+ histories.addAll(updatedHistories);
+ sortedHistories();
+ _buildCategories();
+ }
+
+ void gotoBookReadFromHistory(BookHistoryEntity history) async {
+ final book = books.firstWhereOrNull((b) => b.bookId == history.bookId);
+ if (book == null) {
+ EpicLog.debug(
+ 'Book with ID ${history.bookId} not found for history ${history.bookId}');
+ return;
+ }
+ await Get.toNamed(
+ EpicRoutes.bookRead,
+ arguments: book,
+ );
+ final updatedHistories = await childrenUsecase.getBookHistories(
+ refreshLocal: true,
+ );
+ histories.clear();
+ histories.addAll(updatedHistories);
+ sortedHistories();
+ _buildCategories();
+ }
+
+ void goToBookHistories(BookHistoriesState state) {
+ Get.toNamed(EpicRoutes.bookHistories, arguments: state);
+ }
+}
diff --git a/lib/feature/books/presentation/book_home/book_home_page.dart b/lib/feature/books/presentation/book_home/book_home_page.dart
new file mode 100644
index 0000000..043a9c8
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/book_home_page.dart
@@ -0,0 +1,525 @@
+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/books/presentation/book_histories/state/book_histories_state.dart';
+import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class BookHomePage extends StatelessWidget {
+ const BookHomePage({super.key});
+
+ static const Color _mainBackground = Color(0xFFE0CEFF);
+ static const double _historyCardWidth = 280;
+ static const double _historyCardHeight = 152;
+ static const List _gradientCycle = [
+ Color(0xFFFFDAA0),
+ Color(0xFFAAFFC0),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final BookHomeController controller = Get.find();
+
+ return Scaffold(
+ backgroundColor: _mainBackground,
+ body: Obx(() {
+ final bookById = {
+ for (final book in controller.books)
+ if (book.bookId != null) book.bookId!: book,
+ };
+
+ if (controller.isLoading.value) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final histories = controller.filteredHistory;
+ final latestHistories = histories.take(5).toList();
+ final items = controller.filteredBooks;
+ final learnCount = controller.uncompletedHistories.length;
+ final quizCount = controller.completedHistories.length;
+
+ return SingleChildScrollView(
+ padding: EdgeInsets.only(left: 10, top: 6),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildSectionTitle('Riwayat Buku'),
+ const SizedBox(height: 12),
+ if (histories.isEmpty)
+ _buildEmptyState(
+ 'Belum ada riwayat baca di kategori "${controller.selectedCategory.value}"',
+ )
+ else
+ SizedBox(
+ height: _historyCardHeight,
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: latestHistories.length + 1,
+ separatorBuilder: (_, __) => const SizedBox(width: 12),
+ itemBuilder: (context, index) {
+ if (index == latestHistories.length) {
+ return _buildSeeAllHistoriesButton(
+ index: index,
+ onTap: () => controller.goToBookHistories(
+ BookHistoriesState.allHistories,
+ ),
+ );
+ }
+
+ final history = latestHistories[index];
+ final linkedBook = history.bookId == null
+ ? null
+ : bookById[history.bookId!];
+
+ return _buildHistoryCard(
+ history,
+ linkedBook,
+ index,
+ onTap: () {
+ controller.gotoBookReadFromHistory(history);
+ },
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 24),
+ _buildActionButtons(
+ learnCount: learnCount,
+ quizCount: quizCount,
+ onTapLearn: () {
+ controller
+ .goToBookHistories(BookHistoriesState.learnHistories);
+ },
+ onTapQuiz: () {
+ controller
+ .goToBookHistories(BookHistoriesState.quizHistories);
+ },
+ ),
+ const SizedBox(height: 24),
+ _buildSectionTitle('Buku yang dapat kamu pelajari'),
+ const SizedBox(height: 10),
+ if (items.isEmpty)
+ _buildEmptyState(
+ 'Belum ada buku di kategori "${controller.selectedCategory.value}"',
+ )
+ else
+ ListView.separated(
+ itemCount: items.length,
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ separatorBuilder: (_, __) => const SizedBox(height: 12),
+ itemBuilder: (context, index) {
+ return _buildBookLearningCard(
+ items[index],
+ index,
+ onTap: () {
+ controller.goToBookRead(items[index]);
+ },
+ );
+ },
+ ),
+ SizedBox(height: 24),
+ ],
+ ),
+ );
+ }),
+ );
+ }
+
+ Widget _buildSectionTitle(String title) {
+ return Text(
+ title,
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ letterSpacing: 0.2,
+ ),
+ );
+ }
+
+ Widget _buildHistoryCard(
+ BookHistoryEntity history,
+ BookEntity? linkedBook,
+ int index, {
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: _historyCardWidth,
+ height: _historyCardHeight,
+ padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
+ decoration: _cardDecoration(index),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildBookImage(
+ coverUrl: history.bookCover,
+ category: linkedBook?.category ?? history.category,
+ index: index,
+ width: 96,
+ height: 132,
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ history.title ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ linkedBook?.summary ?? '-',
+ maxLines: 4,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 12,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const Spacer(),
+ _miniPill(
+ icon: Icons.label_outline,
+ label:
+ history.category ?? linkedBook?.category ?? 'Lainnya',
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildSeeAllHistoriesButton({
+ required int index,
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: 80,
+ height: _historyCardHeight,
+ decoration: _cardDecoration(index),
+ child: const Center(
+ child: Icon(
+ Icons.arrow_right_alt_rounded,
+ size: 30,
+ color: Colors.black87,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildActionButtons({
+ required int learnCount,
+ required int quizCount,
+ required VoidCallback onTapLearn,
+ required VoidCallback onTapQuiz,
+ }) {
+ return Row(
+ children: [
+ Expanded(
+ child: _buildActionCapsule(
+ label: 'Belajar=$learnCount',
+ imagePath: 'assets/images/yellow-button.png',
+ onPressed: onTapLearn,
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: _buildActionCapsule(
+ label: 'Kuis=$quizCount',
+ imagePath: 'assets/images/green-button.png',
+ onPressed: onTapQuiz,
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildActionCapsule({
+ required String label,
+ required String imagePath,
+ required VoidCallback onPressed,
+ }) {
+ return GestureDetector(
+ onTap: onPressed,
+ child: SizedBox(
+ height: 60,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Positioned.fill(
+ child: Image.asset(
+ imagePath,
+ fit: BoxFit.fill,
+ ),
+ ),
+ Text(
+ label,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 16,
+ fontWeight: FontWeight.w900,
+ height: 1,
+ shadows: [
+ Shadow(
+ color: Color(0x66000000),
+ offset: Offset(0, 1),
+ blurRadius: 2,
+ )
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildBookLearningCard(
+ BookEntity item,
+ int index, {
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
+ decoration: _cardDecoration(index),
+ child: Column(
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildBookImage(
+ coverUrl: item.bookCover,
+ category: item.category,
+ index: index,
+ width: 144,
+ height: 164,
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'Category : ${item.category ?? '-'}',
+ style: const TextStyle(
+ fontSize: 12,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ 'Total Halaman : ${item.totalPages ?? 0}',
+ style: const TextStyle(
+ fontSize: 12,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ item.summary ?? '-',
+ maxLines: 4,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 14,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 10),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Text(
+ _buildReadLabel(item.reads),
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF232323),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildBookImage({
+ required String? coverUrl,
+ required String? category,
+ required int index,
+ required double width,
+ required double height,
+ }) {
+ final fallbackAsset = _resolveFallbackBookAsset(category, index);
+
+ return ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: SizedBox(
+ width: width,
+ height: height,
+ child: coverUrl != null && coverUrl.trim().isNotEmpty
+ ? Image.network(
+ coverUrl,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => Image.asset(
+ fallbackAsset,
+ fit: BoxFit.cover,
+ ),
+ )
+ : Image.asset(
+ fallbackAsset,
+ fit: BoxFit.cover,
+ ),
+ ),
+ );
+ }
+
+ Widget _miniPill({
+ required IconData icon,
+ required String label,
+ }) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.9),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 13, color: const Color(0xFF151515)),
+ const SizedBox(width: 4),
+ Text(
+ label,
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF151515),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _resolveFallbackBookAsset(String? category, int index) {
+ final normalized = (category ?? '').toLowerCase();
+
+ if (normalized.contains('matematika') || normalized.contains('ipa')) {
+ 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-matematika.png'
+ : 'assets/images/book-bhs-indo.png';
+ }
+
+ String _buildReadLabel(int? reads) {
+ final value = reads ?? 0;
+ if (value >= 1000) {
+ return '${(value / 1000).toStringAsFixed(0)} rb x dibaca';
+ }
+ return '$value x dibaca';
+ }
+
+ BoxDecoration _cardDecoration(int index) => BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ _gradientCycle[index % _gradientCycle.length],
+ const Color(0xFFFFFFFF),
+ ],
+ stops: const [0.0, 1.0],
+ ),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: Colors.white, width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.16),
+ blurRadius: 12,
+ offset: const Offset(0, 5),
+ ),
+ ],
+ );
+
+ Widget _buildEmptyState(String message) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [
+ Color(0xFFAAFFC0),
+ Color(0xFFFFFFFF),
+ ],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: Colors.white, width: 1.2),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.info_outline, color: Color(0xFF151515), size: 18),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ message,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF151515),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_home/components/book_card.dart b/lib/feature/books/presentation/book_home/components/book_card.dart
new file mode 100644
index 0000000..663eeca
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/components/book_card.dart
@@ -0,0 +1,113 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:epic_story_app/domain/entities/book_entity.dart';
+import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
+import 'package:epic_story_app/feature/books/presentation/book_home/components/book_cover.dart';
+import 'package:epic_story_app/feature/books/presentation/book_read/components/dialog_add_book_coll.dart';
+import 'package:flutter/material.dart';
+
+class BookCard extends StatelessWidget {
+ final BookEntity item;
+ final BookHomeController controller;
+
+ const BookCard({super.key, required this.item, required this.controller});
+
+ @override
+ Widget build(BuildContext context) {
+ final book = item;
+
+ return GestureDetector(
+ onTap: () {
+ controller.goToBookRead(book);
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: const Color(0xFF4D4D4D), width: 1),
+ ),
+ padding: EdgeInsets.all(EpicSize.sizeMedium),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: EpicSize.calculatedSize(90),
+ height: EpicSize.calculatedSize(122),
+ child: BookCover(coverUrl: book.bookCover),
+ ),
+ SizedBox(width: EpicSize.sizeSmall),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ book.title ?? 'Judul belum tersedia',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w800,
+ color: Colors.black,
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ SizedBox(height: EpicSize.sizeSuperSmall),
+ Text(
+ 'Category : ${book.category ?? '-'}',
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.black,
+ ),
+ ),
+ SizedBox(height: EpicSize.sizeSuperSmall),
+ Text(
+ 'Total Halaman : ${book.totalPages ?? 0}',
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.black,
+ ),
+ ),
+ SizedBox(height: EpicSize.sizeSuperSmall),
+ Text(
+ book.summary ?? '--',
+ style: TextStyle(
+ color: Colors.black,
+ height: 1.2,
+ fontSize: 12,
+ ),
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: EpicSize.sizeSmall),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.end,
+ children: [
+ Text(
+ _buildReadLabel(book.reads),
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.black,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _buildReadLabel(int? reads) {
+ final value = reads ?? 0;
+ if (value >= 1000) {
+ return '${(value / 1000).toStringAsFixed(0)} rb x dibaca';
+ }
+ return '$value x dibaca';
+ }
+}
diff --git a/lib/feature/books/presentation/book_home/components/book_cover.dart b/lib/feature/books/presentation/book_home/components/book_cover.dart
new file mode 100644
index 0000000..398f949
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/components/book_cover.dart
@@ -0,0 +1,52 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:flutter/material.dart';
+
+class BookCover extends StatelessWidget {
+ final String? coverUrl;
+
+ const BookCover({this.coverUrl});
+
+ @override
+ Widget build(BuildContext context) {
+ final double side = EpicSize.calculatedSize(110);
+
+ return ClipRRect(
+ borderRadius: EpicSize.radiusSmall,
+ child: Container(
+ width: side,
+ height: side,
+ decoration: BoxDecoration(
+ color: Colors.grey.shade100,
+ border: Border.all(color: Colors.black.withOpacity(0.16)),
+ ),
+ child: coverUrl != null
+ ? Image.network(
+ coverUrl!,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => _placeholder(),
+ loadingBuilder: (context, child, progress) {
+ if (progress == null) return child;
+ return _placeholder(isLoading: true);
+ },
+ )
+ : _placeholder(),
+ ),
+ );
+ }
+
+ Widget _placeholder({bool isLoading = false}) {
+ return Center(
+ child: isLoading
+ ? const SizedBox(
+ height: 20,
+ width: 20,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : Text(
+ 'Cover Buku',
+ style: EpicSize.bodyLarge.copyWith(color: Colors.black54),
+ textAlign: TextAlign.center,
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_home/components/book_react_stats.dart b/lib/feature/books/presentation/book_home/components/book_react_stats.dart
new file mode 100644
index 0000000..4263e72
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/components/book_react_stats.dart
@@ -0,0 +1,42 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
+import 'package:flutter/material.dart';
+
+class ReactionStat extends StatelessWidget {
+ final String symbol;
+ final int count;
+ final String symbolName;
+ final BookHomeController bookController;
+ final String bookId;
+
+ const ReactionStat({
+ super.key,
+ required this.symbol,
+ required this.count,
+ required this.symbolName,
+ required this.bookController,
+ required this.bookId,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: () {
+ bookController.reactBook(
+ bookId: bookId,
+ emoticon: symbolName,
+ );
+ },
+ child: Row(
+ children: [
+ Text(
+ symbol,
+ style: TextStyle(fontSize: EpicSize.sizeMediumLarge),
+ ),
+ SizedBox(width: EpicSize.sizeSuperSmall),
+ Text(count.toString(), style: EpicSize.bodyLarge),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_home/components/history_card.dart b/lib/feature/books/presentation/book_home/components/history_card.dart
new file mode 100644
index 0000000..af98551
--- /dev/null
+++ b/lib/feature/books/presentation/book_home/components/history_card.dart
@@ -0,0 +1,106 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:epic_story_app/domain/entities/book_histories_entity.dart';
+import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart';
+import 'package:epic_story_app/feature/books/presentation/book_home/components/book_cover.dart';
+import 'package:flutter/material.dart';
+
+class HistoryCard extends StatelessWidget {
+ final BookHistoryEntity item;
+ final String? category;
+ final String? summary;
+ final BookHomeController controller;
+
+ const HistoryCard({
+ super.key,
+ required this.item,
+ this.category,
+ this.summary,
+ required this.controller,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: () {
+ controller.gotoBookReadFromHistory(item);
+ },
+ child: Container(
+ width: EpicSize.calculatedSize(300),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: const Color(0xFF4D4D4D), width: 1),
+ ),
+ padding: EdgeInsets.all(EpicSize.sizeSmall),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ width: EpicSize.calculatedSize(70),
+ height: EpicSize.calculatedSize(92),
+ child: BookCover(coverUrl: item.bookCover),
+ ),
+ SizedBox(width: EpicSize.sizeSmall),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? 'Judul belum tersedia',
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w800,
+ color: Colors.black,
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ SizedBox(height: EpicSize.sizeSuperSmall),
+ Text(
+ summary ?? '-',
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.black,
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ SizedBox(height: EpicSize.sizeSuperSmall),
+ Text(
+ 'Category : ${category ?? '-'}',
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.black87,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class EmptyHistoryState extends StatelessWidget {
+ final String category;
+
+ const EmptyHistoryState({super.key, required this.category});
+
+ @override
+ Widget build(BuildContext context) {
+ return Center(
+ child: Text(
+ 'Belum ada riwayat baca di kategori "$category"',
+ style: TextStyle(
+ fontSize: 16,
+ color: Colors.black,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/book_quiz_binding.dart b/lib/feature/books/presentation/book_quiz/book_quiz_binding.dart
new file mode 100644
index 0000000..9c23b73
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/book_quiz_binding.dart
@@ -0,0 +1,21 @@
+import 'package:get/get.dart';
+import 'package:epic_story_app/data/modules/children_module.dart';
+import 'package:epic_story_app/data/modules/quiz_module.dart';
+import 'package:epic_story_app/domain/usecases/children_usecase.dart';
+import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
+
+import 'book_quiz_controller.dart';
+
+class BookQuizBinding extends Bindings {
+ @override
+ void dependencies() {
+ ChildrenModule();
+ QuizModule();
+ Get.lazyPut(
+ () => BookQuizController(
+ childrenUsecase: Get.find(),
+ quizUsecase: Get.find(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/book_quiz_controller.dart b/lib/feature/books/presentation/book_quiz/book_quiz_controller.dart
new file mode 100644
index 0000000..25c0b7a
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/book_quiz_controller.dart
@@ -0,0 +1,930 @@
+import 'dart:convert';
+
+import 'package:epic_story_app/core/constants/quiz_prompt.dart';
+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/data/models/mappers/quiz/history/quiz_choice_answer_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_fill_blank_answer_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/history/quiz_group_answer_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/quiz_choice_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/quiz_fill_blank_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
+import 'package:epic_story_app/data/models/remotes/quiz/quiz_choice_remote_model.dart';
+import 'package:epic_story_app/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart';
+import 'package:epic_story_app/data/models/remotes/quiz/quiz_group_remote_model.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/book_history_quiz_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/history/quiz_choice_answer_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/history/quiz_fill_blank_answer_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/history/quiz_group_answer_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
+import 'package:epic_story_app/domain/usecases/children_usecase.dart';
+import 'package:epic_story_app/domain/usecases/quiz_usecase.dart';
+import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:get/get.dart';
+
+class BookQuizController extends GetxController {
+ BookQuizController({
+ required this.childrenUsecase,
+ required this.quizUsecase,
+ });
+
+ final ChildrenUsecase childrenUsecase;
+ final QuizUsecase quizUsecase;
+
+ final navController = Get.find();
+ final Rxn bookHistory = Rxn();
+ final Rxn currentBook = Rxn();
+ final RxBool isLoading = false.obs;
+
+ final isAnswerChecked = false.obs;
+ final isAnswerCorrect = false.obs;
+ final retryQuestion = 3.obs;
+
+ final assignedChoice = (-1).obs;
+ final assignedGroupByOption = {}.obs;
+ final assignedBlankByOption = {}.obs;
+ String _theoryText = '';
+ int? _bookQuizType;
+ var title = ''.obs;
+
+ BookHistoryQuizEntity? get quiz => bookHistory.value?.quiz;
+ int get quizType => quiz?.quizType ?? 0;
+
+ QuizChoiceEntity? get multipleChoiceQuiz => quiz?.multipleChoiceQuestion;
+ QuizFillBlankEntity? get fillBlankQuiz => quiz?.fillBlankQuestion;
+ QuizGroupEntity? get groupQuiz => quiz?.groupQuestion;
+
+ bool get isQuizAvailable {
+ final type = quizType;
+ if (type == 1) return multipleChoiceQuiz != null;
+ if (type == 2) return fillBlankQuiz != null;
+ if (type == 3) return groupQuiz != null;
+ return false;
+ }
+
+ String? get explanation {
+ if (quiz?.explanation?.trim().isNotEmpty == true) {
+ return quiz?.explanation;
+ }
+ if (quizType == 1) return multipleChoiceQuiz?.explanation;
+ if (quizType == 2) return fillBlankQuiz?.explanation;
+ if (quizType == 3) return groupQuiz?.explanation;
+ return quiz?.explanation;
+ }
+
+ @override
+ void onInit() {
+ super.onInit();
+ _initFromArguments();
+ }
+
+ void _initFromArguments() {
+ final args = Get.arguments;
+ BookHistoryEntity? history;
+
+ if (args is BookHistoryEntity) {
+ history = args;
+ } else if (args is Map) {
+ final rawHistory = args['history'];
+ if (rawHistory is BookHistoryEntity) {
+ history = rawHistory;
+ }
+ final theoryTextArg = args['theoryText'];
+ if (theoryTextArg is String) {
+ _theoryText = theoryTextArg;
+ }
+ final bookQuizTypeArg = args['bookQuizType'];
+ if (bookQuizTypeArg is int) {
+ _bookQuizType = bookQuizTypeArg;
+ }
+ final bookArg = args['book'];
+ if (bookArg is BookEntity) {
+ currentBook.value = bookArg;
+ }
+ title.value = history?.title ?? 'null';
+ }
+
+ if (history == null) {
+ EpicLog.debug('BookQuizController - missing book history argument');
+ return;
+ }
+
+ bookHistory.value = history;
+ syncCurrentQuiz();
+ }
+
+ void syncCurrentQuiz() {
+ final currentQuiz = quiz;
+ retryQuestion.value = currentQuiz?.retry ?? 3;
+
+ assignedChoice.value =
+ currentQuiz?.multipleChoiceAnswer?.selectedAnswer ?? -1;
+
+ assignedBlankByOption.clear();
+ assignedBlankByOption
+ .addAll(currentQuiz?.fillBlankAnswer?.assignedBlankByOption ?? {});
+
+ assignedGroupByOption.clear();
+ assignedGroupByOption
+ .addAll(currentQuiz?.groupAnswer?.assignedGroupByOption ?? {});
+
+ final hasStoredAnswer = (currentQuiz?.answer?.trim().isNotEmpty ?? false) ||
+ currentQuiz?.multipleChoiceAnswer != null ||
+ currentQuiz?.fillBlankAnswer != null ||
+ currentQuiz?.groupAnswer != null;
+
+ isAnswerChecked.value = hasStoredAnswer;
+ isAnswerCorrect.value = _evaluateCurrentCorrectness();
+ }
+
+ bool _evaluateCurrentCorrectness() {
+ final type = quizType;
+ if (type == 1) {
+ final q = multipleChoiceQuiz;
+ if (q == null) return false;
+ return q.correctedIndex == assignedChoice.value &&
+ assignedChoice.value >= 0;
+ }
+ if (type == 2) {
+ final q = fillBlankQuiz;
+ if (q == null) return false;
+ final options = q.options ?? const [];
+ if (options.isEmpty) return false;
+ for (final option in options) {
+ final id = option.id;
+ final correctBlank = option.correctBlankId;
+ if (id == null || correctBlank == null) return false;
+ final assigned = assignedBlankByOption[id];
+ if (assigned != correctBlank) return false;
+ }
+ return true;
+ }
+ if (type == 3) {
+ final q = groupQuiz;
+ if (q == null) return false;
+ final options = q.options ?? const [];
+ if (options.isEmpty) return false;
+ for (final option in options) {
+ final id = option.id;
+ final correctGroup = option.correctGroupId;
+ if (id == null || correctGroup == null) return false;
+ final assigned = assignedGroupByOption[id];
+ if (assigned != correctGroup) return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ void selectChoice(int index) {
+ assignedChoice.value = index;
+ isAnswerChecked.value = false;
+ }
+
+ void assignOptionToGroup(
+ {required String optionId, required String? groupId}) {
+ if (groupId != null && groupId.isNotEmpty) {
+ final currentGroupId = assignedGroupByOption[optionId];
+ final currentCount = countForGroup(groupId);
+ final alreadyInTarget = currentGroupId == groupId;
+ if (!alreadyInTarget && currentCount >= 4) {
+ EpicSnackBar.showErrorSnackBar(
+ 'Group Full',
+ 'Setiap group maksimal 4 item.',
+ );
+ return;
+ }
+ }
+
+ if (optionId.isEmpty) return;
+ if (groupId == null || groupId.isEmpty) {
+ assignedGroupByOption.remove(optionId);
+ } else {
+ assignedGroupByOption[optionId] = groupId;
+ }
+ isAnswerChecked.value = false;
+ }
+
+ void assignOptionToBlank(
+ {required String optionId, required String? blankId}) {
+ if (optionId.isEmpty) return;
+ if (blankId == null || blankId.isEmpty) {
+ assignedBlankByOption.remove(optionId);
+ } else {
+ assignedBlankByOption[optionId] = blankId;
+ }
+ isAnswerChecked.value = false;
+ }
+
+ int countForGroup(String groupId) {
+ var count = 0;
+ assignedGroupByOption.forEach((_, value) {
+ if (value == groupId) count++;
+ });
+ return count;
+ }
+
+ void cleanupAssignments(Set validOptionIds) {
+ assignedGroupByOption
+ .removeWhere((key, _) => !validOptionIds.contains(key));
+ assignedBlankByOption
+ .removeWhere((key, _) => !validOptionIds.contains(key));
+ }
+
+ Future updateHistoryAnswer({
+ required int quizType,
+ required bool isCorrect,
+ String? question,
+ String? answer,
+ String? explanation,
+ QuizChoiceAnswerEntity? multipleChoiceAnswer,
+ QuizChoiceEntity? multipleChoiceQuestion,
+ QuizGroupAnswerEntity? groupAnswer,
+ QuizGroupEntity? groupQuestion,
+ QuizFillBlankAnswerEntity? fillBlankAnswer,
+ QuizFillBlankEntity? fillBlankQuestion,
+ }) async {
+ final history = bookHistory.value;
+ if (history == null) return;
+
+ final existingQuiz = history.quiz;
+ if (existingQuiz == null) return;
+
+ final updatedQuiz = existingQuiz.copyWith(
+ quizType: quizType,
+ retry: retryQuestion.value,
+ question: question ?? existingQuiz.question,
+ answer: answer,
+ explanation: explanation ?? existingQuiz.explanation,
+ multipleChoiceQuestion:
+ multipleChoiceQuestion ?? existingQuiz.multipleChoiceQuestion,
+ multipleChoiceAnswer: multipleChoiceAnswer,
+ fillBlankQuestion: fillBlankQuestion ?? existingQuiz.fillBlankQuestion,
+ fillBlankAnswer: fillBlankAnswer,
+ groupQuestion: groupQuestion ?? existingQuiz.groupQuestion,
+ groupAnswer: groupAnswer,
+ );
+
+ final completed = [...?history.completedQuizes];
+ if (isCorrect && !completed.contains(quizType)) {
+ completed.add(quizType);
+ }
+
+ final finished = isCorrect;
+
+ final updatedHistory = history.copyWith(
+ quiz: updatedQuiz,
+ completedQuizes: completed,
+ lastReadAt: DateTime.now(),
+ isFinished: finished,
+ finishedAt: finished
+ ? (history.finishedAt ?? DateTime.now())
+ : history.finishedAt,
+ );
+
+ // Update local state first so UI/result stays consistent even if user leaves quickly.
+ bookHistory.value = updatedHistory;
+ await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
+ }
+
+ Future onCheckAnswer() async {
+ if (!isQuizAvailable) return;
+
+ if (retryQuestion.value <= 0) {
+ EpicSnackBar.showErrorSnackBar(
+ 'Kesempatan Habis',
+ 'Kesempatan menjawab untuk quiz ini sudah habis.',
+ );
+ return;
+ }
+
+ final type = quizType;
+ if (type == 1) {
+ final q = multipleChoiceQuiz;
+ if (q == null) return;
+
+ final correctIndex = q.correctedIndex;
+ if (correctIndex == null || assignedChoice.value < 0) {
+ EpicSnackBar.showWarningSnackBar(
+ 'Quiz', 'Pilih jawaban terlebih dahulu.');
+ return;
+ }
+
+ isAnswerCorrect.value = assignedChoice.value == correctIndex;
+ isAnswerChecked.value = true;
+ retryQuestion.value--;
+
+ final answerData = QuizChoiceAnswerEntity(
+ selectedAnswer: assignedChoice.value,
+ );
+ final questionJson = QuizChoiceMapper.entityToRemote(q).toJson();
+ final answerJson =
+ QuizChoiceAnswerMapper.entityToRemote(answerData).toJson();
+
+ if (isAnswerCorrect.value) {
+ EpicSnackBar.showSuccessSnackBar('Benar', 'Jawaban kamu benar.');
+ } else {
+ EpicSnackBar.showErrorSnackBar('Salah', 'Coba lagi ya.');
+ }
+
+ await updateHistoryAnswer(
+ quizType: type,
+ isCorrect: isAnswerCorrect.value,
+ question: jsonEncode(questionJson),
+ answer: jsonEncode(answerJson),
+ explanation: q.explanation,
+ multipleChoiceQuestion: q,
+ multipleChoiceAnswer: answerData,
+ );
+ // if (isAnswerCorrect.value) {
+ // navController.refreshRewardBook.value++;
+ // }
+ return;
+ }
+
+ if (type == 2) {
+ final q = fillBlankQuiz;
+ if (q == null) return;
+ final options = q.options ?? const [];
+ if (options.isEmpty) return;
+
+ var allAssigned = true;
+ var allCorrect = true;
+
+ for (final option in options) {
+ final id = option.id;
+ final correctBlankId = option.correctBlankId;
+ if (id == null) continue;
+ final assigned = assignedBlankByOption[id];
+ if (assigned == null) {
+ allAssigned = false;
+ allCorrect = false;
+ continue;
+ }
+ if (correctBlankId != null && assigned != correctBlankId) {
+ allCorrect = false;
+ }
+ }
+
+ isAnswerChecked.value = true;
+ isAnswerCorrect.value = allAssigned && allCorrect;
+ retryQuestion.value--;
+
+ final answerData = QuizFillBlankAnswerEntity(
+ assignedBlankByOption: Map.from(assignedBlankByOption),
+ );
+ final questionJson = QuizFillBlankMapper.entityToRemote(q).toJson();
+ final answerJson =
+ QuizFillBlankAnswerMapper.entityToRemote(answerData).toJson();
+
+ if (isAnswerCorrect.value) {
+ EpicSnackBar.showSuccessSnackBar('Benar', 'Semua isian sudah benar.');
+ } else {
+ EpicSnackBar.showErrorSnackBar(
+ 'Salah',
+ allAssigned
+ ? 'Masih ada isian yang salah.'
+ : 'Lengkapi semua isian dulu.',
+ );
+ }
+
+ await updateHistoryAnswer(
+ quizType: type,
+ isCorrect: isAnswerCorrect.value,
+ question: jsonEncode(questionJson),
+ answer: jsonEncode(answerJson),
+ explanation: q.explanation,
+ fillBlankQuestion: q,
+ fillBlankAnswer: answerData,
+ );
+ // if (isAnswerCorrect.value) {
+ // navController.refreshRewardBook.value++;
+ // }
+ return;
+ }
+
+ if (type == 3) {
+ final q = groupQuiz;
+ if (q == null) return;
+ final options = q.options ?? const [];
+ if (options.isEmpty) return;
+
+ var allAssigned = true;
+ var allCorrect = true;
+
+ for (final option in options) {
+ final id = option.id;
+ final correctGroup = option.correctGroupId;
+ if (id == null) continue;
+ final assigned = assignedGroupByOption[id];
+ if (assigned == null) {
+ allAssigned = false;
+ allCorrect = false;
+ continue;
+ }
+ if (correctGroup != null && assigned != correctGroup) {
+ allCorrect = false;
+ }
+ }
+
+ isAnswerChecked.value = true;
+ isAnswerCorrect.value = allAssigned && allCorrect;
+ retryQuestion.value--;
+
+ final answerData = QuizGroupAnswerEntity(
+ assignedGroupByOption: Map.from(assignedGroupByOption),
+ );
+ final questionJson = QuizGroupMapper.entityToRemote(q).toJson();
+ final answerJson =
+ QuizGroupAnswerMapper.entityToRemote(answerData).toJson();
+
+ if (isAnswerCorrect.value) {
+ EpicSnackBar.showSuccessSnackBar(
+ 'Benar',
+ 'Semua item sudah di kelompok yang tepat.',
+ );
+ } else {
+ EpicSnackBar.showErrorSnackBar(
+ 'Salah',
+ allAssigned
+ ? 'Masih ada item di kelompok yang salah.'
+ : 'Masukkan semua item ke group.',
+ );
+ }
+
+ await updateHistoryAnswer(
+ quizType: type,
+ isCorrect: isAnswerCorrect.value,
+ question: jsonEncode(questionJson),
+ answer: jsonEncode(answerJson),
+ explanation: q.explanation,
+ groupQuestion: q,
+ groupAnswer: answerData,
+ );
+ // if (isAnswerCorrect.value) {
+ // navController.refreshRewardBook.value++;
+ // }
+ return;
+ }
+
+ EpicLog.debug('BookQuizController - unsupported quiz type: $type');
+ }
+
+ void showExplanationDialog(String? explanationText) {
+ Get.dialog(
+ Dialog(
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ insetPadding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Stack(
+ clipBehavior: Clip.none,
+ alignment: Alignment.topCenter,
+ children: [
+ // Main card
+ Container(
+ margin: const EdgeInsets.only(top: 44),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(
+ color: const Color(0xFF6B42B8),
+ width: 2.5,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFF6B42B8).withOpacity(0.25),
+ blurRadius: 18,
+ offset: const Offset(0, 6),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(21.5),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ // Purple gradient header
+ Container(
+ decoration: const BoxDecoration(
+ gradient: LinearGradient(
+ colors: [Color(0xFF6B42B8), Color(0xFF9C5FD4)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ ),
+ padding: const EdgeInsets.fromLTRB(18, 14, 18, 14),
+ child: const Row(
+ children: [
+ Text('💡', style: TextStyle(fontSize: 22)),
+ SizedBox(width: 10),
+ Text(
+ 'Penjelasan',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ ),
+ // Content
+ Padding(
+ padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxHeight: 200),
+ child: SingleChildScrollView(
+ child: Text(
+ explanationText ?? 'Penjelasan belum tersedia.',
+ textAlign: TextAlign.left,
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF37474F),
+ height: 1.6,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 20),
+ GestureDetector(
+ onTap: () => Get.back(),
+ child: Container(
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 13),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(50),
+ border: Border.all(
+ color: const Color(0xFF6B42B8),
+ width: 2,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFF6B42B8)
+ .withOpacity(0.3),
+ offset: const Offset(0, 3),
+ blurRadius: 0,
+ ),
+ ],
+ ),
+ child: const Center(
+ child: Text(
+ 'Tutup',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w800,
+ color: Color(0xFF6B42B8),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ // Lightbulb badge
+ Positioned(
+ top: 0,
+ child: Container(
+ width: 88,
+ height: 88,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ gradient: const LinearGradient(
+ colors: [Color(0xFFFFE566), Color(0xFFFFB300)],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ border: Border.all(
+ color: const Color(0xFF6B42B8),
+ width: 3,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0xFFFFB300).withOpacity(0.5),
+ blurRadius: 12,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: const Center(
+ child: Text('💡', style: TextStyle(fontSize: 42)),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ barrierDismissible: true,
+ barrierColor: Colors.black.withOpacity(0.4),
+ );
+ }
+
+ Future forceGenerateQuiz() async {
+ if (isLoading.value) return;
+
+ final history = bookHistory.value;
+ if (history == null) return;
+
+ final text = _theoryText.trim();
+ if (text.isEmpty) {
+ EpicSnackBar.showErrorSnackBar(
+ 'Quiz',
+ 'Materi buku kosong, quiz tidak bisa digenerate ulang.',
+ );
+ return;
+ }
+
+ final type = _bookQuizType ?? quizType;
+ if (type < 1 || type > 3) {
+ EpicSnackBar.showErrorSnackBar(
+ 'Quiz',
+ 'Tipe quiz tidak valid untuk generate ulang.',
+ );
+ return;
+ }
+
+ isLoading.value = true;
+ try {
+ final previousQuestions = _extractPreviousQuestions(history.quiz);
+ final generatedQuiz = await _tryGenerateQuizByQuizType(
+ quizType: type,
+ text: text,
+ category: currentBook.value?.category,
+ previousQuestions: previousQuestions,
+ );
+ if (generatedQuiz == null) {
+ EpicSnackBar.showErrorSnackBar(
+ 'Quiz',
+ 'Gagal generate ulang quiz.',
+ );
+ return;
+ }
+
+ final updatedHistory = history.copyWith(
+ quiz: generatedQuiz,
+ lastReadAt: DateTime.now(),
+ );
+ await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
+ bookHistory.value = updatedHistory;
+ syncCurrentQuiz();
+
+ EpicSnackBar.showSuccessSnackBar(
+ 'Success',
+ 'Quiz berhasil digenerate ulang.',
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'forceGenerateQuiz');
+ EpicSnackBar.showErrorSnackBar(
+ 'Quiz',
+ 'Terjadi kesalahan saat generate ulang quiz.',
+ );
+ } finally {
+ isLoading.value = false;
+ }
+ }
+
+ List _extractPreviousQuestions(BookHistoryQuizEntity? quiz) {
+ if (quiz == null) return [];
+ final List previous = [];
+ if (quiz.multipleChoiceQuestion?.question != null) {
+ previous.add(quiz.multipleChoiceQuestion!.question!);
+ }
+ if (quiz.fillBlankQuestion?.sentence != null) {
+ final sentenceText = quiz.fillBlankQuestion!.sentence!
+ .map((s) => s.type == 'blank' ? '___' : (s.value ?? ''))
+ .join();
+ if (sentenceText.trim().isNotEmpty) previous.add(sentenceText);
+ }
+ if (quiz.groupQuestion?.groups != null) {
+ final groupTitles = quiz.groupQuestion!.groups!
+ .map((g) => g.title ?? '')
+ .where((t) => t.isNotEmpty)
+ .join(' vs ');
+ if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles');
+ }
+ return previous;
+ }
+
+ Future _tryGenerateQuizByQuizType({
+ required int quizType,
+ required String text,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ if (quizType == 1) {
+ final choiceQuiz = await _tryToGenerateMultipleChoiceQuiz(
+ fullText: text,
+ category: category,
+ previousQuestions: previousQuestions,
+ );
+ if (choiceQuiz != null) {
+ final remote = QuizChoiceMapper.entityToRemote(choiceQuiz);
+ return BookHistoryQuizEntity(
+ quizType: 1,
+ retry: 3,
+ question: jsonEncode(remote.toJson()),
+ answer: null,
+ explanation: choiceQuiz.explanation,
+ multipleChoiceQuestion: choiceQuiz,
+ multipleChoiceAnswer: null,
+ fillBlankQuestion: null,
+ fillBlankAnswer: null,
+ groupQuestion: null,
+ groupAnswer: null,
+ );
+ }
+ } else if (quizType == 2) {
+ final fillBlankQuiz = await _tryToGenerateFillBlankQuiz(
+ fullText: text,
+ category: category,
+ previousQuestions: previousQuestions,
+ );
+ if (fillBlankQuiz != null) {
+ final remote = QuizFillBlankMapper.entityToRemote(fillBlankQuiz);
+ return BookHistoryQuizEntity(
+ quizType: 2,
+ retry: 3,
+ question: jsonEncode(remote.toJson()),
+ answer: null,
+ explanation: fillBlankQuiz.explanation,
+ multipleChoiceQuestion: null,
+ multipleChoiceAnswer: null,
+ fillBlankQuestion: fillBlankQuiz,
+ fillBlankAnswer: null,
+ groupQuestion: null,
+ groupAnswer: null,
+ );
+ }
+ } else if (quizType == 3) {
+ final groupQuiz = await _tryToGenerateGroupQuiz(
+ fullText: text,
+ category: category,
+ previousQuestions: previousQuestions,
+ );
+ if (groupQuiz != null) {
+ final remote = QuizGroupMapper.entityToRemote(groupQuiz);
+ return BookHistoryQuizEntity(
+ quizType: 3,
+ retry: 3,
+ question: jsonEncode(remote.toJson()),
+ answer: null,
+ explanation: groupQuiz.explanation,
+ multipleChoiceQuestion: null,
+ multipleChoiceAnswer: null,
+ fillBlankQuestion: null,
+ fillBlankAnswer: null,
+ groupQuestion: groupQuiz,
+ groupAnswer: null,
+ );
+ }
+ }
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType');
+ }
+ return null;
+ }
+
+ Future _tryToGenerateMultipleChoiceQuiz({
+ required String fullText,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.multipleChoicePrompt(
+ text: fullText,
+ category: category,
+ previousQuestions: previousQuestions,
+ ),
+ );
+ if (quizJson == null) return null;
+
+ final parsed = jsonDecode(quizJson) as Map;
+ final quizModel = QuizChoiceRemoteModel.fromJson(parsed);
+ // === VALIDASI ===
+ if (quizModel.question == null ||
+ quizModel.choices == null ||
+ quizModel.choices!.length != 4 ||
+ quizModel.correctedIndex == null ||
+ quizModel.correctedIndex! < 0 ||
+ quizModel.correctedIndex! >= quizModel.choices!.length) {
+ EpicLog.debug(
+ '_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
+ return null;
+ }
+ final quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
+ }
+ return null;
+ }
+
+ Future _tryToGenerateFillBlankQuiz({
+ required String fullText,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.fillBlankPrompt(
+ text: fullText,
+ category: category,
+ previousQuestions: previousQuestions,
+ ),
+ );
+ if (quizJson == null) return null;
+
+ final parsed = jsonDecode(quizJson) as Map;
+ final quizModel = QuizFillBlankRemoteModel.fromJson(parsed);
+ // Validasi fill blank:
+ if (quizModel.sentence == null ||
+ quizModel.options == null ||
+ quizModel.options!.length != 4 ||
+ quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
+ quizModel.options!
+ .any((o) => o.value == null || o.correctBlankId == null)) {
+ EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
+ return null;
+ }
+ final quizEntity = QuizFillBlankMapper.remoteToEntity(quizModel);
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateFillBlankQuiz');
+ }
+ return null;
+ }
+
+ Future _tryToGenerateGroupQuiz({
+ required String fullText,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.groupQuizPrompt(
+ text: fullText,
+ category: category,
+ previousQuestions: previousQuestions,
+ ),
+ );
+ if (quizJson == null) return null;
+
+ final parsed = jsonDecode(quizJson) as Map;
+ final quizModel = QuizGroupRemoteModel.fromJson(parsed);
+ // Validasi group quiz:
+ final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
+ if (quizModel.groups == null ||
+ quizModel.groups!.length != 2 ||
+ quizModel.options == null ||
+ quizModel.options!.length != 8 ||
+ quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
+ EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
+ return null;
+ }
+ final quizEntity = QuizGroupMapper.remoteToEntity(quizModel);
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateGroupQuiz');
+ }
+ return null;
+ }
+
+ Future closePage() async {
+ await SystemChrome.setPreferredOrientations([
+ DeviceOrientation.landscapeLeft,
+ DeviceOrientation.landscapeRight,
+ ]);
+ Get.back(result: bookHistory.value);
+ }
+
+ void openBookRewardClaim() async {
+ await Get.toNamed(
+ EpicRoutes.bookReward,
+ arguments: currentBook.value,
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/book_quiz_page.dart b/lib/feature/books/presentation/book_quiz/book_quiz_page.dart
new file mode 100644
index 0000000..23a5fdc
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/book_quiz_page.dart
@@ -0,0 +1,600 @@
+import 'package:epic_story_app/core/styles/epic_box_colors.dart';
+import 'package:epic_story_app/core/styles/epic_app_size.dart';
+import 'package:epic_story_app/core/widgets/cards/box_shadow.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:get/get.dart';
+
+class BookQuizPage extends StatefulWidget {
+ const BookQuizPage({super.key});
+
+ @override
+ State createState() => _BookQuizPageState();
+}
+
+class _BookQuizPageState extends State {
+ final BookQuizController controller = Get.find();
+
+ void _handleBackTap() {
+ if (controller.isLoading.value) {
+ Get.snackbar(
+ 'Quiz',
+ 'Tunggu proses generate selesai.',
+ snackPosition: SnackPosition.BOTTOM,
+ duration: const Duration(seconds: 2),
+ );
+ return;
+ }
+ controller.closePage();
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ SystemChrome.setPreferredOrientations([
+ DeviceOrientation.portraitUp,
+ ]);
+ }
+
+ @override
+ void dispose() {
+ SystemChrome.setPreferredOrientations([
+ DeviceOrientation.landscapeLeft,
+ DeviceOrientation.landscapeRight,
+ ]);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ EpicAppSize.initialize(context);
+ var width = EpicAppSize.screenWidth;
+ var height = EpicAppSize.screenHeight;
+ return Scaffold(
+ backgroundColor: const Color(0xFFE0CEFF),
+ appBar: AppBar(
+ backgroundColor: const Color(0xFFE0CEFF),
+ elevation: 0,
+ automaticallyImplyLeading: false,
+ toolbarHeight: 78,
+ titleSpacing: 0,
+ title: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 8, 14, 4),
+ child: Obx(
+ () => _BookQuizTopBar(
+ title: controller.title.value,
+ onBackTap: _handleBackTap,
+ ),
+ ),
+ ),
+ ),
+ body: WillPopScope(
+ onWillPop: () async {
+ _handleBackTap();
+ return false;
+ },
+ child: Obx(
+ () => Center(
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ ShadowBox(
+ width: width * 0.9,
+ height: height * 0.75,
+ padding: const EdgeInsets.fromLTRB(18, 18, 18, 26),
+ cardColor: BoxColors.purplePrimary,
+ radius: BorderRadius.circular(
+ EpicAppSize.calculatedSize(24),
+ ),
+ child: _buildQuizBody(),
+ ),
+ if (controller.isQuizAvailable)
+ Positioned(
+ bottom: EpicAppSize.calculatedSize(-20),
+ left: 0,
+ right: 0,
+ child: Center(
+ child: _buildCheckButton(context),
+ ),
+ ),
+ if (controller.isQuizAvailable)
+ Positioned(
+ top: -20,
+ right: 16,
+ child: _buildOverlayControl(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildQuizBody() {
+ if (controller.isLoading.value) {
+ return const Center(
+ child: CircularProgressIndicator(
+ color: Colors.white,
+ ),
+ );
+ }
+
+ if (!controller.isQuizAvailable) {
+ return const Center(
+ child: Text(
+ 'Quiz belum tersedia untuk buku ini.',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ color: Colors.white,
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ );
+ }
+
+ return SingleChildScrollView(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const SizedBox(height: 16),
+ Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: _buildQuizWidgetByType(),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildQuizWidgetByType() {
+ if (controller.quizType == 1 && controller.multipleChoiceQuiz != null) {
+ return BookMultipleChoiceQuiz(
+ quizController: controller,
+ quizData: controller.multipleChoiceQuiz!,
+ );
+ }
+
+ if (controller.quizType == 2 && controller.fillBlankQuiz != null) {
+ return BookFillBlankQuiz(
+ quizController: controller,
+ quizData: controller.fillBlankQuiz!,
+ );
+ }
+
+ if (controller.quizType == 3 && controller.groupQuiz != null) {
+ return BookGroupQuiz(
+ quizController: controller,
+ quizData: controller.groupQuiz!,
+ );
+ }
+
+ return const SizedBox.shrink();
+ }
+
+ Widget _buildCheckButton(BuildContext context) {
+ if (controller.isLoading.value) {
+ return const SizedBox.shrink();
+ }
+
+ final isRewardUnclaimed =
+ controller.bookHistory.value?.claimedRewards == null ||
+ controller.bookHistory.value!.claimedRewards!.isEmpty;
+
+ if (controller.isAnswerCorrect.value) {
+ if (isRewardUnclaimed) {
+ return _PulsingRewardShadowButton(
+ onTap: controller.openBookRewardClaim,
+ width: MediaQuery.of(context).size.width * 0.65,
+ height: EpicAppSize.calculatedSize(50),
+ );
+ }
+
+ return ShadowBox(
+ cardColor: BoxColors.bluePrimary,
+ isCanClick: false,
+ width: MediaQuery.of(context).size.width * 0.65,
+ height: EpicAppSize.calculatedSize(50),
+ borderWidthRatio: EpicAppSize.borderWidthRatio,
+ radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
+ child: Center(
+ child: Text(
+ 'Kuis Sudah Diselesaikan',
+ style: TextStyle(
+ color: BoxColors.bluePrimary.textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ );
+ }
+
+ final isCanAnswer = (controller.retryQuestion.value > 0) &&
+ controller.isAnswerCorrect.value == false;
+ final bgColor =
+ isCanAnswer ? BoxColors.greenSecondary : BoxColors.grayDisabled;
+
+ return ShadowBox(
+ cardColor: bgColor,
+ isCanClick: isCanAnswer,
+ onTap: controller.onCheckAnswer,
+ width: MediaQuery.of(context).size.width * 0.65,
+ height: EpicAppSize.calculatedSize(50),
+ borderWidthRatio: EpicAppSize.borderWidthRatio,
+ radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
+ child: Center(
+ child: Text(
+ 'Cek Jawaban',
+ style: TextStyle(
+ color: bgColor.textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildOverlayControl() {
+ return Obx(() {
+ if (controller.isLoading.value) {
+ return const SizedBox();
+ }
+
+ final isCorrect = controller.isAnswerCorrect.value;
+ final explanation = controller.explanation;
+
+ if (isCorrect) {
+ return _buildHelpButton(explanation);
+ }
+
+ final retries = controller.retryQuestion.value;
+ if (retries <= 0) {
+ return Row(
+ children: [
+ _PulsingRetryButton(
+ onPressed: () {
+ controller.forceGenerateQuiz();
+ },
+ ),
+ const SizedBox(width: 8),
+ _buildHelpButton(explanation),
+ ],
+ );
+ }
+
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: BoxColors.purplePrimary.backgroundColor,
+ width: 1.1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: List.generate(3, (index) {
+ final filled = retries > index;
+ return Padding(
+ padding: EdgeInsets.only(left: index == 0 ? 0 : 8),
+ child: Icon(
+ filled ? Icons.favorite : Icons.favorite_border,
+ size: 30,
+ color: filled ? Colors.pink : BoxColors.purplePrimary.textColor,
+ ),
+ );
+ }),
+ ),
+ );
+ });
+ }
+
+ Widget _buildHelpButton(String? explanation) {
+ return Material(
+ color: Colors.transparent,
+ child: Row(
+ children: [
+ // Visibility(
+ // visible: controller.bookHistory.value?.claimedRewards == null ||
+ // controller.bookHistory.value!.claimedRewards!.isEmpty,
+ // child: GestureDetector(
+ // onTap: () {
+ // controller.openBookRewardClaim();
+ // },
+ // child: Container(
+ // padding: const EdgeInsets.symmetric(
+ // horizontal: 10,
+ // vertical: 6,
+ // ),
+ // decoration: BoxDecoration(
+ // color: Colors.white,
+ // borderRadius: BorderRadius.circular(14),
+ // border: Border.all(
+ // color: BoxColors.purplePrimary.backgroundColor,
+ // width: 1.1,
+ // ),
+ // ),
+ // child: Text(
+ // 'Dapatkan Hadiah',
+ // style: TextStyle(
+ // color: BoxColors.purplePrimary.textColor,
+ // fontSize: 14,
+ // ),
+ // ),
+ // ),
+ // ),
+ // ),
+ SizedBox(width: 8),
+ InkWell(
+ borderRadius: BorderRadius.circular(12),
+ onTap: () => controller.showExplanationDialog(explanation),
+ child: Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: Color(0xFFFFD700),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: BoxColors.purplePrimary.backgroundColor,
+ width: 1.2,
+ ),
+ ),
+ child: Icon(
+ Icons.help_outline,
+ size: 22,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _BookQuizTopBar extends StatelessWidget {
+ const _BookQuizTopBar({
+ required this.title,
+ required this.onBackTap,
+ });
+
+ final String title;
+ final VoidCallback onBackTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ children: [
+ _TopBackButton(onTap: onBackTap),
+ const SizedBox(width: 10),
+ Expanded(
+ child: SizedBox(
+ height: 55,
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: Image.asset(
+ 'assets/images/judul-kartu.png',
+ fit: BoxFit.fill,
+ ),
+ ),
+ Positioned.fill(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 15),
+ child: Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: 8),
+ child: Text(
+ title.isEmpty ? 'Quiz' : title,
+ maxLines: 1,
+ textAlign: TextAlign.center,
+ softWrap: false,
+ style: const TextStyle(
+ color: Colors.white,
+ height: 1,
+ fontSize: 22,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ ],
+ );
+ }
+}
+
+class _TopBackButton extends StatelessWidget {
+ const _TopBackButton({required this.onTap});
+
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(14),
+ child: Ink(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.18),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: const Icon(
+ Icons.arrow_back_ios_new_rounded,
+ size: 21,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PulsingRetryButton extends StatefulWidget {
+ const _PulsingRetryButton({required this.onPressed});
+
+ final VoidCallback onPressed;
+
+ @override
+ State<_PulsingRetryButton> createState() => _PulsingRetryButtonState();
+}
+
+class _PulsingRetryButtonState extends State<_PulsingRetryButton>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+ late final Animation _scaleAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 900),
+ )..repeat(reverse: true);
+
+ _scaleAnimation = Tween(begin: 0.94, end: 1.06).animate(
+ CurvedAnimation(
+ parent: _controller,
+ curve: Curves.easeInOut,
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ScaleTransition(
+ scale: _scaleAnimation,
+ child: TextButton(
+ style: TextButton.styleFrom(
+ backgroundColor: Colors.white,
+ foregroundColor: BoxColors.purplePrimary.textColor,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ side: BorderSide(
+ color: BoxColors.purplePrimary.backgroundColor,
+ width: 1.1,
+ ),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ ),
+ onPressed: widget.onPressed,
+ child: const Text(
+ 'Coba Lagi',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _PulsingRewardShadowButton extends StatefulWidget {
+ const _PulsingRewardShadowButton({
+ required this.onTap,
+ required this.width,
+ required this.height,
+ });
+
+ final VoidCallback onTap;
+ final double width;
+ final double height;
+
+ @override
+ State<_PulsingRewardShadowButton> createState() =>
+ _PulsingRewardShadowButtonState();
+}
+
+class _PulsingRewardShadowButtonState extends State<_PulsingRewardShadowButton>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+ late final Animation _scaleAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 900),
+ )..repeat(reverse: true);
+
+ _scaleAnimation = Tween(begin: 0.95, end: 1.05).animate(
+ CurvedAnimation(
+ parent: _controller,
+ curve: Curves.easeInOut,
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ScaleTransition(
+ scale: _scaleAnimation,
+ child: ShadowBox(
+ cardColor: BoxColors.yellowPrimary,
+ isCanClick: true,
+ onTap: widget.onTap,
+ width: widget.width,
+ height: widget.height,
+ borderWidthRatio: EpicAppSize.borderWidthRatio,
+ radius: BorderRadius.circular(EpicAppSize.calculatedSize(50)),
+ child: Center(
+ child: Text(
+ 'Dapatkan Hadiah',
+ style: TextStyle(
+ color: BoxColors.yellowPrimary.textColor,
+ fontSize: 16,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart b/lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart
new file mode 100644
index 0000000..c869779
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart
@@ -0,0 +1,164 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+class RayBurstController extends ChangeNotifier {
+ void fire() => notifyListeners();
+}
+
+class RayBurst extends StatefulWidget {
+ const RayBurst({
+ super.key,
+ required this.controller,
+ required this.child,
+ this.color = const Color(0xFF6B42B8),
+ this.duration = const Duration(milliseconds: 380),
+ this.rayCount = 10,
+ });
+
+ final RayBurstController controller;
+ final Widget child;
+ final Color color;
+ final Duration duration;
+ final int rayCount;
+
+ @override
+ State createState() => _RayBurstState();
+}
+
+class _RayBurstState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _anim;
+
+ @override
+ void initState() {
+ super.initState();
+ _anim = AnimationController(vsync: this, duration: widget.duration)
+ ..addStatusListener((status) {
+ if (status == AnimationStatus.completed) {
+ _anim.reset();
+ }
+ });
+ widget.controller.addListener(_onFire);
+ }
+
+ @override
+ void didUpdateWidget(covariant RayBurst oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.controller != widget.controller) {
+ oldWidget.controller.removeListener(_onFire);
+ widget.controller.addListener(_onFire);
+ }
+ }
+
+ @override
+ void dispose() {
+ widget.controller.removeListener(_onFire);
+ _anim.dispose();
+ super.dispose();
+ }
+
+ void _onFire() {
+ _anim.forward(from: 0);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedBuilder(
+ animation: _anim,
+ builder: (context, child) {
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ child!,
+ Positioned.fill(
+ child: IgnorePointer(
+ child: CustomPaint(
+ painter: _RayPainter(
+ progress: _anim.value,
+ color: widget.color,
+ rayCount: widget.rayCount,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ child: widget.child,
+ );
+ }
+}
+
+class _RayPainter extends CustomPainter {
+ _RayPainter({
+ required this.progress,
+ required this.color,
+ required this.rayCount,
+ });
+
+ final double progress;
+ final Color color;
+ final int rayCount;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ if (progress <= 0) return;
+
+ final center = Offset(size.width / 2, size.height / 2);
+ final maxLen = (math.min(size.width, size.height) / 2) + 32;
+ final len = maxLen * progress;
+ final strokeWidth = 3.0 * (1 - progress).clamp(0.3, 1.0);
+ final paint = Paint()
+ ..color = color.withOpacity(0.7 * (1 - progress))
+ ..strokeWidth = strokeWidth
+ ..strokeCap = StrokeCap.round;
+
+ for (var i = 0; i < rayCount; i++) {
+ final angle = (2 * math.pi / rayCount) * i;
+ final dx = math.cos(angle) * len;
+ final dy = math.sin(angle) * len;
+ canvas.drawLine(center, center.translate(dx, dy), paint);
+ }
+ }
+
+ @override
+ bool shouldRepaint(covariant _RayPainter oldDelegate) {
+ return oldDelegate.progress != progress ||
+ oldDelegate.color != color ||
+ oldDelegate.rayCount != rayCount;
+ }
+}
+
+class RayDraggableChip extends StatefulWidget {
+ const RayDraggableChip({
+ super.key,
+ required this.data,
+ required this.child,
+ required this.feedback,
+ this.childWhenDragging,
+ this.color = const Color(0xFF6B42B8),
+ });
+
+ final T data;
+ final Widget child;
+ final Widget feedback;
+ final Widget? childWhenDragging;
+ final Color color;
+
+ @override
+ State> createState() => _RayDraggableChipState();
+}
+
+class _RayDraggableChipState
+ extends State> {
+ @override
+ Widget build(BuildContext context) {
+ return Draggable(
+ data: widget.data,
+ feedback: widget.feedback,
+ childWhenDragging: widget.childWhenDragging,
+ child: widget.child,
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart b/lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart
new file mode 100644
index 0000000..63a125f
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/components/quiz/fill_blank_quiz.dart
@@ -0,0 +1,353 @@
+import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class BookFillBlankQuiz extends StatelessWidget {
+ const BookFillBlankQuiz({
+ super.key,
+ required this.quizController,
+ required this.quizData,
+ });
+
+ final BookQuizController quizController;
+ final QuizFillBlankEntity quizData;
+
+ @override
+ Widget build(BuildContext context) {
+ final sentences = quizData.sentence ?? [];
+ final options = quizData.options ?? [];
+
+ // limit to 8 options to mirror mock scale
+ final limitedOptions = options.take(8).toList();
+ quizController.cleanupAssignments(
+ limitedOptions.where((o) => o.id != null).map((o) => o.id!).toSet(),
+ );
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _SentenceArea(
+ sentences: sentences,
+ quizController: quizController,
+ ),
+ const SizedBox(height: 40),
+ Obx(() {
+ final isLocked = quizController.isAnswerCorrect.value ||
+ quizController.retryQuestion.value == 0;
+ return _OptionsTray(
+ options: limitedOptions,
+ quizController: quizController,
+ isLocked: isLocked,
+ );
+ }),
+ const SizedBox(height: 14),
+ ],
+ );
+ }
+}
+
+class _SentenceArea extends StatelessWidget {
+ const _SentenceArea({
+ required this.sentences,
+ required this.quizController,
+ });
+
+ final List sentences;
+ final BookQuizController quizController;
+
+ @override
+ Widget build(BuildContext context) {
+ return Wrap(
+ alignment: WrapAlignment.start,
+ runSpacing: 8,
+ spacing: 8,
+ children: sentences.map((part) {
+ if (part.type == 'blank') {
+ final blankId = part.id ?? '';
+ return _BlankTarget(
+ blankId: blankId,
+ quizController: quizController,
+ );
+ }
+ return Text(
+ part.value ?? '',
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w800,
+ height: 1.35,
+ color: Colors.black,
+ ),
+ );
+ }).toList(),
+ );
+ }
+}
+
+class _BlankTarget extends StatefulWidget {
+ const _BlankTarget({
+ required this.blankId,
+ required this.quizController,
+ });
+
+ final String blankId;
+ final BookQuizController quizController;
+
+ @override
+ State<_BlankTarget> createState() => _BlankTargetState();
+}
+
+class _BlankTargetState extends State<_BlankTarget>
+ with SingleTickerProviderStateMixin {
+ late final RayBurstController _rayController;
+
+ @override
+ void initState() {
+ super.initState();
+ _rayController = RayBurstController();
+ }
+
+ @override
+ void dispose() {
+ _rayController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ widget.quizController.assignedBlankByOption.length;
+ widget.quizController.isAnswerChecked.value;
+
+ QuizFillBlankOptionEntity? assignedOption;
+ widget.quizController.assignedBlankByOption.forEach((optId, bId) {
+ if (bId == widget.blankId) {
+ assignedOption = widget.quizController.fillBlankQuiz?.options
+ ?.firstWhereOrNull((opt) => opt.id == optId);
+ }
+ });
+
+ final isChecked = widget.quizController.isAnswerChecked.value;
+ final isCorrect = assignedOption != null &&
+ assignedOption!.correctBlankId != null &&
+ assignedOption!.correctBlankId == widget.blankId;
+ final isLocked = widget.quizController.isAnswerCorrect.value ||
+ widget.quizController.retryQuestion.value == 0;
+
+ Color border = const Color(0xFF7D7D7D);
+ Color bg = Colors.white;
+ if (assignedOption != null && isChecked) {
+ border = isCorrect ? Colors.green : Colors.red;
+ bg = isCorrect ? const Color(0x102ECC71) : const Color(0x10F44336);
+ }
+
+ return DragTarget(
+ onWillAccept: (_) => !isLocked,
+ onAccept: (incoming) {
+ if (isLocked) return;
+ final optId = incoming.id;
+ if (optId == null) return;
+ widget.quizController
+ .assignOptionToBlank(optionId: optId, blankId: widget.blankId);
+ _rayController.fire();
+ },
+ builder: (context, candidate, rejected) {
+ final hovering = candidate.isNotEmpty;
+ return RayBurst(
+ controller: _rayController,
+ color: const Color(0xFF6B42B8),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 140),
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: hovering ? const Color(0x0F6B42B8) : bg,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: hovering ? const Color(0xFF6B42B8) : border,
+ width: 1.4,
+ ),
+ ),
+ child: assignedOption == null
+ ? const SizedBox(width: 56, height: 20)
+ : (isLocked
+ ? _Chip(label: assignedOption!.value ?? '')
+ : RayDraggableChip(
+ data: assignedOption!,
+ color: const Color(0xFF6B42B8),
+ feedback: _Chip(
+ label: assignedOption!.value ?? '',
+ isDragging: true,
+ ),
+ childWhenDragging:
+ const SizedBox(width: 56, height: 20),
+ child: _Chip(label: assignedOption!.value ?? ''),
+ )),
+ ),
+ );
+ },
+ );
+ });
+ }
+}
+
+class _OptionsTray extends StatefulWidget {
+ const _OptionsTray({
+ required this.options,
+ required this.quizController,
+ required this.isLocked,
+ });
+
+ final List options;
+ final BookQuizController quizController;
+ final bool isLocked;
+
+ @override
+ State<_OptionsTray> createState() => _OptionsTrayState();
+}
+
+class _OptionsTrayState extends State<_OptionsTray> {
+ final ScrollController _scrollController = ScrollController();
+ late final RayBurstController _rayController;
+
+ List _unassigned() {
+ return widget.options.where((opt) {
+ final id = opt.id;
+ if (id == null) return true;
+ return !widget.quizController.assignedBlankByOption.containsKey(id);
+ }).toList();
+ }
+
+ @override
+ void dispose() {
+ _scrollController.dispose();
+ _rayController.dispose();
+ super.dispose();
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _rayController = RayBurstController();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ widget.quizController.assignedBlankByOption.length;
+ final pool = _unassigned();
+ return DragTarget(
+ onWillAccept: (_) => !widget.isLocked,
+ onAccept: (incoming) {
+ if (widget.isLocked) return;
+ final optId = incoming.id;
+ if (optId == null) return;
+ widget.quizController
+ .assignOptionToBlank(optionId: optId, blankId: null);
+ _rayController.fire();
+ },
+ builder: (context, candidate, rejected) {
+ final hovering = candidate.isNotEmpty;
+ return RayBurst(
+ controller: _rayController,
+ color: const Color(0xFF6B42B8),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 120),
+ height: 96,
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
+ decoration: BoxDecoration(
+ color: hovering ? const Color(0x0F6B42B8) : Colors.transparent,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: const Color(0xFFE0E0E0)),
+ ),
+ child: Scrollbar(
+ controller: _scrollController,
+ thumbVisibility: true,
+ trackVisibility: true,
+ radius: const Radius.circular(8),
+ child: SingleChildScrollView(
+ controller: _scrollController,
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 6),
+ child: Row(
+ children: pool.map((opt) {
+ final chip = _Chip(label: opt.value ?? '-');
+ if (widget.isLocked) {
+ return Padding(
+ padding: const EdgeInsets.only(right: 10),
+ child: chip,
+ );
+ }
+ return Padding(
+ padding: const EdgeInsets.only(right: 10),
+ child: RayDraggableChip(
+ data: opt,
+ color: const Color(0xFF6B42B8),
+ feedback:
+ _Chip(label: opt.value ?? '-', isDragging: true),
+ childWhenDragging:
+ const SizedBox(width: 64, height: 32),
+ child: chip,
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ });
+ }
+}
+
+class _Chip extends StatelessWidget {
+ const _Chip({
+ required this.label,
+ this.isDragging = false,
+ });
+
+ final String label;
+ final bool isDragging;
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ elevation: isDragging ? 6 : 0,
+ color: Colors.transparent,
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: const Color(0xFF7D7D7D), width: 1.2),
+ boxShadow: isDragging
+ ? const [
+ BoxShadow(
+ color: Color(0x22000000),
+ blurRadius: 10,
+ offset: Offset(0, 4),
+ )
+ ]
+ : const [
+ BoxShadow(
+ color: Color(0x14000000),
+ blurRadius: 6,
+ offset: Offset(0, 3),
+ )
+ ],
+ ),
+ child: Text(
+ label,
+ style: const TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart b/lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart
new file mode 100644
index 0000000..c04632a
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/components/quiz/group_quiz.dart
@@ -0,0 +1,481 @@
+import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/components/quiz/components/ray_burst.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class BookGroupQuiz extends StatelessWidget {
+ const BookGroupQuiz({
+ super.key,
+ required this.quizController,
+ required this.quizData,
+ });
+
+ final BookQuizController quizController;
+ final QuizGroupEntity quizData;
+
+ @override
+ Widget build(BuildContext context) {
+ final groups = (quizData.groups ?? []).take(2).toList();
+ final options = (quizData.options ?? []).take(8).toList();
+ quizController.cleanupAssignments(
+ options.where((o) => o.id != null).map((o) => o.id!).toSet(),
+ );
+
+ return Column(
+ children: [
+ Obx(() {
+ // observe changes to trigger rebuilds
+ quizController.assignedGroupByOption.length;
+ quizController.isAnswerChecked.value;
+ final isLocked = quizController.isAnswerCorrect.value ||
+ quizController.retryQuestion.value == 0;
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: const Color(0xFFBFBFBF), width: 1.2),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x14000000),
+ blurRadius: 10,
+ offset: Offset(0, 5)),
+ ],
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: List.generate(groups.length, (index) {
+ final group = groups[index];
+ return Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: index == 0 ? 0 : 10,
+ right: index == groups.length - 1 ? 0 : 10,
+ ),
+ child: _GroupColumn(
+ group: group,
+ allOptions: options,
+ quizController: quizController,
+ isLocked: isLocked,
+ ),
+ ),
+ );
+ }),
+ ),
+ );
+ }),
+ const SizedBox(height: 24),
+ Obx(() {
+ final isLocked = quizController.isAnswerCorrect.value ||
+ quizController.retryQuestion.value == 0;
+ return _UnassignedOptionsRow(
+ options: options,
+ quizController: quizController,
+ isLocked: isLocked,
+ );
+ }),
+ const SizedBox(height: 16),
+ ],
+ );
+ }
+}
+
+class _GroupColumn extends StatelessWidget {
+ const _GroupColumn({
+ required this.group,
+ required this.allOptions,
+ required this.quizController,
+ required this.isLocked,
+ });
+
+ final QuizGroupItemEntity group;
+ final List allOptions;
+ final BookQuizController quizController;
+ final bool isLocked;
+
+ List _assignedOptions(String groupId) {
+ return allOptions.where((option) {
+ final optId = option.id;
+ if (optId == null) return false;
+ return quizController.assignedGroupByOption[optId] == groupId;
+ }).toList();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final groupId = group.id ?? '';
+ return Container(
+ padding: const EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Color(0xFFFFF5BE),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: const Color(0xFFBFBFBF), width: 1.2),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ ConstrainedBox(
+ constraints: const BoxConstraints(minHeight: 44),
+ child: Center(
+ child: Text(
+ group.title ?? '-',
+ softWrap: true,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 12),
+ ..._buildDropSlots(groupId),
+ ],
+ ),
+ );
+ }
+
+ List _buildDropSlots(String groupId) {
+ final assigned = _assignedOptions(groupId);
+ const slotsCount = 4; // static max 4 slots per group
+ return List.generate(slotsCount, (index) {
+ final option = index < assigned.length ? assigned[index] : null;
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 10),
+ child: _GroupDropSlot(
+ groupId: groupId,
+ option: option,
+ quizController: quizController,
+ isLocked: isLocked,
+ ),
+ );
+ });
+ }
+}
+
+class _GroupDropSlot extends StatefulWidget {
+ const _GroupDropSlot({
+ required this.groupId,
+ required this.option,
+ required this.quizController,
+ required this.isLocked,
+ });
+
+ final String groupId;
+ final QuizGroupOptionEntity? option;
+ final BookQuizController quizController;
+ final bool isLocked;
+
+ @override
+ State<_GroupDropSlot> createState() => _GroupDropSlotState();
+}
+
+class _GroupDropSlotState extends State<_GroupDropSlot> {
+ late final RayBurstController _rayController;
+
+ @override
+ void initState() {
+ super.initState();
+ _rayController = RayBurstController();
+ }
+
+ @override
+ void dispose() {
+ _rayController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isChecked = widget.quizController.isAnswerChecked.value;
+ final isCorrect = widget.option != null &&
+ widget.option!.correctGroupId != null &&
+ widget.option!.correctGroupId == widget.groupId;
+
+ Color borderColor = const Color(0xFF6B6B6B);
+ Color fillColor = Colors.white;
+ if (widget.option != null && isChecked) {
+ borderColor = isCorrect ? Colors.green : Colors.red;
+ fillColor = isCorrect ? const Color(0x102ECC71) : const Color(0x10F44336);
+ }
+
+ return DragTarget(
+ onWillAccept: (incoming) {
+ if (widget.isLocked) return false;
+ if (incoming?.id == null) return false;
+ final optId = incoming!.id!;
+ final existing = widget.quizController.assignedGroupByOption[optId];
+ if (existing == widget.groupId) return true;
+ return widget.quizController.countForGroup(widget.groupId) < 4;
+ },
+ onAccept: (incoming) {
+ if (widget.isLocked) return;
+ final optId = incoming.id;
+ if (optId == null) return;
+ widget.quizController
+ .assignOptionToGroup(optionId: optId, groupId: widget.groupId);
+ _rayController.fire();
+ },
+ builder: (context, candidate, rejected) {
+ final isHovering = candidate.isNotEmpty;
+ return RayBurst(
+ controller: _rayController,
+ color: const Color(0xFF6B42B8),
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 140),
+ height: 60,
+ padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 1),
+ decoration: BoxDecoration(
+ color: isHovering ? const Color(0x0F6B42B8) : fillColor,
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(
+ color: isHovering ? const Color(0xFF6B42B8) : borderColor,
+ width: 1.2,
+ ),
+ ),
+ child: widget.option == null
+ ? const Center(
+ child: Text(
+ 'Taruh disini',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF6B6B6B),
+ ),
+ ),
+ )
+ : Center(
+ child: _DraggableOptionCard(
+ option: widget.option!,
+ quizController: widget.quizController,
+ groupId: widget.groupId,
+ isLocked: widget.isLocked,
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
+class _DraggableOptionCard extends StatelessWidget {
+ const _DraggableOptionCard({
+ required this.option,
+ required this.quizController,
+ required this.groupId,
+ required this.isLocked,
+ });
+
+ final QuizGroupOptionEntity option;
+ final BookQuizController quizController;
+ final String groupId;
+ final bool isLocked;
+
+ @override
+ Widget build(BuildContext context) {
+ final isChecked = quizController.isAnswerChecked.value;
+ final isCorrect =
+ option.correctGroupId != null && option.correctGroupId == groupId;
+
+ if (isLocked) {
+ return _OptionChip(
+ label: option.value ?? '-',
+ isCorrect: isChecked ? isCorrect : null,
+ );
+ }
+
+ return Draggable(
+ data: option,
+ feedback: _OptionChip(
+ label: option.value ?? '-',
+ isDragging: true,
+ ),
+ childWhenDragging: const SizedBox.shrink(),
+ child: _OptionChip(
+ label: option.value ?? '-',
+ isCorrect: isChecked ? isCorrect : null,
+ ),
+ );
+ }
+}
+
+class _UnassignedOptionsRow extends StatefulWidget {
+ const _UnassignedOptionsRow({
+ required this.options,
+ required this.quizController,
+ required this.isLocked,
+ });
+
+ final List options;
+ final BookQuizController quizController;
+ final bool isLocked;
+
+ @override
+ State<_UnassignedOptionsRow> createState() => _UnassignedOptionsRowState();
+}
+
+class _UnassignedOptionsRowState extends State<_UnassignedOptionsRow> {
+ final ScrollController _scrollController = ScrollController();
+ late final RayBurstController _rayController;
+
+ List _unassigned() {
+ return widget.options.where((option) {
+ final id = option.id;
+ if (id == null) return true;
+ return !widget.quizController.assignedGroupByOption.containsKey(id);
+ }).toList();
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ _rayController = RayBurstController();
+ }
+
+ @override
+ void dispose() {
+ _scrollController.dispose();
+ _rayController.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ widget.quizController.assignedGroupByOption.length;
+ return DragTarget(
+ onWillAccept: (_) => !widget.isLocked,
+ onAccept: (incoming) {
+ if (widget.isLocked) return;
+ final optId = incoming.id;
+ if (optId == null) return;
+ widget.quizController
+ .assignOptionToGroup(optionId: optId, groupId: null);
+ _rayController.fire();
+ },
+ builder: (context, candidate, rejected) {
+ final items = _unassigned();
+ return RayBurst(
+ controller: _rayController,
+ color: const Color(0xFF6B42B8),
+ child: AnimatedContainer(
+ width: double.infinity,
+ duration: const Duration(milliseconds: 120),
+ padding: const EdgeInsets.symmetric(vertical: 10),
+ decoration: BoxDecoration(
+ color: Color(0xFFFFF5BE),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: const Color(0xFFD8D8D8)),
+ ),
+ height: 90,
+ child: Scrollbar(
+ controller: _scrollController,
+ thumbVisibility: true,
+ trackVisibility: true,
+ radius: const Radius.circular(8),
+ child: SingleChildScrollView(
+ controller: _scrollController,
+ scrollDirection: Axis.horizontal,
+ padding: const EdgeInsets.symmetric(horizontal: 6),
+ child: Row(
+ children: items.map((option) {
+ final chip = _OptionChip(
+ label: option.value ?? '-',
+ isDragging: false,
+ );
+ if (widget.isLocked) {
+ return Padding(
+ padding: const EdgeInsets.only(right: 10),
+ child: chip,
+ );
+ }
+ return Padding(
+ padding: const EdgeInsets.only(right: 10),
+ child: Draggable(
+ data: option,
+ feedback: _OptionChip(
+ label: option.value ?? '-',
+ isDragging: true,
+ ),
+ childWhenDragging: const SizedBox.shrink(),
+ child: chip,
+ ),
+ );
+ }).toList(),
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ });
+ }
+}
+
+class _OptionChip extends StatelessWidget {
+ const _OptionChip({
+ required this.label,
+ this.isCorrect,
+ this.isDragging = false,
+ });
+
+ final String label;
+ final bool? isCorrect;
+ final bool isDragging;
+
+ @override
+ Widget build(BuildContext context) {
+ Color border = const Color(0xFF6B6B6B);
+ Color bg = Colors.white;
+ if (isCorrect != null) {
+ border = isCorrect! ? Colors.green : Colors.red;
+ bg = isCorrect! ? const Color(0x102ECC71) : const Color(0x10F44336);
+ }
+
+ return Material(
+ elevation: isDragging ? 6 : 0,
+ color: Colors.transparent,
+ child: Container(
+ height: 60,
+ width: 105,
+ padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
+ decoration: BoxDecoration(
+ color: bg,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: border, width: 1.2),
+ boxShadow: isDragging
+ ? const [
+ BoxShadow(
+ color: Color(0x22000000),
+ blurRadius: 10,
+ offset: Offset(0, 4),
+ )
+ ]
+ : const [
+ BoxShadow(
+ color: Color(0x14000000),
+ blurRadius: 6,
+ offset: Offset(0, 3),
+ )
+ ],
+ ),
+ child: Center(
+ child: FittedBox(
+ child: Text(
+ label,
+ style: const TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w600,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart b/lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart
new file mode 100644
index 0000000..4ffefa6
--- /dev/null
+++ b/lib/feature/books/presentation/book_quiz/components/quiz/multiple_choice_quiz.dart
@@ -0,0 +1,152 @@
+import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
+import 'package:epic_story_app/feature/books/presentation/book_quiz/book_quiz_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class BookMultipleChoiceQuiz extends StatelessWidget {
+ const BookMultipleChoiceQuiz({
+ super.key,
+ required this.quizController,
+ required this.quizData,
+ });
+
+ final BookQuizController quizController;
+ final QuizChoiceEntity quizData;
+
+ @override
+ Widget build(BuildContext context) {
+ final choices = quizData.choices ?? [];
+ final correctIndex = quizData.correctedIndex ?? -1;
+ var image = quizData.image;
+
+ return Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ if (image != null)
+ Column(
+ children: [
+ Image.network(
+ image,
+ width: 200,
+ height: 120,
+ fit: BoxFit.cover,
+ ),
+ const SizedBox(height: 16),
+ ],
+ ),
+ Text(
+ quizData.question ?? '-',
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ color: Colors.black,
+ height: 1.4,
+ ),
+ ),
+ const SizedBox(height: 18),
+ ...List.generate(choices.length, (index) {
+ final label = String.fromCharCode(65 + index);
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 10),
+ child: Obx(() {
+ final isSelected = quizController.assignedChoice.value == index;
+ final isChecked = quizController.isAnswerChecked.value;
+ final retryLeft = quizController.retryQuestion.value;
+ final isCorrect = index == correctIndex;
+ final showCorrect = quizController.isAnswerCorrect.value
+ ? (isChecked && isCorrect)
+ : (isChecked && isCorrect && retryLeft == 0);
+ final showWrong = isChecked && isSelected && !isCorrect;
+ final isLocked =
+ quizController.isAnswerCorrect.value || retryLeft == 0;
+ const borderColor = Color(0xFF000000);
+
+ Color fillColor = const Color(0xFFE0CEFF);
+ if (isSelected) {
+ fillColor = const Color(0xFF80A6FF);
+ }
+ if (showCorrect) {
+ fillColor = const Color(0xFF51F279);
+ } else if (showWrong) {
+ fillColor = const Color(0xFFFF0000);
+ }
+
+ final textColor =
+ showWrong ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
+ final bubbleColor =
+ showWrong ? const Color(0xFF000000) : const Color(0xFFFFFFFF);
+ final bubbleTextColor =
+ showWrong ? const Color(0xFFFFFFFF) : const Color(0xFF000000);
+
+ return GestureDetector(
+ onTap: isLocked
+ ? null
+ : () {
+ quizController.selectChoice(index);
+ },
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 140),
+ padding:
+ const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+ decoration: BoxDecoration(
+ color: fillColor,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: borderColor,
+ width: 1.2,
+ ),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0xFF3B1D73),
+ blurRadius: 0,
+ offset: Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 34,
+ height: 34,
+ decoration: BoxDecoration(
+ color: bubbleColor,
+ shape: BoxShape.circle,
+ border: Border.all(
+ color: const Color(0xFF000000),
+ width: 1.2,
+ ),
+ ),
+ child: Center(
+ child: Text(
+ label,
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: bubbleTextColor,
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Text(
+ choices[index],
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: textColor,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }),
+ );
+ }),
+ ],
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_read/book_read_binding.dart b/lib/feature/books/presentation/book_read/book_read_binding.dart
new file mode 100644
index 0000000..c5a0824
--- /dev/null
+++ b/lib/feature/books/presentation/book_read/book_read_binding.dart
@@ -0,0 +1,24 @@
+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/quiz_module.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/quiz_usecase.dart';
+import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
+import 'package:get/get.dart';
+
+class BookReadBinding extends Bindings {
+ @override
+ void dependencies() {
+ ChildrenModule();
+ QuizModule();
+ CollectionModule();
+ Get.lazyPut(
+ () => BookReadController(
+ childrenUsecase: Get.find(),
+ quizUsecase: Get.find(),
+ collectionUsecase: Get.find(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_read/book_read_controller.dart b/lib/feature/books/presentation/book_read/book_read_controller.dart
new file mode 100644
index 0000000..a7ef754
--- /dev/null
+++ b/lib/feature/books/presentation/book_read/book_read_controller.dart
@@ -0,0 +1,973 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:epic_story_app/core/constants/quiz_prompt.dart';
+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/data/models/mappers/quiz/quiz_choice_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/quiz_fill_blank_mapper.dart';
+import 'package:epic_story_app/data/models/mappers/quiz/quiz_group_mapper.dart';
+import 'package:epic_story_app/data/models/remotes/quiz/quiz_choice_remote_model.dart';
+import 'package:epic_story_app/data/models/remotes/quiz/quiz_fill_blank_remote_model.dart';
+import 'package:epic_story_app/data/models/remotes/quiz/quiz_group_remote_model.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/book_history_quiz_entity.dart';
+import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart';
+import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.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/quiz_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:flutter/foundation.dart';
+import 'package:flutter_pdfview/flutter_pdfview.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_tts/flutter_tts.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:get/get.dart';
+
+class BookReadController extends GetxController {
+ late BookEntity book;
+ final Rxn bookHistory = Rxn();
+ final collections = [].obs;
+
+ final RxInt currentIndex = 0.obs;
+ final RxInt totalPages = 0.obs;
+ final RxBool isLoading = false.obs;
+ final RxBool isPdfLoading = false.obs;
+ final RxnString pdfPath = RxnString();
+ final RxnString error = RxnString();
+ final Rxn pdfController = Rxn();
+ final RxBool isSoundOn = true.obs;
+ final Stopwatch _readingTimer = Stopwatch();
+ final FlutterTts _tts = FlutterTts();
+ final List _visiblePdfPages =
+ []; // PDF page indexes that are allowed to show
+ final Map _pdfToVisibleIndex =
+ {}; // PDF index -> visible index
+ final Map _pageTextByPdfIndex = {};
+ int _pdfTotalPages = 0;
+ int _lastPdfPage = -1;
+ DateTime _sessionStartedAt = DateTime.now();
+ int _initialReadingSeconds = 0;
+ bool _isBookInitialized = false;
+ bool _isBootstrapping = false;
+ bool _isDisposing = false;
+ bool _isPersistingHistory = false;
+ bool _pendingPersistHistory = false;
+ int _pageChangeTicket = 0;
+ Timer? _persistDebounce;
+ var allTextTheory = ''.obs; // berisi gabungan semua teks dari kartu
+
+ static const Duration _historyTimeout = Duration(seconds: 8);
+ static const Duration _collectionRemoteTimeout = Duration(seconds: 10);
+ static const Duration _collectionMutationTimeout = Duration(seconds: 12);
+ static const Duration _quizGenerationTimeout = Duration(seconds: 14);
+
+ final navController = Get.find();
+ final mainController = Get.find();
+
+ final ChildrenUsecase childrenUsecase;
+ final QuizUsecase quizUsecase;
+ final CollectionUsecase collectionUsecase;
+
+ BookReadController({
+ required this.childrenUsecase,
+ required this.quizUsecase,
+ required this.collectionUsecase,
+ });
+
+ @override
+ void onClose() {
+ _isDisposing = true;
+ _persistDebounce?.cancel();
+ _readingTimer.stop();
+ pdfController.value = null;
+ _visiblePdfPages.clear();
+ _pdfToVisibleIndex.clear();
+ _pageTextByPdfIndex.clear();
+ unawaited(_stopTts(force: true));
+ unawaited(pushHistoryToFirestore());
+ super.onClose();
+ }
+
+ @override
+ void onInit() {
+ super.onInit();
+ _sessionStartedAt = DateTime.now();
+ _readingTimer.start();
+ loadBook();
+ }
+
+ Future loadBook() async {
+ if (_isBootstrapping) return;
+ _isBootstrapping = true;
+
+ try {
+ var arg = Get.arguments;
+ if (arg is BookEntity) {
+ book = arg;
+ _isBookInitialized = true;
+ } else {
+ throw ArgumentError('Expected BookEntity as argument');
+ }
+
+ isLoading.value = true;
+ error.value = null;
+
+ // Keep the screen responsive: PDF is loaded in background.
+ isPdfLoading.value = true;
+ unawaited(_loadPdf().whenComplete(() {
+ if (!isClosed) {
+ isPdfLoading.value = false;
+ }
+ }));
+
+ await Future.wait([
+ initBookHistory().timeout(
+ _historyTimeout,
+ onTimeout: () {
+ EpicLog.warn(
+ 'initBookHistory timeout, continue with default state');
+ },
+ ),
+ _loadCollectionsFromLocal(),
+ ]);
+
+ // Refresh collections from remote in background; UI should not block for this.
+ unawaited(_refreshCollectionsFromRemote());
+
+ _buildAllTextTheory();
+
+ // Quiz generation is optional; run in background with timeout.
+ unawaited(_generateQuizSafely());
+ _initTts();
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'loadBook');
+ if (!isClosed && error.value == null) {
+ error.value = 'Gagal memuat buku';
+ }
+ } finally {
+ if (!isClosed) {
+ isLoading.value = false;
+ }
+ _isBootstrapping = false;
+ }
+ }
+
+ Future _loadCollectionsFromLocal() async {
+ try {
+ final children = await childrenUsecase
+ .getCurrentChildren()
+ .timeout(const Duration(seconds: 3));
+ if (!isClosed) {
+ collections.assignAll(children?.collections ?? []);
+ }
+ } on TimeoutException {
+ EpicLog.warn('_loadCollectionsFromLocal timeout');
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_loadCollectionsFromLocal');
+ }
+ }
+
+ Future _refreshCollectionsFromRemote() async {
+ try {
+ final children = await mainController.childrenUsecase
+ .getChildrenRemoteModel()
+ .timeout(_collectionRemoteTimeout);
+ if (!isClosed) {
+ collections.assignAll(children?.collections ?? []);
+ }
+ } on TimeoutException {
+ EpicLog.warn('_refreshCollectionsFromRemote timeout');
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_refreshCollectionsFromRemote');
+ }
+ }
+
+ Future _generateQuizSafely() async {
+ try {
+ await generateQuiz().timeout(
+ _quizGenerationTimeout,
+ onTimeout: () {
+ EpicLog.warn('generateQuiz timeout, skipped for this session');
+ },
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_generateQuizSafely');
+ }
+ }
+
+ Future initBookHistory() async {
+ try {
+ final bookId = book.bookId;
+ if (bookId == null || bookId.isEmpty) return;
+
+ final existing = await childrenUsecase.getBookHistoryById(bookId);
+ if (existing != null) {
+ bookHistory.value = existing;
+ _initialReadingSeconds = existing.totalReadingSeconds ?? 0;
+ }
+ await _persistBookHistory();
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_initBookHistory');
+ }
+ }
+
+ void setInitialSound(bool value) {
+ isSoundOn.value = value;
+ }
+
+ Future onPageChanged(int index) async {
+ if (_isDisposing || isClosed) return;
+ final ticket = ++_pageChangeTicket;
+
+ await _stopTts();
+ if (_isDisposing || isClosed || ticket != _pageChangeTicket) return;
+
+ if (_visiblePdfPages.isEmpty && _pdfTotalPages > 0) {
+ _rebuildVisiblePages(_pdfTotalPages);
+ }
+
+ final visibleIndex = _pdfToVisibleIndex[index];
+ if (visibleIndex != null) {
+ _lastPdfPage = index;
+ currentIndex.value = visibleIndex;
+ _debouncedPersistHistory();
+ if (_isDisposing || isClosed || ticket != _pageChangeTicket) return;
+ await _speakCurrentVisibleText();
+ return;
+ }
+
+ final direction = index >= _lastPdfPage ? 1 : -1;
+ final target = _nextVisiblePdf(index, direction);
+ if (target != null && target != index) {
+ _lastPdfPage = target;
+ pdfController.value?.setPage(target);
+ return;
+ }
+ }
+
+ void _debouncedPersistHistory() {
+ _persistDebounce?.cancel();
+ _persistDebounce = Timer(const Duration(seconds: 2), () {
+ if (!_isDisposing && !isClosed) {
+ _persistBookHistory();
+ }
+ });
+ }
+
+ void setTotalPages(int total) {
+ if (_isDisposing || isClosed) return;
+ final sanitizedTotal = total.clamp(0, 10000).toInt();
+ if (_pdfTotalPages == sanitizedTotal && _visiblePdfPages.isNotEmpty) {
+ return;
+ }
+ _pdfTotalPages = sanitizedTotal;
+ _rebuildVisiblePages(_pdfTotalPages);
+ }
+
+ void toggleSound() {
+ isSoundOn.toggle();
+ if (isSoundOn.value) {
+ _speakCurrentVisibleText();
+ } else {
+ _stopTts();
+ }
+ }
+
+ Future goTo(int index) async {
+ final controller = pdfController.value;
+ if (controller == null) return;
+ if (_visiblePdfPages.isEmpty) return;
+ if (index < 0 || index >= _visiblePdfPages.length) return;
+ await _stopTts();
+ final pdfPage = _visiblePdfPages[index];
+ await controller.setPage(pdfPage);
+ }
+
+ void onPdfViewCreated(PDFViewController controller) {
+ if (_isDisposing || isClosed) return;
+ pdfController.value = controller;
+ }
+
+ Future _loadPdf() async {
+ if (isClosed) return;
+ error.value = null;
+ pdfPath.value = null;
+ currentIndex.value = 0;
+
+ try {
+ final url = book.bookAsset;
+ if (url == null || url.isEmpty) {
+ error.value = 'Book asset tidak tersedia';
+ return;
+ }
+
+ // Check cache first — avoid re-downloading if PDF already on disk.
+ final dir = await getTemporaryDirectory();
+ final file = File('${dir.path}/book_${book.bookId ?? 'temp'}.pdf');
+ if (await file.exists()) {
+ final fileSize = await file.length();
+ if (fileSize > 0) {
+ if (!isClosed) {
+ pdfPath.value = file.path;
+ }
+ return;
+ }
+ }
+
+ final uri = Uri.parse(url);
+ final client = HttpClient()
+ ..connectionTimeout = const Duration(seconds: 20);
+ try {
+ final request = await client.getUrl(uri);
+ final response = await request.close().timeout(
+ const Duration(seconds: 30),
+ );
+ if (response.statusCode != HttpStatus.ok) {
+ error.value = 'Gagal mengunduh PDF (${response.statusCode})';
+ return;
+ }
+
+ final bytes =
+ await consolidateHttpClientResponseBytes(response).timeout(
+ const Duration(seconds: 40),
+ );
+ await file.writeAsBytes(bytes, flush: true);
+ if (!isClosed) {
+ pdfPath.value = file.path;
+ }
+ } finally {
+ client.close(force: true);
+ }
+ } on TimeoutException {
+ if (!isClosed) {
+ error.value = 'Waktu memuat PDF habis. Coba buka ulang buku.';
+ }
+ } catch (_) {
+ if (!isClosed) {
+ error.value = 'Gagal memuat PDF';
+ }
+ }
+ }
+
+ void _rebuildVisiblePages(int pdfTotal) {
+ if (_isDisposing || isClosed) return;
+ _visiblePdfPages.clear();
+ _pdfToVisibleIndex.clear();
+ _pageTextByPdfIndex.clear();
+ if (pdfTotal <= 0) {
+ totalPages.value = 0;
+ currentIndex.value = 0;
+ return;
+ }
+
+ final skipped = {};
+ final pages = book.pages;
+ if (pages != null && pages.isNotEmpty) {
+ for (var i = 0; i < pages.length; i++) {
+ final page = pages[i];
+ if (page.skipped == true) {
+ final rawIndex =
+ page.nPage != null && page.nPage! > 0 ? page.nPage! - 1 : i;
+ if (rawIndex >= 0 && rawIndex < pdfTotal) {
+ skipped.add(rawIndex);
+ }
+ }
+
+ final text = page.text?.trim();
+ if (text != null && text.isNotEmpty) {
+ final rawIndex =
+ page.nPage != null && page.nPage! > 0 ? page.nPage! - 1 : i;
+ if (rawIndex >= 0 && rawIndex < pdfTotal) {
+ _pageTextByPdfIndex[rawIndex] = text;
+ }
+ }
+ }
+ }
+
+ for (var i = 0; i < pdfTotal; i++) {
+ if (!skipped.contains(i)) {
+ _visiblePdfPages.add(i);
+ }
+ }
+
+ // Fallback: if all pages were marked skipped, show all pages to avoid empty viewer.
+ if (_visiblePdfPages.isEmpty) {
+ for (var i = 0; i < pdfTotal; i++) {
+ _visiblePdfPages.add(i);
+ }
+ }
+
+ for (var i = 0; i < _visiblePdfPages.length; i++) {
+ _pdfToVisibleIndex[_visiblePdfPages[i]] = i;
+ }
+
+ totalPages.value = _visiblePdfPages.length;
+
+ if (currentIndex.value >= totalPages.value) {
+ currentIndex.value = totalPages.value > 0 ? totalPages.value - 1 : 0;
+ }
+
+ if (_visiblePdfPages.isNotEmpty) {
+ final firstVisible = _visiblePdfPages.first;
+ if (_lastPdfPage == -1 || !_pdfToVisibleIndex.containsKey(_lastPdfPage)) {
+ _lastPdfPage = firstVisible;
+ currentIndex.value = 0;
+ pdfController.value?.setPage(firstVisible);
+ }
+ }
+
+ _speakCurrentVisibleText();
+ }
+
+ int? _nextVisiblePdf(int currentPdf, int direction) {
+ if (_visiblePdfPages.isEmpty) return null;
+ if (direction >= 0) {
+ for (final page in _visiblePdfPages) {
+ if (page > currentPdf) return page;
+ }
+ for (var i = _visiblePdfPages.length - 1; i >= 0; i--) {
+ final page = _visiblePdfPages[i];
+ if (page < currentPdf) return page;
+ }
+ } else {
+ for (var i = _visiblePdfPages.length - 1; i >= 0; i--) {
+ final page = _visiblePdfPages[i];
+ if (page < currentPdf) return page;
+ }
+ for (final page in _visiblePdfPages) {
+ if (page > currentPdf) return page;
+ }
+ }
+ return null;
+ }
+
+ void _initTts() {
+ _tts.setLanguage('id-ID');
+ _tts.setPitch(1.0);
+ _tts.setSpeechRate(0.5);
+ }
+
+ Future _speakCurrentVisibleText() async {
+ if (_isDisposing || isClosed) return;
+ if (!isSoundOn.value) {
+ await _stopTts();
+ return;
+ }
+
+ if (_visiblePdfPages.isEmpty) {
+ await _stopTts();
+ return;
+ }
+
+ final visibleIdx = currentIndex.value;
+ if (visibleIdx < 0 || visibleIdx >= _visiblePdfPages.length) {
+ await _stopTts();
+ return;
+ }
+
+ final pdfIdx = _visiblePdfPages[visibleIdx];
+ final text = _pageTextByPdfIndex[pdfIdx];
+ if (text == null || text.trim().isEmpty) {
+ await _stopTts();
+ return;
+ }
+
+ await _tts.stop();
+ await _tts.speak(text);
+ }
+
+ Future _stopTts({bool force = false}) async {
+ if (!force && (_isDisposing || isClosed)) return;
+ await _tts.stop();
+ }
+
+ Future _persistBookHistory() async {
+ if (!_isBookInitialized || _isDisposing || isClosed) return;
+ if (_isPersistingHistory) {
+ _pendingPersistHistory = true;
+ return;
+ }
+
+ _isPersistingHistory = true;
+
+ try {
+ do {
+ _pendingPersistHistory = false;
+ if (_isDisposing || isClosed) return;
+
+ final now = DateTime.now();
+ _readingTimer.stop();
+
+ final existing = bookHistory.value;
+ final total =
+ totalPages.value > 0 ? totalPages.value : (book.totalPages ?? 0);
+ int read = currentIndex.value + 1;
+ if (total > 0 && read > total) {
+ read = total;
+ }
+
+ late final BookHistoryEntity history;
+ if (existing != null) {
+ // UPDATE: preserve existing values that should not regress.
+ final mergedRead = (existing.readPages ?? 0) > read
+ ? (existing.readPages ?? 0)
+ : read;
+ final readingSeconds =
+ _initialReadingSeconds + _readingTimer.elapsed.inSeconds;
+
+ history = existing.copyWith(
+ lastReadAt: now,
+ readPages: mergedRead,
+ totalReadingSeconds: readingSeconds,
+ );
+ } else {
+ history = BookHistoryEntity(
+ bookId: book.bookId,
+ title: book.title,
+ bookCover: book.bookCover,
+ totalPages: total == 0 ? book.totalPages : total,
+ readPages: read,
+ isFinished: false,
+ startedAt: _sessionStartedAt,
+ lastReadAt: now,
+ finishedAt: null,
+ totalReadingSeconds: _readingTimer.elapsed.inSeconds,
+ isHaveQuiz: book.isHaveQuiz,
+ totalQuizes: book.totalQuizes,
+ completedQuizes: null,
+ category: book.category,
+ );
+ }
+
+ await childrenUsecase.updateBookHistory(bookHistory: history);
+ if (_isDisposing || isClosed) return;
+ bookHistory.value = history;
+ } while (_pendingPersistHistory && !_isDisposing && !isClosed);
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_persistBookHistory');
+ } finally {
+ _isPersistingHistory = false;
+ if (!_readingTimer.isRunning && !_isDisposing && !isClosed) {
+ _readingTimer.start();
+ }
+ }
+ }
+
+ Future pushHistoryToFirestore() async {
+ if (!_isBookInitialized) return;
+
+ try {
+ var history = bookHistory.value;
+ if (history != null) {
+ await childrenUsecase.updateBookHistory(
+ bookHistory: history,
+ pushToRemote: true,
+ );
+ // navController.refreshRewardBook.value++;
+ EpicLog.debug(
+ 'pushHistoryToFirestore - Book history pushed to Firestore for bookId ${book.bookId}');
+ bookHistory.value = history;
+ if (history.isFinished == true &&
+ history.claimedRewards != null &&
+ history.claimedRewards!.isEmpty) {
+ // navController.refreshRewardBook.value++;
+ }
+ } else {
+ EpicLog.debug(
+ 'pushHistoryToFirestore - No book history to push for bookId ${book.bookId}');
+ }
+ } catch (ex, s) {
+ EpicLog.exception(ex, s,
+ 'pushHistoryToFirestore - Error pushing book history to Firestore');
+ }
+ }
+
+ Future addBookToCollection({
+ required String collectionId,
+ required String bookId,
+ }) async {
+ try {
+ Get.back();
+ mainController.showLoadingPage();
+ await collectionUsecase
+ .addBookToCollection(
+ collectionId: collectionId,
+ bookId: bookId,
+ )
+ .timeout(_collectionMutationTimeout);
+ Get.back();
+ navController.refreshCollectionHome.value++;
+ Get.snackbar(
+ 'Berhasil',
+ 'Buku ditambahkan ke koleksi.',
+ snackPosition: SnackPosition.BOTTOM,
+ duration: const Duration(seconds: 2),
+ );
+ } on TimeoutException {
+ Get.snackbar(
+ 'Timeout',
+ 'Proses tambah koleksi terlalu lama. Coba lagi.',
+ snackPosition: SnackPosition.BOTTOM,
+ duration: const Duration(seconds: 3),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'addBookToCollection');
+ Get.snackbar(
+ 'Gagal',
+ 'Tidak bisa menambah buku ke koleksi.',
+ snackPosition: SnackPosition.BOTTOM,
+ duration: const Duration(seconds: 2),
+ );
+ } finally {
+ mainController.hideLoadingPage();
+ }
+ }
+
+ void _buildAllTextTheory() {
+ final texts = book.pages
+ ?.where((p) => p.skipped != true)
+ .map((p) => p.text?.trim())
+ .where((t) => t != null && t.isNotEmpty)
+ .cast()
+ .toList();
+ allTextTheory.value =
+ texts != null && texts.isNotEmpty ? texts.join('\n') : '';
+ }
+
+ Future generateQuiz({
+ bool forceGenerate = false,
+ }) async {
+ try {
+ if (book.isGenerateQuizSupported != true || book.isHaveQuiz != true) {
+ EpicLog.debug('generateQuiz - skipped, quiz is not supported by book');
+ return;
+ }
+
+ final quizType = book.quizType ?? 0;
+ if (quizType < 1 || quizType > 3) {
+ EpicLog.debug('generateQuiz - skipped, invalid quizType: $quizType');
+ return;
+ }
+
+ final text = allTextTheory.value.trim();
+ if (text.isEmpty) {
+ EpicLog.debug('generateQuiz - skipped, theory text is empty');
+ return;
+ }
+
+ if (bookHistory.value == null) {
+ await _persistBookHistory();
+ }
+
+ var history = bookHistory.value;
+ if (history == null) {
+ EpicLog.debug('generateQuiz - failed generate, book history is null');
+ return;
+ }
+
+ var quizHistory = history.quiz;
+ var isQuizAlreadyGenerated =
+ (quizHistory?.question?.trim().isNotEmpty ?? false);
+ var isNotCompleted = (quizHistory?.answer?.trim().isEmpty ?? true);
+ var totalRetry = quizHistory?.retry ?? 3;
+ var firstAnswer = totalRetry == 3;
+ var isCanGenerate = quizHistory == null ||
+ (isNotCompleted && firstAnswer && !isQuizAlreadyGenerated);
+
+ if (isCanGenerate || forceGenerate) {
+ EpicLog.debug('generateQuiz - ready for generate');
+ final previousQuestions = _extractPreviousQuestions(quizHistory);
+ final generatedQuiz = await _tryGenerateQuizByQuizType(
+ quizType: quizType,
+ text: text,
+ category: book.category,
+ previousQuestions: previousQuestions,
+ );
+ if (generatedQuiz == null) {
+ EpicLog.debug('generateQuiz - failed to generate quiz data');
+ return;
+ }
+
+ final updatedHistory = history.copyWith(quiz: generatedQuiz);
+ await childrenUsecase.updateBookHistory(bookHistory: updatedHistory);
+ bookHistory.value = updatedHistory;
+ EpicLog.debug('generateQuiz - quiz generated successfully');
+ } else {
+ EpicLog.debug('generateQuiz - rejected for generate');
+ }
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'generateQuiz');
+ }
+ }
+
+ List _extractPreviousQuestions(BookHistoryQuizEntity? quiz) {
+ if (quiz == null) return [];
+ final List previous = [];
+ if (quiz.multipleChoiceQuestion?.question != null) {
+ previous.add(quiz.multipleChoiceQuestion!.question!);
+ }
+ if (quiz.fillBlankQuestion?.sentence != null) {
+ final sentenceText = quiz.fillBlankQuestion!.sentence!
+ .map((s) => s.type == 'blank' ? '___' : (s.value ?? ''))
+ .join();
+ if (sentenceText.trim().isNotEmpty) previous.add(sentenceText);
+ }
+ if (quiz.groupQuestion?.groups != null) {
+ final groupTitles = quiz.groupQuestion!.groups!
+ .map((g) => g.title ?? '')
+ .where((t) => t.isNotEmpty)
+ .join(' vs ');
+ if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles');
+ }
+ return previous;
+ }
+
+ Future _tryGenerateQuizByQuizType({
+ required int quizType,
+ required String text,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ if (quizType == 1) {
+ final choiceQuiz = await _tryToGenerateMultipleChoiceQuiz(
+ fullText: text,
+ category: category,
+ previousQuestions: previousQuestions,
+ );
+ if (choiceQuiz != null) {
+ final remote = QuizChoiceMapper.entityToRemote(choiceQuiz);
+ return BookHistoryQuizEntity(
+ quizType: 1,
+ retry: 3,
+ question: jsonEncode(remote.toJson()),
+ answer: null,
+ explanation: choiceQuiz.explanation,
+ multipleChoiceQuestion: choiceQuiz,
+ multipleChoiceAnswer: null,
+ fillBlankQuestion: null,
+ fillBlankAnswer: null,
+ groupQuestion: null,
+ groupAnswer: null,
+ );
+ }
+ } else if (quizType == 2) {
+ final fillBlankQuiz = await _tryToGenerateFillBlankQuiz(
+ fullText: text,
+ category: category,
+ previousQuestions: previousQuestions,
+ );
+ if (fillBlankQuiz != null) {
+ final remote = QuizFillBlankMapper.entityToRemote(fillBlankQuiz);
+ return BookHistoryQuizEntity(
+ quizType: 2,
+ retry: 3,
+ question: jsonEncode(remote.toJson()),
+ answer: null,
+ explanation: fillBlankQuiz.explanation,
+ multipleChoiceQuestion: null,
+ multipleChoiceAnswer: null,
+ fillBlankQuestion: fillBlankQuiz,
+ fillBlankAnswer: null,
+ groupQuestion: null,
+ groupAnswer: null,
+ );
+ }
+ } else if (quizType == 3) {
+ final groupQuiz = await _tryToGenerateGroupQuiz(
+ fullText: text,
+ category: category,
+ previousQuestions: previousQuestions,
+ );
+ if (groupQuiz != null) {
+ final remote = QuizGroupMapper.entityToRemote(groupQuiz);
+ return BookHistoryQuizEntity(
+ quizType: 3,
+ retry: 3,
+ question: jsonEncode(remote.toJson()),
+ answer: null,
+ explanation: groupQuiz.explanation,
+ multipleChoiceQuestion: null,
+ multipleChoiceAnswer: null,
+ fillBlankQuestion: null,
+ fillBlankAnswer: null,
+ groupQuestion: groupQuiz,
+ groupAnswer: null,
+ );
+ }
+ }
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType');
+ }
+ return null;
+ }
+
+ Future _tryToGenerateMultipleChoiceQuiz({
+ required String fullText,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.multipleChoicePrompt(
+ text: fullText,
+ category: category,
+ previousQuestions: previousQuestions,
+ ),
+ );
+
+ if (quizJson == null) {
+ EpicLog.debug('_tryToGenerateMultipleChoiceQuiz - quizJson is null');
+ return null;
+ }
+
+ final parsed = jsonDecode(quizJson) as Map;
+ final quizModel = QuizChoiceRemoteModel.fromJson(parsed);
+ // === VALIDASI ===
+ if (quizModel.question == null ||
+ quizModel.choices == null ||
+ quizModel.choices!.length != 4 ||
+ quizModel.correctedIndex == null ||
+ quizModel.correctedIndex! < 0 ||
+ quizModel.correctedIndex! >= quizModel.choices!.length) {
+ EpicLog.debug(
+ '_tryToGenerateMultipleChoiceQuiz - invalid quiz structure');
+ return null;
+ }
+ final quizEntity = QuizChoiceMapper.remoteToEntity(quizModel);
+
+ EpicLog.debug('generateQuiz - quizJson: $quizJson');
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateMultipleChoiceQuiz');
+ }
+ return null;
+ }
+
+ Future _tryToGenerateFillBlankQuiz({
+ required String fullText,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.fillBlankPrompt(
+ text: fullText,
+ category: category,
+ previousQuestions: previousQuestions,
+ ),
+ );
+
+ if (quizJson == null) {
+ EpicLog.debug('_tryToGenerateFillBlankQuiz - quizJson is null');
+ return null;
+ }
+
+ final parsed = jsonDecode(quizJson) as Map;
+ final quizModel = QuizFillBlankRemoteModel.fromJson(parsed);
+ // === VALIDASI ===
+ if (quizModel.sentence == null ||
+ quizModel.options == null ||
+ quizModel.options!.length != 4 ||
+ quizModel.sentence!.where((s) => s.type == 'blank').length != 4 ||
+ quizModel.options!
+ .any((o) => o.value == null || o.correctBlankId == null)) {
+ EpicLog.debug('_tryToGenerateFillBlankQuiz - invalid quiz structure');
+ return null;
+ }
+ final quizEntity = QuizFillBlankMapper.remoteToEntity(quizModel);
+
+ EpicLog.debug('generateQuiz - quizJson: $quizJson');
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateFillBlankQuiz');
+ }
+ return null;
+ }
+
+ Future _tryToGenerateGroupQuiz({
+ required String fullText,
+ String? category,
+ List previousQuestions = const [],
+ }) async {
+ try {
+ final quizJson = await quizUsecase.generate(
+ prompt: QuizPrompt.groupQuizPrompt(
+ text: fullText,
+ category: category,
+ previousQuestions: previousQuestions,
+ ),
+ );
+
+ if (quizJson == null) {
+ EpicLog.debug('_tryToGenerateGroupQuiz - quizJson is null');
+ return null;
+ }
+
+ final parsed = jsonDecode(quizJson) as Map;
+ final quizModel = QuizGroupRemoteModel.fromJson(parsed);
+ // Validasi group quiz:
+ final groupIds = quizModel.groups?.map((g) => g.id).toSet() ?? {};
+ if (quizModel.groups == null ||
+ quizModel.groups!.length != 2 ||
+ quizModel.options == null ||
+ quizModel.options!.length != 8 ||
+ quizModel.options!.any((o) => !groupIds.contains(o.correctGroupId))) {
+ EpicLog.debug('_tryToGenerateGroupQuiz - invalid quiz structure');
+ return null;
+ }
+ final quizEntity = QuizGroupMapper.remoteToEntity(quizModel);
+
+ EpicLog.debug('generateQuiz - quizJson: $quizJson');
+ return quizEntity.copyWith(
+ quizDataHelper: quizModel.toJson().toString(),
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, '_tryToGenerateGroupQuiz');
+ }
+ return null;
+ }
+
+ Future goToQuizPage() async {
+ final currentHistory = bookHistory.value;
+ if (currentHistory?.quiz == null) return;
+ await _stopTts(force: true);
+
+ try {
+ final result = await Get.toNamed(
+ EpicRoutes.bookQuiz,
+ arguments: {
+ 'history': currentHistory,
+ 'theoryText': allTextTheory.value,
+ 'bookQuizType': book.quizType,
+ 'book': book,
+ },
+ );
+ if (result is BookHistoryEntity) {
+ bookHistory.value = result;
+ }
+ } finally {
+ await _forceLandscapeOrientation();
+ }
+ }
+
+ Future _forceLandscapeOrientation() async {
+ const landscape = [
+ DeviceOrientation.landscapeLeft,
+ DeviceOrientation.landscapeRight,
+ ];
+
+ await SystemChrome.setPreferredOrientations(landscape);
+ }
+}
diff --git a/lib/feature/books/presentation/book_read/book_read_page.dart b/lib/feature/books/presentation/book_read/book_read_page.dart
new file mode 100644
index 0000000..6439746
--- /dev/null
+++ b/lib/feature/books/presentation/book_read/book_read_page.dart
@@ -0,0 +1,340 @@
+import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
+import 'package:epic_story_app/feature/books/presentation/book_read/components/dialog_add_book_coll.dart';
+import 'package:epic_story_app/feature/books/presentation/book_read/components/dot_indicator.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_pdfview/flutter_pdfview.dart';
+import 'package:get/get.dart';
+
+/// PDF reader page driven by controller; no widget parameters (clean architecture).
+class BookReadPage extends StatefulWidget {
+ const BookReadPage({super.key});
+
+ @override
+ State createState() => _BookReadPageState();
+}
+
+class _BookReadPageState extends State {
+ final BookReadController controller = Get.find();
+
+ static const Color _mainBackground = Color(0xFFE0CEFF);
+ static const double _topBarHeight = 84;
+
+ @override
+ void initState() {
+ super.initState();
+ SystemChrome.setPreferredOrientations(
+ [DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight],
+ );
+ }
+
+ @override
+ void dispose() {
+ SystemChrome.setPreferredOrientations(DeviceOrientation.values);
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: _mainBackground,
+ body: SafeArea(
+ child: Stack(
+ children: [
+ // PDF content area
+ Positioned.fill(
+ child: Obx(() {
+ if (controller.isLoading.value) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ final path = controller.pdfPath.value;
+ if (path == null) {
+ if (controller.isPdfLoading.value) {
+ return const Center(child: CircularProgressIndicator());
+ }
+ if (controller.error.value != null) {
+ return Center(
+ child: Text(
+ controller.error.value!,
+ style: const TextStyle(color: Colors.black87),
+ ),
+ );
+ }
+ return const Center(
+ child: Text(
+ 'PDF tidak tersedia',
+ style: TextStyle(color: Colors.black87),
+ ),
+ );
+ }
+ return _PdfContent(
+ key: ValueKey(path),
+ path: path,
+ controller: controller,
+ );
+ }),
+ ),
+
+ // Top bar: back + actions
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: Obx(() {
+ final bool isBookVisible = !controller.isLoading.value &&
+ !controller.isPdfLoading.value &&
+ controller.error.value == null &&
+ controller.pdfPath.value != null;
+
+ return Container(
+ height: _topBarHeight,
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
+ decoration: BoxDecoration(
+ gradient: isBookVisible
+ ? const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.transparent,
+ Colors.transparent,
+ ],
+ )
+ : const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Color(0xFF7E32AB),
+ Color(0xFFE0CEFF),
+ ],
+ stops: [0.0, 1.0],
+ ),
+ borderRadius: const BorderRadius.vertical(
+ bottom: Radius.circular(18),
+ ),
+ ),
+ child: Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ InkWell(
+ onTap: () => Get.back(),
+ borderRadius: BorderRadius.circular(20),
+ child: Container(
+ padding: const EdgeInsets.all(4),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.42),
+ blurRadius: 10,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: const Icon(
+ Icons.reply_rounded,
+ size: 46,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ Row(
+ children: [
+ _buildTopActionButton(
+ icon: Icons.bookmark,
+ backgroundColor: const Color(0xFFFFED8C),
+ iconColor: const Color(0xFF8D8D8D),
+ onTap: () {
+ if (controller.isLoading.value) {
+ Get.snackbar(
+ 'Collection',
+ 'Tunggu sampai buku selesai dimuat.',
+ snackPosition: SnackPosition.BOTTOM,
+ duration: const Duration(seconds: 2),
+ );
+ return;
+ }
+ showAddBookCollectionSheet(
+ context,
+ controller,
+ controller.book,
+ );
+ },
+ ),
+ const SizedBox(width: 10),
+ _buildTopActionButton(
+ icon: controller.isSoundOn.value
+ ? Icons.volume_up_rounded
+ : Icons.volume_off_rounded,
+ backgroundColor: const Color(0xFF999999),
+ iconColor: controller.isSoundOn.value
+ ? const Color(0xFFF3D86A)
+ : const Color(0xFFE4E4E4),
+ onTap: controller.toggleSound,
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ }),
+ ),
+
+ // Bottom controls: prev, indicator, next
+ Positioned(
+ left: 16,
+ right: 16,
+ bottom: 24,
+ child: Obx(
+ () {
+ final idx = controller.currentIndex.value;
+ final length = controller.totalPages.value;
+ final canGoPrev = idx > 0;
+ final bool isLastPage = length > 0 && idx >= length - 1;
+ final bool hasQuiz =
+ controller.bookHistory.value?.quiz != null;
+ final bool canGoNextPage = length > 0 && !isLastPage;
+ final bool allowQuizNav = isLastPage;
+ final VoidCallback? nextAction = canGoNextPage
+ ? () => controller.goTo(idx + 1)
+ : allowQuizNav
+ ? () {
+ if (hasQuiz) {
+ controller.goToQuizPage();
+ } else {
+ Get.snackbar(
+ 'Quiz',
+ 'Quiz belum tersedia.',
+ snackPosition: SnackPosition.BOTTOM,
+ duration: const Duration(seconds: 2),
+ );
+ }
+ }
+ : null;
+ return Row(
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ _buildNavArrowButton(
+ icon: Icons.play_arrow_rounded,
+ onTap:
+ canGoPrev ? () => controller.goTo(idx - 1) : null,
+ isPrevious: true,
+ ),
+ DotsIndicator(
+ length: length,
+ index: idx,
+ activeColor: const Color(0xFFD9D9D9),
+ inactiveColor: const Color(0xFFD9D9D9),
+ activeBorderColor: const Color(0xFF2A2A2A),
+ ),
+ _buildNavArrowButton(
+ icon: Icons.play_arrow_rounded,
+ onTap: nextAction,
+ isPrevious: false,
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildTopActionButton({
+ required IconData icon,
+ required Color backgroundColor,
+ required Color iconColor,
+ required VoidCallback onTap,
+ }) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: Ink(
+ width: 52,
+ height: 52,
+ decoration: BoxDecoration(
+ color: backgroundColor,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.black.withOpacity(0.5), width: 1),
+ ),
+ child: Icon(icon, size: 32, color: iconColor),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildNavArrowButton({
+ required IconData icon,
+ required VoidCallback? onTap,
+ required bool isPrevious,
+ }) {
+ final disabled = onTap == null;
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(12),
+ child: Ink(
+ width: 62,
+ height: 62,
+ decoration: BoxDecoration(
+ color: disabled ? const Color(0xFF9CC8F2) : const Color(0xFF66B0FF),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: const Color(0xFF2A2A2A), width: 1),
+ ),
+ child: Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.identity()..rotateZ(isPrevious ? 3.14159 : 0),
+ child: Icon(
+ icon,
+ color:
+ disabled ? const Color(0xFF2F2F2F) : const Color(0xFF161616),
+ size: 36,
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// Extracted PDF widget to avoid rebuild when parent Obx re-evaluates.
+class _PdfContent extends StatelessWidget {
+ const _PdfContent({
+ super.key,
+ required this.path,
+ required this.controller,
+ });
+
+ final String path;
+ final BookReadController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ return ClipRect(
+ child: SizedBox.expand(
+ child: PDFView(
+ filePath: path,
+ enableSwipe: true,
+ swipeHorizontal: true,
+ autoSpacing: true,
+ pageFling: true,
+ pageSnap: true,
+ fitPolicy: FitPolicy.HEIGHT,
+ preventLinkNavigation: true,
+ onViewCreated: controller.onPdfViewCreated,
+ onRender: (pages) =>
+ controller.setTotalPages((pages ?? 0).clamp(0, 10000)),
+ onPageChanged: (page, total) {
+ if (page != null) controller.onPageChanged(page);
+ if (total != null) controller.setTotalPages(total);
+ },
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_read/components/circle_button.dart b/lib/feature/books/presentation/book_read/components/circle_button.dart
new file mode 100644
index 0000000..93ca40e
--- /dev/null
+++ b/lib/feature/books/presentation/book_read/components/circle_button.dart
@@ -0,0 +1,44 @@
+import 'package:flutter/material.dart';
+
+class CircleButton extends StatelessWidget {
+ const CircleButton({
+ super.key,
+ required this.icon,
+ this.onTap,
+ this.disabled = false,
+ });
+
+ final IconData icon;
+ final VoidCallback? onTap;
+ final bool disabled;
+
+ @override
+ Widget build(BuildContext context) {
+ final color = disabled ? Colors.grey.shade300 : Colors.white;
+ final iconColor = disabled ? Colors.grey : Colors.black87;
+
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: disabled ? null : onTap,
+ customBorder: const CircleBorder(),
+ child: Ink(
+ width: 48,
+ height: 48,
+ decoration: BoxDecoration(
+ color: color,
+ shape: BoxShape.circle,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.08),
+ blurRadius: 10,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Icon(icon, color: iconColor, size: 22),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart b/lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart
new file mode 100644
index 0000000..fbd7502
--- /dev/null
+++ b/lib/feature/books/presentation/book_read/components/dialog_add_book_coll.dart
@@ -0,0 +1,270 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/domain/entities/book_entity.dart';
+import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
+import 'package:epic_story_app/feature/books/presentation/book_read/book_read_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+const List _fallbackGradientIndexes = [2, 7, 6];
+const List _thumbAssets = [
+ 'assets/images/book-bhs-indo.png',
+ 'assets/images/book-matematika.png',
+ 'assets/images/tipe-book.png',
+];
+
+void showAddBookCollectionSheet(
+ BuildContext context,
+ BookReadController controller,
+ BookEntity book,
+) {
+ Get.bottomSheet(
+ SafeArea(
+ top: false,
+ child: Obx(
+ () {
+ final items = controller.collections;
+ final media = MediaQuery.of(context);
+ final isLandscape = media.orientation == Orientation.landscape;
+ final sheetMaxWidth = isLandscape
+ ? (media.size.width * 0.52).clamp(300.0, 430.0).toDouble()
+ : media.size.width;
+ final listMaxHeight = media.size.height * (isLandscape ? 0.34 : 0.50);
+
+ return Align(
+ alignment: Alignment.bottomCenter,
+ child: ConstrainedBox(
+ constraints: BoxConstraints(maxWidth: sheetMaxWidth),
+ child: Container(
+ decoration: const BoxDecoration(
+ color: Color(0xFFC6B2E6),
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(32),
+ ),
+ ),
+ padding: const EdgeInsets.fromLTRB(12, 10, 12, 12),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Collection',
+ style: EpicSize.titleLarge.copyWith(
+ fontSize: 22,
+ fontWeight: FontWeight.w800,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ const SizedBox(height: 8),
+ if (items.isEmpty)
+ Padding(
+ padding: const EdgeInsets.symmetric(vertical: 16),
+ child: Text(
+ 'Belum ada koleksi buku.',
+ style: EpicSize.bodyMedium.copyWith(
+ fontSize: 14,
+ color: const Color(0xFF333333),
+ ),
+ ),
+ )
+ else
+ ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: listMaxHeight,
+ ),
+ child: ListView.separated(
+ shrinkWrap: true,
+ itemCount: items.length,
+ separatorBuilder: (_, __) =>
+ const SizedBox(height: 8),
+ itemBuilder: (_, index) {
+ final item = items[index];
+ final colorIndex = item.bgColor ??
+ _fallbackGradientIndexes[
+ index % _fallbackGradientIndexes.length];
+ final gradient =
+ collectionColorAt(colorIndex).gradient;
+ final thumbAsset =
+ _thumbAssets[index % _thumbAssets.length];
+
+ return _BookCollectionSheetTile(
+ item: item,
+ thumbnailAsset: thumbAsset,
+ gradient: gradient,
+ onAdd: () {
+ controller.addBookToCollection(
+ collectionId: item.id,
+ bookId: book.bookId ?? '',
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ );
+}
+
+class _BookCollectionSheetTile extends StatelessWidget {
+ const _BookCollectionSheetTile({
+ required this.item,
+ required this.thumbnailAsset,
+ required this.gradient,
+ required this.onAdd,
+ });
+
+ final ChildrenCollectionEntity item;
+ final String thumbnailAsset;
+ final LinearGradient gradient;
+ final VoidCallback onAdd;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ gradient: gradient,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(color: const Color(0x99FFFFFF), width: 1.1),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0x22000000),
+ blurRadius: 6,
+ offset: const Offset(0, 1.5),
+ ),
+ ],
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ _BookThumbnail(
+ coverUrl: item.bookCovers?.isNotEmpty == true
+ ? item.bookCovers!.first
+ : null,
+ fallbackAssetPath: thumbnailAsset,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: EpicSize.titleMedium.copyWith(
+ fontSize: 15,
+ fontWeight: FontWeight.w900,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ const SizedBox(height: 4),
+ _chip(
+ icon: Icons.menu_book_rounded,
+ text: 'Total Buku : ${item.nBooks ?? 0}',
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 6),
+ GestureDetector(
+ onTap: onAdd,
+ child: Container(
+ width: 34,
+ height: 34,
+ decoration: const BoxDecoration(
+ color: Color.fromARGB(255, 47, 85, 255),
+ shape: BoxShape.circle,
+ ),
+ child: const Icon(
+ Icons.add_rounded,
+ size: 22,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _chip({required String text, IconData? icon}) {
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.55),
+ borderRadius: BorderRadius.circular(6),
+ border: Border.all(color: const Color(0xFF111111), width: 0.9),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(
+ icon,
+ size: 12,
+ color: const Color(0xFF111111),
+ ),
+ const SizedBox(width: 4),
+ ],
+ Text(
+ text,
+ style: EpicSize.bodySmall.copyWith(
+ fontSize: 11,
+ fontWeight: FontWeight.w600,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _BookThumbnail extends StatelessWidget {
+ const _BookThumbnail({
+ required this.coverUrl,
+ required this.fallbackAssetPath,
+ });
+
+ final String? coverUrl;
+ final String fallbackAssetPath;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: 50,
+ height: 50,
+ decoration: BoxDecoration(
+ color: const Color(0xFFFFFFFF),
+ borderRadius: BorderRadius.circular(10),
+ border: Border.all(color: const Color(0x44FFFFFF), width: 1.1),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: (coverUrl != null && coverUrl!.trim().isNotEmpty)
+ ? Image.network(
+ coverUrl!,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => Image.asset(
+ fallbackAssetPath,
+ fit: BoxFit.cover,
+ ),
+ )
+ : Image.asset(
+ fallbackAssetPath,
+ fit: BoxFit.cover,
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_read/components/dot_indicator.dart b/lib/feature/books/presentation/book_read/components/dot_indicator.dart
new file mode 100644
index 0000000..d8fc99f
--- /dev/null
+++ b/lib/feature/books/presentation/book_read/components/dot_indicator.dart
@@ -0,0 +1,51 @@
+import 'package:flutter/material.dart';
+
+class DotsIndicator extends StatelessWidget {
+ const DotsIndicator({
+ super.key,
+ required this.length,
+ required this.index,
+ this.activeColor = const Color(0xFF1F1F1F),
+ this.inactiveColor = const Color(0xFFD9D9D9),
+ this.activeBorderColor,
+ });
+
+ final int length;
+ final int index;
+ final Color activeColor;
+ final Color inactiveColor;
+ final Color? activeBorderColor;
+
+ @override
+ Widget build(BuildContext context) {
+ if (length == 0) return const SizedBox.shrink();
+
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: List.generate(length, (i) {
+ final isActive = i == index;
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 200),
+ margin: const EdgeInsets.symmetric(horizontal: 4),
+ width: isActive ? 12 : 8,
+ height: isActive ? 12 : 8,
+ decoration: BoxDecoration(
+ color: isActive ? activeColor : inactiveColor,
+ shape: BoxShape.circle,
+ border: isActive && activeBorderColor != null
+ ? Border.all(color: activeBorderColor!, width: 1)
+ : null,
+ boxShadow: isActive
+ ? [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.12),
+ blurRadius: 8,
+ )
+ ]
+ : null,
+ ),
+ );
+ }),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_reward/book_reward_binding.dart b/lib/feature/books/presentation/book_reward/book_reward_binding.dart
new file mode 100644
index 0000000..c436398
--- /dev/null
+++ b/lib/feature/books/presentation/book_reward/book_reward_binding.dart
@@ -0,0 +1,18 @@
+import 'package:get/get.dart';
+
+import '../../../../data/modules/children_module.dart';
+import '../../../../domain/usecases/children_usecase.dart';
+
+import 'book_reward_controller.dart';
+
+class BookRewardBinding extends Bindings {
+ @override
+ void dependencies() {
+ ChildrenModule();
+ Get.lazyPut(
+ () => BookRewardController(
+ childrenUsecase: Get.find(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/books/presentation/book_reward/book_reward_controller.dart b/lib/feature/books/presentation/book_reward/book_reward_controller.dart
new file mode 100644
index 0000000..42874a9
--- /dev/null
+++ b/lib/feature/books/presentation/book_reward/book_reward_controller.dart
@@ -0,0 +1,124 @@
+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/book_entity.dart';
+import 'package:epic_story_app/domain/entities/book_histories_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:get/get.dart';
+
+class BookRewardController extends GetxController {
+ BookRewardController({
+ required this.childrenUsecase,
+ });
+
+ static const int maxSelection = 3;
+
+ final ChildrenUsecase childrenUsecase;
+ final mainController = Get.find();
+
+ late BookEntity book;
+
+ final rewards = [].obs;
+ final selectedQueue = [].obs;
+ final bookHistory = Rxn();
+
+ int get selectedCount => selectedQueue.length;
+ int get remainingSelection =>
+ (maxSelection - selectedCount).clamp(0, maxSelection);
+ bool get hasRewards => rewards.isNotEmpty;
+
+ List get selectedRewardsOrdered => selectedQueue
+ .where((index) => index >= 0 && index < rewards.length)
+ .map((index) => rewards[index])
+ .toList();
+
+ @override
+ void onInit() async {
+ super.onInit();
+ book = const BookEntity();
+
+ final argument = Get.arguments;
+ if (argument != null && argument is BookEntity) {
+ book = argument;
+ final bookId = book.bookId;
+ if (bookId != null && bookId.isNotEmpty) {
+ bookHistory.value = await childrenUsecase.getBookHistoryById(bookId);
+ }
+ }
+
+ _initRewards();
+ }
+
+ void _initRewards() {
+ var rewardList = book.rewards ?? [];
+ rewards.clear();
+ rewards.addAll(rewardList);
+ }
+
+ void selectReward(int index) {
+ if (index < 0 || index >= rewards.length) return;
+
+ if (selectedQueue.contains(index)) {
+ selectedQueue.remove(index);
+ return;
+ }
+
+ if (selectedQueue.length >= maxSelection) {
+ selectedQueue.removeAt(0);
+ }
+
+ selectedQueue.add(index);
+ }
+
+ void removeSelectionAt(int slotIndex) {
+ if (slotIndex < 0 || slotIndex >= selectedQueue.length) return;
+ selectedQueue.removeAt(slotIndex);
+ }
+
+ Future claimReward() async {
+ try {
+ if (selectedQueue.length < maxSelection) {
+ EpicSnackBar.showWarningSnackBar(
+ 'Pilih Reward',
+ 'Silakan pilih $maxSelection gambar terlebih dahulu.',
+ );
+ return;
+ }
+
+ final history = bookHistory.value;
+ if (history == null) {
+ EpicSnackBar.showErrorSnackBar(
+ 'Error',
+ 'Riwayat buku tidak ditemukan.',
+ );
+ return;
+ }
+
+ final newHistory = history.copyWith(
+ claimedRewards: List.from(selectedQueue),
+ );
+
+ mainController.showLoadingPage();
+ await childrenUsecase.updateBookHistory(
+ bookHistory: newHistory,
+ pushToRemote: true,
+ );
+ bookHistory.value = newHistory;
+
+ mainController.hideLoadingPage();
+ Get.back(result: true);
+ EpicSnackBar.showSuccessSnackBar(
+ 'Success',
+ 'Rewards claimed successfully!',
+ );
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'claimReward');
+ EpicSnackBar.showErrorSnackBar(
+ 'Error',
+ 'Failed to claim rewards. Please try again.',
+ );
+ } finally {
+ mainController.hideLoadingPage();
+ }
+ }
+}
diff --git a/lib/feature/books/presentation/book_reward/book_reward_page.dart b/lib/feature/books/presentation/book_reward/book_reward_page.dart
new file mode 100644
index 0000000..7ca603f
--- /dev/null
+++ b/lib/feature/books/presentation/book_reward/book_reward_page.dart
@@ -0,0 +1,372 @@
+import 'package:epic_story_app/feature/books/presentation/book_reward/book_reward_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class BookRewardPage extends GetView {
+ const BookRewardPage({super.key});
+
+ static const Color _pageBackground = Color(0xFFE0CEFF);
+ static const Color _contentBackground = Color(0xFFA991D2);
+ static const Color _scrollbarColor = Color(0xFFCACACA);
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: _pageBackground,
+ body: Obx(
+ () {
+ final rewards = controller.rewards;
+ final remaining = controller.remainingSelection;
+
+ return Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
+ child: Column(
+ children: [
+ const _RewardBadgeHeader(),
+ const SizedBox(height: 14),
+ const Text(
+ 'Silahkan pilih 3 gambar\nsebagai reward buku',
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w800,
+ color: Color(0xFF1E1532),
+ height: 1.32,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ controller.selectedCount == 0
+ ? 'Kamu belum memilih gambar'
+ : 'Kamu sudah memilih ${controller.selectedCount} gambar sekarang kurang $remaining lagi',
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 14,
+ color: Color(0xFF2D1F49),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 14),
+ _SelectedSlotsRow(controller: controller),
+ const SizedBox(height: 14),
+ ],
+ ),
+ ),
+ Expanded(
+ child: Container(
+ width: double.infinity,
+ decoration: const BoxDecoration(
+ color: _contentBackground,
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(14),
+ ),
+ ),
+ child: RawScrollbar(
+ thumbVisibility: true,
+ trackVisibility: true,
+ thickness: 6.5,
+ radius: const Radius.circular(6),
+ thumbColor: _scrollbarColor,
+ trackColor: _scrollbarColor.withOpacity(0.45),
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(18, 0, 18, 14),
+ child: _buildGrid(rewards),
+ ),
+ ),
+ ),
+ ),
+ _claimButton(),
+ ],
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildGrid(List rewards) {
+ if (rewards.isEmpty) {
+ return _emptyList();
+ }
+
+ return GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ mainAxisSpacing: 18,
+ crossAxisSpacing: 18,
+ childAspectRatio: 1,
+ ),
+ itemCount: rewards.length,
+ itemBuilder: (context, index) {
+ final isSelected = controller.selectedQueue.contains(index);
+ return _rewardTile(
+ imageUrl: rewards[index],
+ isSelected: isSelected,
+ onTap: () => controller.selectReward(index),
+ );
+ },
+ );
+ }
+
+ Widget _rewardTile({
+ required String imageUrl,
+ required bool isSelected,
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ onTap: onTap,
+ child: AnimatedContainer(
+ duration: const Duration(milliseconds: 180),
+ decoration: BoxDecoration(
+ color: _pageBackground,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: isSelected
+ ? const Color(0xFF33234A)
+ : Colors.black.withOpacity(0.42),
+ width: isSelected ? 2.2 : 1.1,
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(12.5),
+ child: _networkImage(imageUrl, key: ValueKey(imageUrl)),
+ ),
+ ),
+ );
+ }
+
+ Widget _networkImage(String url, {Key? key}) {
+ return Image.network(
+ url,
+ key: key,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) => _imageFallback(),
+ loadingBuilder: (context, child, loadingProgress) {
+ if (loadingProgress == null) return child;
+ return ColoredBox(
+ color: _pageBackground,
+ child: const Center(
+ child: SizedBox(
+ width: 26,
+ height: 26,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.4,
+ color: Color(0xFF6B4F92),
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _emptyList() {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(16),
+ decoration: BoxDecoration(
+ color: _pageBackground,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(color: Colors.black.withOpacity(0.3)),
+ ),
+ child: const Row(
+ children: [
+ Icon(Icons.info_outline, size: 18, color: Color(0xFF2F2249)),
+ SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Belum ada reward untuk buku ini.',
+ style: TextStyle(
+ color: Color(0xFF2F2249),
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _imageFallback() {
+ return Container(
+ color: _pageBackground,
+ alignment: Alignment.center,
+ child: const Icon(
+ Icons.broken_image_outlined,
+ size: 30,
+ color: Color(0xFF6A5A83),
+ ),
+ );
+ }
+
+ Widget _claimButton() {
+ final canClaim =
+ controller.selectedCount == BookRewardController.maxSelection;
+
+ return SafeArea(
+ top: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(66, 10, 66, 12),
+ child: Opacity(
+ opacity: canClaim ? 1 : 0.68,
+ child: GestureDetector(
+ onTap: canClaim ? controller.claimReward : null,
+ child: SizedBox(
+ width: double.infinity,
+ child: Image.asset(
+ 'assets/images/buttom-ambil-reward.png',
+ fit: BoxFit.fitWidth,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _SelectedSlotsRow extends StatelessWidget {
+ const _SelectedSlotsRow({required this.controller});
+
+ final BookRewardController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ final selected = controller.selectedRewardsOrdered;
+ final maxSlot = BookRewardController.maxSelection;
+
+ return Row(
+ children: List.generate(maxSlot, (slotIndex) {
+ return Expanded(
+ child: Padding(
+ padding: EdgeInsets.only(right: slotIndex == maxSlot - 1 ? 0 : 12),
+ child: _SlotPreview(
+ imageUrl:
+ slotIndex < selected.length ? selected[slotIndex] : null,
+ slotIndex: slotIndex,
+ onRemove: () => controller.removeSelectionAt(slotIndex),
+ ),
+ ),
+ );
+ }),
+ );
+ }
+}
+
+class _SlotPreview extends StatelessWidget {
+ const _SlotPreview({
+ required this.imageUrl,
+ required this.slotIndex,
+ required this.onRemove,
+ });
+
+ final String? imageUrl;
+ final int slotIndex;
+ final VoidCallback onRemove;
+
+ @override
+ Widget build(BuildContext context) {
+ final hasImage = imageUrl != null && imageUrl!.isNotEmpty;
+ return GestureDetector(
+ onTap: hasImage ? onRemove : null,
+ child: AspectRatio(
+ aspectRatio: 1,
+ child: Container(
+ decoration: BoxDecoration(
+ color: const Color(0xFFE0CEFF),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: Colors.black.withOpacity(0.5),
+ width: 1.1,
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(14.5),
+ child: hasImage
+ ? Image.network(
+ imageUrl!,
+ fit: BoxFit.cover,
+ loadingBuilder: (context, child, loadingProgress) {
+ if (loadingProgress == null) return child;
+ return Container(
+ color: const Color(0xFFE0CEFF),
+ alignment: Alignment.center,
+ child: const SizedBox(
+ width: 20,
+ height: 20,
+ child: CircularProgressIndicator(
+ strokeWidth: 2.1,
+ color: Color(0xFF6B4F92),
+ ),
+ ),
+ );
+ },
+ errorBuilder: (context, error, stackTrace) =>
+ _slotFallback(),
+ )
+ : _slotPlaceholder(slotIndex),
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _slotPlaceholder(int slotIndex) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(
+ Icons.image_outlined,
+ color: Color(0xFF6A5A83),
+ size: 26,
+ ),
+ const SizedBox(height: 6),
+ Text(
+ 'Slot\ngambar\nke ${slotIndex + 1}',
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 11,
+ color: Color(0xFF33254B),
+ height: 1.2,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _slotFallback() {
+ return Container(
+ color: const Color(0xFFE0CEFF),
+ alignment: Alignment.center,
+ child: const Icon(
+ Icons.broken_image_outlined,
+ size: 22,
+ color: Color(0xFF6A5A83),
+ ),
+ );
+ }
+}
+
+class _RewardBadgeHeader extends StatelessWidget {
+ const _RewardBadgeHeader();
+
+ @override
+ Widget build(BuildContext context) {
+ return SizedBox(
+ height: 200,
+ width: double.infinity,
+ child: Image.asset(
+ 'assets/images/header-claim-reward.png',
+ fit: BoxFit.contain,
+ alignment: Alignment.center,
+ ),
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/create_collection/create_collection_args.dart b/lib/feature/favorites/presentation/create_collection/create_collection_args.dart
new file mode 100644
index 0000000..f189b1c
--- /dev/null
+++ b/lib/feature/favorites/presentation/create_collection/create_collection_args.dart
@@ -0,0 +1,16 @@
+class CreateCollectionArgs {
+ final bool isEdit;
+ final String type;
+ final String collectionId;
+ final String? title;
+ final String? desc;
+ final int? bgColor;
+
+ const CreateCollectionArgs.edit({
+ required this.type,
+ required this.collectionId,
+ this.title,
+ this.desc,
+ this.bgColor,
+ }) : isEdit = true;
+}
diff --git a/lib/feature/favorites/presentation/create_collection/create_collection_binding.dart b/lib/feature/favorites/presentation/create_collection/create_collection_binding.dart
new file mode 100644
index 0000000..46f6650
--- /dev/null
+++ b/lib/feature/favorites/presentation/create_collection/create_collection_binding.dart
@@ -0,0 +1,16 @@
+import 'package:epic_story_app/data/modules/collection_module.dart';
+import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
+import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_controller.dart';
+import 'package:get/get.dart';
+
+class CreateCollectionBinding extends Bindings {
+ @override
+ void dependencies() {
+ CollectionModule();
+ Get.lazyPut(
+ () => CreateCollectionController(
+ collectionUsecase: Get.find(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/create_collection/create_collection_controller.dart b/lib/feature/favorites/presentation/create_collection/create_collection_controller.dart
new file mode 100644
index 0000000..13443da
--- /dev/null
+++ b/lib/feature/favorites/presentation/create_collection/create_collection_controller.dart
@@ -0,0 +1,187 @@
+import 'package:epic_story_app/core/utils/epic_log.dart';
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
+import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
+import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
+import 'package:epic_story_app/feature/others/main_controller/main_controller.dart';
+import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_args.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class CreateCollectionController extends GetxController {
+ final CollectionUsecase collectionUsecase;
+
+ CreateCollectionController({
+ required this.collectionUsecase,
+ });
+
+ final TextEditingController nameCtrl = TextEditingController();
+ final TextEditingController descCtrl = TextEditingController();
+
+ final mainController = Get.find();
+
+ final types = const ['Buku', 'Flashcard'];
+ final RxString selectedType = ''.obs;
+
+ final List palette = kCollectionColorPalette;
+
+ final RxInt selectedIndex = (-1).obs;
+ final RxBool isSaving = false.obs;
+ final RxBool isEditMode = false.obs;
+ String? editingCollectionId;
+
+ CollectionColorModel? get selectedColorModel =>
+ selectedIndex.value >= 0 ? palette[selectedIndex.value] : null;
+
+ Color? get selectedColor => selectedColorModel?.color;
+
+ LinearGradient? get selectedGradient => selectedColorModel?.gradient;
+
+ @override
+ void onInit() {
+ super.onInit();
+ _loadArgs();
+ }
+
+ void _loadArgs() {
+ final args = Get.arguments;
+ if (args is! CreateCollectionArgs || !args.isEdit) return;
+
+ isEditMode.value = true;
+ editingCollectionId = args.collectionId;
+ selectedType.value = args.type;
+ nameCtrl.text = args.title ?? '';
+ descCtrl.text = args.desc ?? '';
+
+ final colorIndex = args.bgColor;
+ if (colorIndex != null && colorIndex >= 0 && colorIndex < palette.length) {
+ selectedIndex.value = colorIndex;
+ }
+ }
+
+ Future onSubmit() async {
+ final title = nameCtrl.text.trim();
+ final desc = descCtrl.text.trim();
+ final colorIndex = selectedIndex.value;
+ final type = selectedType.value;
+
+ if (title.isEmpty) {
+ Get.snackbar('Gagal', 'Nama koleksi tidak boleh kosong');
+ return;
+ }
+ if (colorIndex < 0 || colorIndex >= palette.length) {
+ Get.snackbar('Gagal', 'Pilih warna background terlebih dahulu');
+ return;
+ }
+ if (type.isEmpty) {
+ Get.snackbar('Gagal', 'Pilih tipe koleksi');
+ return;
+ }
+
+ isSaving.value = true;
+ final now = DateTime.now();
+ final generatedId = now.millisecondsSinceEpoch.toString();
+
+ EpicLog.debug("type selected: ${selectedType.value}");
+
+ try {
+ mainController.showLoadingPage();
+ if (isEditMode.value) {
+ final collectionId = editingCollectionId;
+ if (collectionId == null || collectionId.isEmpty) {
+ Get.snackbar('Gagal', 'Koleksi tidak ditemukan');
+ return;
+ }
+
+ if (type == 'Buku') {
+ final updatedCollection = ChildrenCollectionEntity(
+ id: collectionId,
+ bgColor: colorIndex,
+ title: title,
+ desc: desc.isEmpty ? null : desc,
+ updatedAt: now,
+ );
+ await collectionUsecase.updateCollection(
+ collection: updatedCollection,
+ );
+ } else {
+ final updatedCollection = FlashCardCollectionEntity(
+ collectionId: collectionId,
+ title: title,
+ desc: desc.isEmpty ? null : desc,
+ bgColor: colorIndex,
+ updatedAt: now,
+ );
+ await collectionUsecase.updateFlashcardCollection(
+ collection: updatedCollection,
+ );
+ }
+ mainController.hideLoadingPage();
+ Get.back(result: true);
+ Get.snackbar('Berhasil', 'Koleksi berhasil diperbarui');
+ return;
+ }
+
+ if (selectedType.value == 'Buku') {
+ final bookCollection = ChildrenCollectionEntity(
+ id: generatedId,
+ bgColor: colorIndex,
+ title: title,
+ desc: desc.isEmpty ? null : desc,
+ nBooks: 0,
+ nPages: 0,
+ bookCovers: const [],
+ categories: const [],
+ createdAt: now,
+ updatedAt: now,
+ );
+ await collectionUsecase.createCollection(collection: bookCollection);
+ } else {
+ var flashcardCollection = FlashCardCollectionEntity(
+ collectionId: generatedId,
+ title: title,
+ desc: desc.isEmpty ? null : desc,
+ bgColor: colorIndex,
+ categories: const [],
+ totalCards: 0,
+ totalFlashcard: 0,
+ totalQuiz: 0,
+ completedQuiz: 0,
+ createdAt: now,
+ updatedAt: now,
+ );
+ await collectionUsecase.createFlashcardCollection(
+ collection: flashcardCollection,
+ );
+ }
+ mainController.hideLoadingPage();
+ Get.back(result: true);
+ Get.snackbar('Berhasil', 'Koleksi berhasil dibuat');
+ } catch (e) {
+ Get.snackbar(
+ 'Gagal',
+ isEditMode.value
+ ? 'Tidak dapat memperbarui koleksi'
+ : 'Tidak dapat membuat koleksi',
+ );
+ } finally {
+ isSaving.value = false;
+ mainController.hideLoadingPage();
+ }
+ }
+
+ void selectColor(int index) {
+ selectedIndex.value = index;
+ }
+
+ void selectType(String? value) {
+ if (value != null) selectedType.value = value;
+ }
+
+ @override
+ void onClose() {
+ nameCtrl.dispose();
+ descCtrl.dispose();
+ super.onClose();
+ }
+}
diff --git a/lib/feature/favorites/presentation/create_collection/create_collection_page.dart b/lib/feature/favorites/presentation/create_collection/create_collection_page.dart
new file mode 100644
index 0000000..a6121f7
--- /dev/null
+++ b/lib/feature/favorites/presentation/create_collection/create_collection_page.dart
@@ -0,0 +1,274 @@
+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/core/widgets/cards/stacked_info_top_bar.dart';
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class CreateCollectionPage extends StatefulWidget {
+ const CreateCollectionPage({super.key});
+
+ @override
+ State createState() => _CreateCollectionPageState();
+}
+
+class _CreateCollectionPageState extends State {
+ late final CreateCollectionController controller =
+ Get.find();
+
+ static const Color _pageBackground = Color(0xFFC6B2E6);
+ static const Color _inputBorder = Color(0xFF22162F);
+ static const Color _saveButton = Color(0xFF8BDE88);
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: _pageBackground,
+ body: SafeArea(
+ bottom: false,
+ child: Column(
+ children: [
+ Obx(() {
+ final isEditMode = controller.isEditMode.value;
+ return StackedInfoTopBar(
+ titleText: isEditMode ? 'Edit Koleksi' : 'Buat Koleksi',
+ infoText: isEditMode
+ ? 'Silahkan ubah koleksi yang\nsudah kamu buat sebelumnya'
+ : 'Silahkan buat koleksi dari\nbuku/flashcard yang ingin\nkamu pelajari selanjutnya',
+ isBackButtonVisible: true,
+ onBackTap: () => Get.back(),
+ );
+ }),
+ Expanded(
+ child: SingleChildScrollView(
+ physics: const BouncingScrollPhysics(),
+ padding: EdgeInsets.fromLTRB(
+ EpicSize.calculatedSize(30),
+ EpicSize.calculatedSize(10),
+ EpicSize.calculatedSize(30),
+ EpicSize.calculatedSize(28),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SizedBox(
+ height: EpicAppSize.calculatedSize(16),
+ ),
+ _FieldLabel(text: 'Nama Koleksi'),
+ SizedBox(
+ height: EpicAppSize.calculatedSize(10),
+ ),
+ TextField(
+ controller: controller.nameCtrl,
+ decoration: _inputDecoration(),
+ ),
+ SizedBox(
+ height: EpicAppSize.calculatedSize(16),
+ ),
+ _FieldLabel(text: 'Tipe Koleksi'),
+ SizedBox(
+ height: EpicAppSize.calculatedSize(10),
+ ),
+ Obx(
+ () => DropdownButtonFormField(
+ value: controller.selectedType.value.isEmpty
+ ? null
+ : controller.selectedType.value,
+ hint: const Text(
+ 'Pilih satu (dropdown)',
+ style: TextStyle(
+ color: Colors.black,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ items: controller.types
+ .map(
+ (t) => DropdownMenuItem(
+ value: t,
+ child: Text(t),
+ ),
+ )
+ .toList(),
+ onChanged: controller.isEditMode.value
+ ? null
+ : controller.selectType,
+ icon: const Icon(Icons.keyboard_arrow_down_rounded),
+ style: const TextStyle(
+ color: Colors.black,
+ fontWeight: FontWeight.w600,
+ ),
+ decoration: _inputDecoration(),
+ isExpanded: true,
+ dropdownColor: _pageBackground,
+ ),
+ ),
+ SizedBox(height: EpicAppSize.calculatedSize(20)),
+ _FieldLabel(text: 'Deskripsi Koleksi'),
+ SizedBox(height: EpicAppSize.calculatedSize(10)),
+ TextField(
+ controller: controller.descCtrl,
+ decoration: _inputDecoration(),
+ minLines: 5,
+ maxLines: 5,
+ ),
+ SizedBox(height: EpicAppSize.calculatedSize(22)),
+ _FieldLabel(text: 'Background Koleksi'),
+ SizedBox(height: EpicAppSize.calculatedSize(10)),
+ LayoutBuilder(
+ builder: (context, constraints) {
+ final spacing = EpicSize.calculatedSize(18);
+
+ return GridView.builder(
+ shrinkWrap: true,
+ itemCount: controller.palette.length,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate:
+ SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 5,
+ mainAxisSpacing: spacing,
+ crossAxisSpacing: spacing,
+ childAspectRatio: 1,
+ ),
+ itemBuilder: (context, index) {
+ final colorModel = controller.palette[index];
+ return Obx(
+ () {
+ final selected =
+ controller.selectedIndex.value == index;
+ return GestureDetector(
+ onTap: () => controller.selectColor(index),
+ child: _ColorSwatch(
+ colorModel: colorModel,
+ selected: selected,
+ ),
+ );
+ },
+ );
+ },
+ );
+ },
+ ),
+ SizedBox(height: EpicSize.calculatedSize(32)),
+ Obx(
+ () => controller.isSaving.value
+ ? const Center(child: CircularProgressIndicator())
+ : const SizedBox.shrink(),
+ ),
+ SizedBox(height: EpicSize.sizeSmall),
+ Center(
+ child: GestureDetector(
+ onTap: controller.onSubmit,
+ child: Container(
+ width: EpicSize.calculatedSize(220),
+ height: EpicSize.calculatedSize(52),
+ decoration: BoxDecoration(
+ color: _saveButton,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: const Color(0xFF1A9E1A),
+ width: 1.2,
+ ),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x3F000000),
+ offset: Offset(0, 4),
+ blurRadius: 6,
+ ),
+ ],
+ ),
+ child: Center(
+ child: Text(
+ 'Simpan',
+ style: TextStyle(
+ color: Colors.black,
+ fontSize: EpicAppSize.calculatedSize(22),
+ fontWeight: FontWeight.w900,
+ height: 0.9,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ InputDecoration _inputDecoration() {
+ return InputDecoration(
+ contentPadding: EdgeInsets.symmetric(
+ horizontal: EpicSize.sizeSmallMedium,
+ vertical: EpicSize.sizeSmall,
+ ),
+ filled: true,
+ fillColor: Colors.transparent,
+ enabledBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: _inputBorder),
+ ),
+ focusedBorder: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(12),
+ borderSide: const BorderSide(color: _inputBorder, width: 1.5),
+ ),
+ );
+ }
+}
+
+class _FieldLabel extends StatelessWidget {
+ final String text;
+
+ const _FieldLabel({required this.text});
+
+ @override
+ Widget build(BuildContext context) {
+ return Text(
+ text,
+ style: TextStyle(
+ color: Color(0xFF120D1A),
+ fontSize: EpicAppSize.calculatedSize(24),
+ fontWeight: FontWeight.w800,
+ height: 1,
+ ),
+ );
+ }
+}
+
+class _ColorSwatch extends StatelessWidget {
+ final CollectionColorModel colorModel;
+ final bool selected;
+
+ const _ColorSwatch({required this.colorModel, required this.selected});
+
+ @override
+ Widget build(BuildContext context) {
+ return AnimatedContainer(
+ duration: const Duration(milliseconds: 150),
+ width: EpicAppSize.calculatedSize(45),
+ height: EpicAppSize.calculatedSize(45),
+ decoration: BoxDecoration(
+ gradient: colorModel.gradient,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: selected ? Colors.black : Colors.black26,
+ width: selected ? 2.5 : 1.5,
+ ),
+ boxShadow: selected
+ ? [
+ BoxShadow(
+ color: colorModel.color.withValues(alpha: 0.35),
+ blurRadius: 12,
+ offset: const Offset(0, 4),
+ ),
+ ]
+ : null,
+ ),
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/detail_collection/detail_collection_binding.dart b/lib/feature/favorites/presentation/detail_collection/detail_collection_binding.dart
new file mode 100644
index 0000000..0ea39a9
--- /dev/null
+++ b/lib/feature/favorites/presentation/detail_collection/detail_collection_binding.dart
@@ -0,0 +1,21 @@
+import 'package:epic_story_app/data/modules/book_module.dart';
+import 'package:epic_story_app/data/modules/collection_module.dart';
+import 'package:epic_story_app/domain/usecases/book_usecase.dart';
+import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
+import 'package:get/get.dart';
+
+import 'detail_collection_controller.dart';
+
+class DetailCollectionBinding extends Bindings {
+ @override
+ void dependencies() {
+ CollectionModule();
+ BookModule();
+ Get.lazyPut(
+ () => DetailCollectionController(
+ collectionUsecase: Get.find(),
+ bookUsecase: Get.find(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/detail_collection/detail_collection_controller.dart b/lib/feature/favorites/presentation/detail_collection/detail_collection_controller.dart
new file mode 100644
index 0000000..0c443c0
--- /dev/null
+++ b/lib/feature/favorites/presentation/detail_collection/detail_collection_controller.dart
@@ -0,0 +1,154 @@
+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/collection_book_entity.dart';
+import 'package:epic_story_app/domain/entities/collection_detail_entity.dart';
+import 'package:epic_story_app/domain/usecases/book_usecase.dart';
+import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
+import 'package:epic_story_app/feature/favorites/presentation/create_collection/create_collection_args.dart';
+import 'package:epic_story_app/core/routes/epic_routes.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class DetailCollectionController extends GetxController {
+ var collectionId = ''.obs;
+ var collectionDetail = Rx(null);
+
+ var loading = false.obs;
+
+ final CollectionUsecase collectionUsecase;
+ final BookUsecase bookUsecase;
+
+ DetailCollectionController({
+ required this.collectionUsecase,
+ required this.bookUsecase,
+ });
+
+ @override
+ void onInit() async {
+ super.onInit();
+ collectionId.value = Get.arguments;
+ await _loadDetail();
+ }
+
+ Future _loadDetail() async {
+ try {
+ loading.value = true;
+ final detail = await collectionUsecase.getCollectionDetail(
+ collectionId: collectionId.value,
+ );
+ collectionDetail.value = detail;
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, '_loadDetail');
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ Future _showDeleteConfirmation({
+ required String title,
+ required String subtitle,
+ }) async {
+ final result = await Get.dialog(
+ AlertDialog(
+ title: Text(title),
+ content: Text(subtitle),
+ actions: [
+ TextButton(
+ onPressed: () => Get.back(result: false),
+ child: const Text('Batal'),
+ ),
+ FilledButton(
+ onPressed: () => Get.back(result: true),
+ style: FilledButton.styleFrom(
+ backgroundColor: const Color(0xFFFF0000),
+ ),
+ child: const Text('Hapus'),
+ ),
+ ],
+ ),
+ barrierDismissible: true,
+ );
+
+ return result ?? false;
+ }
+
+ Future deleteBookFromCollection(CollectionBookEntity book) async {
+ final isConfirmed = await _showDeleteConfirmation(
+ title: 'Hapus buku dari koleksi?',
+ subtitle: 'Buku "${book.title ?? '-'}" akan dihapus dari koleksi ini.',
+ );
+ if (!isConfirmed) return;
+
+ try {
+ loading.value = true;
+ await collectionUsecase.removeBookFromCollection(
+ collectionId: collectionId.value,
+ bookId: book.id,
+ );
+ await _loadDetail();
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, 'deleteBookFromCollection');
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ Future deleteCollection() async {
+ final detail = collectionDetail.value;
+ if (detail == null || detail.id.isEmpty) return;
+
+ final isConfirmed = await _showDeleteConfirmation(
+ title: 'Hapus koleksi buku?',
+ subtitle: 'Koleksi "${detail.title ?? '-'}" akan dihapus permanen.',
+ );
+ if (!isConfirmed) return;
+
+ try {
+ loading.value = true;
+ await collectionUsecase.removeCollection(
+ collectionId: detail.id,
+ );
+ loading.value = false;
+ Get.back(result: true);
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, 'deleteCollection');
+ loading.value = false;
+ }
+ }
+
+ Future goToBookRead(CollectionBookEntity book) async {
+ try {
+ loading.value = true;
+ final bookEntity = await bookUsecase.getBookById(bookId: book.id);
+ if (bookEntity != null) {
+ Get.toNamed(EpicRoutes.bookRead, arguments: bookEntity);
+ } else {
+ EpicSnackBar.showWarningSnackBar('Warning', 'Buku tidak ditemukan');
+ }
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, 'goToBookRead');
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ Future goToEditCollection() async {
+ final detail = collectionDetail.value;
+ if (detail == null) return;
+
+ final result = await Get.toNamed(
+ EpicRoutes.createCollection,
+ arguments: CreateCollectionArgs.edit(
+ type: 'Buku',
+ collectionId: detail.id,
+ title: detail.title,
+ desc: detail.desc,
+ bgColor: detail.bgColor,
+ ),
+ );
+
+ if (result == true) {
+ await _loadDetail();
+ }
+ }
+}
diff --git a/lib/feature/favorites/presentation/detail_collection/detail_collection_page.dart b/lib/feature/favorites/presentation/detail_collection/detail_collection_page.dart
new file mode 100644
index 0000000..9440aca
--- /dev/null
+++ b/lib/feature/favorites/presentation/detail_collection/detail_collection_page.dart
@@ -0,0 +1,410 @@
+import 'package:epic_story_app/core/styles/epic_app_size.dart';
+import 'package:epic_story_app/core/widgets/cards/collection_top_section.dart';
+import 'package:epic_story_app/domain/entities/collection_book_entity.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import 'detail_collection_controller.dart';
+
+double _s(double value) => EpicAppSize.calculatedSize(value);
+
+class DetailCollectionPage extends StatelessWidget {
+ const DetailCollectionPage({super.key});
+
+ static const List _itemColors = [
+ Color(0xFFFFE070),
+ Color(0xFFFFDAA0),
+ Color(0xFFAAFFC0),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = Get.find();
+
+ return Scaffold(
+ backgroundColor: const Color(0xFFC6B2E6),
+ body: SafeArea(
+ child: Obx(() {
+ if (controller.loading.value) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final detail = controller.collectionDetail.value;
+ if (detail == null) {
+ return Center(
+ child: Text(
+ 'Detail koleksi tidak tersedia',
+ style: TextStyle(fontSize: _s(15)),
+ ),
+ );
+ }
+
+ final books = detail.books ?? [];
+
+ return Column(
+ children: [
+ CollectionTopSection(
+ title: 'Detail Koleksi',
+ onBackTap: Get.back,
+ leftIconAssetPath: 'assets/images/Book Stacks 1.png',
+ mascotAssetPath: 'assets/images/icon-top-bar.png',
+ yellowButtonAssetPath: 'assets/images/yellow-button.png',
+ yellowButtonText: 'Book',
+ titleTextSize: 39,
+ yellowButtonTextSize: 36,
+ yellowButtonWidth: 150,
+ yellowButtonHeight: 52,
+ ),
+ Padding(
+ padding: EdgeInsets.fromLTRB(_s(16), _s(10), _s(16), 0),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ detail.title ?? 'Koleksi Buku',
+ style: TextStyle(
+ fontSize: _s(22),
+ fontWeight: FontWeight.w900,
+ color: const Color(0xFF111111),
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ Padding(
+ padding: EdgeInsets.fromLTRB(_s(16), _s(6), _s(16), 0),
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: Wrap(
+ spacing: _s(8),
+ runSpacing: _s(8),
+ children: [
+ _ActionButton(
+ label: 'Edit Koleksi',
+ icon: Icons.edit,
+ backgroundColor: const Color(0xFFFFE070),
+ textColor: const Color(0xFF111111),
+ onTap: controller.goToEditCollection,
+ ),
+ _ActionButton(
+ label: 'Hapus Koleksi',
+ icon: Icons.delete,
+ backgroundColor: const Color(0xFFFF0000),
+ textColor: Colors.white,
+ onTap: controller.deleteCollection,
+ ),
+ ],
+ ),
+ ),
+ ),
+ Expanded(
+ child: books.isEmpty
+ ? Center(
+ child: Text(
+ 'Belum ada buku di koleksi ini',
+ style: TextStyle(
+ fontSize: _s(15),
+ fontWeight: FontWeight.w700,
+ color: Colors.black54,
+ ),
+ ),
+ )
+ : ListView.separated(
+ padding: EdgeInsets.fromLTRB(0, _s(8), 0, _s(20)),
+ itemCount: books.length,
+ separatorBuilder: (_, __) => SizedBox(height: _s(12)),
+ itemBuilder: (_, index) {
+ final book = books[index];
+ final color = _itemColors[index % _itemColors.length];
+ return _BookItem(
+ book: book,
+ backgroundColor: color,
+ onTap: () => controller.goToBookRead(book),
+ onDelete: () async {
+ await controller.deleteBookFromCollection(book);
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ );
+ }),
+ ),
+ );
+ }
+}
+
+class _BookItem extends StatelessWidget {
+ final CollectionBookEntity book;
+ final Color backgroundColor;
+ final VoidCallback? onDelete;
+ final VoidCallback? onTap;
+
+ const _BookItem({
+ required this.book,
+ required this.backgroundColor,
+ this.onDelete,
+ this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final totalQuiz = book.totalQuiz ?? 0;
+ final completedQuiz = book.completedQuiz ?? 0;
+ final totalPages = book.nPages ?? 0;
+
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: EdgeInsets.fromLTRB(_s(12), _s(12), _s(12), _s(8)),
+ decoration: BoxDecoration(
+ color: backgroundColor,
+ border: Border.all(color: Colors.white, width: _s(1)),
+ ),
+ child: Column(
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _CoverPlaceholder(coverUrl: book.coverUrl),
+ SizedBox(width: _s(10)),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Text(
+ book.title ?? '-'.toUpperCase(),
+ style: TextStyle(
+ fontSize: _s(20),
+ fontWeight: FontWeight.w900,
+ color: const Color(0xFF111111),
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ GestureDetector(
+ onTap: onDelete,
+ child: Container(
+ width: _s(28),
+ height: _s(28),
+ decoration: BoxDecoration(
+ color: const Color(0xFFFF0000),
+ borderRadius: BorderRadius.circular(_s(4)),
+ ),
+ child: Icon(
+ Icons.close_rounded,
+ size: _s(22),
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: _s(4)),
+ if (book.summary != null && book.summary!.isNotEmpty)
+ Text(
+ book.summary!,
+ style: TextStyle(
+ fontSize: _s(17),
+ height: 1.2,
+ color: const Color(0xFF1A1A1A),
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ SizedBox(height: _s(10)),
+ Text(
+ 'Category : ${(book.category != null && book.category!.isNotEmpty) ? book.category : 'Kategori buku'}',
+ style: TextStyle(
+ fontSize: _s(17),
+ color: const Color(0xFF111111),
+ ),
+ ),
+ SizedBox(height: _s(18)),
+ ],
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: _s(8)),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ Flexible(
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: _s(8),
+ vertical: _s(4),
+ ),
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: const Color(0xFF111111), width: _s(1)),
+ color: const Color(0xFFF5E9AE),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Image.asset(
+ 'assets/images/book-stack2.png',
+ width: _s(16),
+ height: _s(16),
+ ),
+ SizedBox(width: _s(4)),
+ Flexible(
+ child: Text(
+ '$completedQuiz dari $totalQuiz Kuis diselesaikan',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: _s(16),
+ color: const Color(0xFF111111),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ SizedBox(width: _s(8)),
+ Text(
+ 'Total Halaman : $totalPages',
+ style: TextStyle(
+ fontSize: _s(16),
+ fontWeight: FontWeight.w700,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _ActionButton extends StatelessWidget {
+ final String label;
+ final IconData icon;
+ final Color backgroundColor;
+ final Color textColor;
+ final VoidCallback? onTap;
+
+ const _ActionButton({
+ required this.label,
+ required this.icon,
+ required this.backgroundColor,
+ required this.textColor,
+ this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final isDisabled = onTap == null;
+ final bgColor =
+ isDisabled ? backgroundColor.withOpacity(0.5) : backgroundColor;
+ final fgColor = isDisabled ? textColor.withOpacity(0.6) : textColor;
+
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(_s(12)),
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: _s(12),
+ vertical: _s(7),
+ ),
+ decoration: BoxDecoration(
+ color: bgColor,
+ borderRadius: BorderRadius.circular(_s(12)),
+ border: Border.all(color: const Color(0xFF111111), width: _s(1)),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0x33000000),
+ blurRadius: _s(6),
+ offset: Offset(0, _s(3)),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: _s(16), color: fgColor),
+ SizedBox(width: _s(6)),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: _s(14),
+ fontWeight: FontWeight.w800,
+ color: fgColor,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _CoverPlaceholder extends StatelessWidget {
+ final String? coverUrl;
+
+ const _CoverPlaceholder({this.coverUrl});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: _s(140),
+ height: _s(140),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(_s(16)),
+ border: Border.all(color: const Color(0xFF111111), width: _s(1.2)),
+ ),
+ alignment: Alignment.center,
+ child: coverUrl != null && coverUrl!.isNotEmpty
+ ? ClipRRect(
+ borderRadius: BorderRadius.circular(_s(14)),
+ child: Image.network(
+ coverUrl!,
+ fit: BoxFit.cover,
+ width: _s(140),
+ height: _s(140),
+ errorBuilder: (_, __, ___) => _fallbackText(),
+ ),
+ )
+ : _fallbackText(),
+ );
+ }
+
+ Widget _fallbackText() {
+ return Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.menu_book_rounded,
+ size: _s(26),
+ color: const Color(0xFF6B6B6B),
+ ),
+ SizedBox(height: _s(4)),
+ Text(
+ 'Cover Buku',
+ style: TextStyle(
+ fontSize: _s(11),
+ color: const Color(0xFF6B6B6B),
+ fontWeight: FontWeight.w600,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_binding.dart b/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_binding.dart
new file mode 100644
index 0000000..3f17827
--- /dev/null
+++ b/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_binding.dart
@@ -0,0 +1,19 @@
+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/collection_usecase.dart';
+import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
+import 'package:get/get.dart';
+
+import 'detail_collection_flashcard_controller.dart';
+
+class DetailCollectionFlashcardBidings extends Bindings {
+ @override
+ void dependencies() {
+ CollectionModule();
+ FlashCardModule();
+ Get.lazyPut(() => DetailCollectionFlashcardController(
+ collectionUsecase: Get.find(),
+ flashCardUsecase: Get.find(),
+ ));
+ }
+}
diff --git a/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_controller.dart b/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_controller.dart
new file mode 100644
index 0000000..d13c8e3
--- /dev/null
+++ b/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_controller.dart
@@ -0,0 +1,162 @@
+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/collections/flashcard_collection_detail_entity.dart';
+import 'package:epic_story_app/domain/entities/collections/flashcard_collection_detail_item_entity.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/favorites/presentation/create_collection/create_collection_args.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class DetailCollectionFlashcardController extends GetxController {
+ var collectionId = ''.obs;
+ var collectionDetail = Rx(null);
+
+ var loading = false.obs;
+
+ final CollectionUsecase collectionUsecase;
+ final FlashCardUsecase flashCardUsecase;
+
+ DetailCollectionFlashcardController({
+ required this.collectionUsecase,
+ required this.flashCardUsecase,
+ });
+
+ @override
+ void onInit() async {
+ super.onInit();
+ collectionId.value = Get.arguments;
+ await _loadDetail();
+ }
+
+ Future _loadDetail() async {
+ try {
+ loading.value = true;
+ final detail = await collectionUsecase.getFlashcardCollectionDetail(
+ collectionId: collectionId.value,
+ );
+ collectionDetail.value = detail;
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, '_loadDetail');
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ Future _showDeleteConfirmation({
+ required String title,
+ required String subtitle,
+ }) async {
+ final result = await Get.dialog(
+ AlertDialog(
+ title: Text(title),
+ content: Text(subtitle),
+ actions: [
+ TextButton(
+ onPressed: () => Get.back(result: false),
+ child: const Text('Batal'),
+ ),
+ FilledButton(
+ onPressed: () => Get.back(result: true),
+ style: FilledButton.styleFrom(
+ backgroundColor: const Color(0xFFFF0000),
+ ),
+ child: const Text('Hapus'),
+ ),
+ ],
+ ),
+ barrierDismissible: true,
+ );
+
+ return result ?? false;
+ }
+
+ Future deleteBookFromCollection(
+ FlashCardCollectionDetailItemEntity flashcard) async {
+ final isConfirmed = await _showDeleteConfirmation(
+ title: 'Hapus flashcard dari koleksi?',
+ subtitle:
+ 'Flashcard "${flashcard.title ?? '-'}" akan dihapus dari koleksi ini.',
+ );
+ if (!isConfirmed) return;
+
+ try {
+ loading.value = true;
+ await collectionUsecase.removeFlashcardFromCollection(
+ collectionId: collectionId.value,
+ flashcardId: flashcard.flashcardId!,
+ );
+ await _loadDetail();
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, 'deleteBookFromCollection');
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ Future deleteCollection() async {
+ final detail = collectionDetail.value;
+ final id = detail?.flashcardId ?? collectionId.value;
+ if (id.isEmpty) return;
+
+ final isConfirmed = await _showDeleteConfirmation(
+ title: 'Hapus koleksi flashcard?',
+ subtitle: 'Koleksi "${detail?.title ?? '-'}" akan dihapus permanen.',
+ );
+ if (!isConfirmed) return;
+
+ try {
+ loading.value = true;
+ await collectionUsecase.removeFlashcardCollection(
+ collectionId: id,
+ );
+ loading.value = false;
+ Get.back(result: true);
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, 'deleteCollection');
+ loading.value = false;
+ }
+ }
+
+ Future goToEditCollection() async {
+ final detail = collectionDetail.value;
+ final id = detail?.flashcardId ?? collectionId.value;
+ if (id.isEmpty) return;
+
+ final result = await Get.toNamed(
+ EpicRoutes.createCollection,
+ arguments: CreateCollectionArgs.edit(
+ type: 'Flashcard',
+ collectionId: id,
+ title: detail?.title,
+ desc: detail?.desc,
+ bgColor: detail?.bgColor,
+ ),
+ );
+
+ if (result == true) {
+ await _loadDetail();
+ }
+ }
+
+ Future goToFlashcardRead({
+ required String flashcardId,
+ }) async {
+ try {
+ loading.value = true;
+ final flashcard = await flashCardUsecase.getFlashcardById(
+ flashcardId: flashcardId,
+ );
+ if (flashcard != null) {
+ Get.toNamed(EpicRoutes.flashCardRead, arguments: flashcard);
+ } else {
+ EpicSnackBar.showWarningSnackBar("Warning", "Flashcard not found");
+ }
+ } catch (e, s) {
+ EpicLog.exception(e, s, this, 'goToFlashcardRead');
+ } finally {
+ loading.value = false;
+ }
+ }
+}
diff --git a/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_home.dart b/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_home.dart
new file mode 100644
index 0000000..23506db
--- /dev/null
+++ b/lib/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_home.dart
@@ -0,0 +1,344 @@
+import 'package:epic_story_app/core/styles/epic_app_size.dart';
+import 'package:epic_story_app/core/widgets/cards/collection_top_section.dart';
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/domain/entities/collections/flashcard_collection_detail_item_entity.dart';
+import 'package:epic_story_app/feature/favorites/presentation/detail_collection_flashcard/detail_collection_flashcard_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+double _s(double value) => EpicAppSize.calculatedSize(value);
+
+class DetailCollectionFlashcardHome
+ extends GetView {
+ const DetailCollectionFlashcardHome({super.key});
+
+ static const List _gradientIndexes = [2, 7, 6];
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: const Color(0xFFC6B2E6),
+ body: SafeArea(
+ child: Obx(() {
+ if (controller.loading.value) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ final detail = controller.collectionDetail.value;
+ final isDetailReady = detail != null;
+ final items = detail?.flashcards ?? [];
+
+ return Column(
+ children: [
+ CollectionTopSection(
+ title: 'Detail Koleksi',
+ onBackTap: Get.back,
+ leftIconAssetPath: 'assets/images/Book Stacks 1.png',
+ mascotAssetPath: 'assets/images/icon-top-bar.png',
+ yellowButtonAssetPath: 'assets/images/blue-button.png',
+ yellowButtonText: 'Flashcard',
+ titleTextSize: 37,
+ yellowButtonTextSize: 22,
+ yellowButtonWidth: 172,
+ yellowButtonHeight: 54,
+ ),
+ if (detail != null)
+ Padding(
+ padding: EdgeInsets.fromLTRB(_s(16), _s(10), _s(16), 0),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ detail.title ?? 'Koleksi Flashcard',
+ style: TextStyle(
+ fontSize: _s(22),
+ fontWeight: FontWeight.w900,
+ color: const Color(0xFF111111),
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ Padding(
+ padding: EdgeInsets.fromLTRB(_s(16), _s(6), _s(16), 0),
+ child: Align(
+ alignment: Alignment.centerRight,
+ child: Wrap(
+ spacing: _s(8),
+ runSpacing: _s(8),
+ children: [
+ _ActionButton(
+ label: 'Edit Koleksi',
+ icon: Icons.edit,
+ backgroundColor: const Color(0xFFAAFFC0),
+ textColor: const Color(0xFF111111),
+ onTap: isDetailReady
+ ? controller.goToEditCollection
+ : null,
+ ),
+ _ActionButton(
+ label: 'Hapus Koleksi',
+ icon: Icons.delete,
+ backgroundColor: const Color(0xFFFF0000),
+ textColor: Colors.white,
+ onTap:
+ isDetailReady ? controller.deleteCollection : null,
+ ),
+ ],
+ ),
+ ),
+ ),
+ Expanded(
+ child: items.isEmpty
+ ? Center(
+ child: Text(
+ 'Belum ada flashcard dalam koleksi ini.',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ color: Colors.black54,
+ ),
+ ),
+ )
+ : ListView.separated(
+ padding:
+ EdgeInsets.fromLTRB(_s(16), _s(12), _s(16), _s(16)),
+ itemBuilder: (context, index) {
+ final item = items[index];
+ final palette = collectionColorAt(
+ _gradientIndexes[index % _gradientIndexes.length],
+ );
+
+ return _FlashcardRow(
+ item: item,
+ gradient: palette.gradient,
+ controller: controller,
+ );
+ },
+ separatorBuilder: (_, __) => SizedBox(height: _s(12)),
+ itemCount: items.length,
+ ),
+ ),
+ ],
+ );
+ }),
+ ),
+ );
+ }
+}
+
+class _FlashcardRow extends StatelessWidget {
+ final FlashCardCollectionDetailItemEntity item;
+ final LinearGradient gradient;
+ final DetailCollectionFlashcardController controller;
+
+ const _FlashcardRow({
+ required this.item,
+ required this.gradient,
+ required this.controller,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: () {
+ controller.goToFlashcardRead(flashcardId: item.flashcardId ?? '');
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ gradient: gradient,
+ borderRadius: BorderRadius.circular(_s(20)),
+ border: Border.all(color: const Color(0x99FFFFFF), width: _s(1.2)),
+ boxShadow: [
+ BoxShadow(
+ color: Color(0x11000000),
+ blurRadius: _s(7),
+ offset: Offset(0, _s(3)),
+ ),
+ ],
+ ),
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(_s(12), _s(10), _s(12), _s(10)),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: _s(21),
+ fontWeight: FontWeight.w900,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ SizedBox(height: _s(4)),
+ Text(
+ item.summary ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: _s(14),
+ height: 1.35,
+ color: const Color(0xFF1A1A1A),
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: _s(8)),
+ GestureDetector(
+ onTap: () {
+ controller.deleteBookFromCollection(item);
+ },
+ child: Container(
+ width: _s(28),
+ height: _s(28),
+ decoration: BoxDecoration(
+ color: const Color(0xFFFF0000),
+ borderRadius: BorderRadius.circular(_s(4)),
+ ),
+ child: Icon(
+ Icons.close_rounded,
+ size: _s(22),
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ],
+ ),
+ SizedBox(height: _s(9)),
+ Wrap(
+ spacing: _s(6),
+ runSpacing: _s(6),
+ children: [
+ _chip(
+ text: '${item.totalCards ?? 0} kartu',
+ icon: Icons.folder_open_outlined,
+ ),
+ _chip(text: item.category ?? '-'),
+ _chip(
+ text: '${item.completedQuiz ?? 0}/${item.totalQuiz ?? 0}',
+ icon: Icons.assignment_outlined,
+ ),
+ ],
+ ),
+ SizedBox(height: _s(8)),
+ Align(
+ alignment: Alignment.centerRight,
+ child: Text(
+ '',
+ style: TextStyle(
+ fontSize: _s(12),
+ color: const Color(0xFF111111),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _chip({required String text, IconData? icon}) {
+ return Container(
+ padding: EdgeInsets.symmetric(horizontal: _s(7), vertical: _s(3)),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.55),
+ borderRadius: BorderRadius.circular(_s(6)),
+ border: Border.all(color: const Color(0xFF111111), width: _s(0.9)),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(icon, size: _s(13), color: const Color(0xFF111111)),
+ SizedBox(width: _s(4)),
+ ],
+ Text(
+ text,
+ style: TextStyle(
+ fontSize: _s(13),
+ fontWeight: FontWeight.w600,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ActionButton extends StatelessWidget {
+ final String label;
+ final IconData icon;
+ final Color backgroundColor;
+ final Color textColor;
+ final VoidCallback? onTap;
+
+ const _ActionButton({
+ required this.label,
+ required this.icon,
+ required this.backgroundColor,
+ required this.textColor,
+ this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final isDisabled = onTap == null;
+ final bgColor =
+ isDisabled ? backgroundColor.withOpacity(0.5) : backgroundColor;
+ final fgColor = isDisabled ? textColor.withOpacity(0.6) : textColor;
+
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(_s(12)),
+ child: Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: _s(12),
+ vertical: _s(7),
+ ),
+ decoration: BoxDecoration(
+ color: bgColor,
+ borderRadius: BorderRadius.circular(_s(12)),
+ border: Border.all(color: const Color(0xFF111111), width: _s(1)),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0x33000000),
+ blurRadius: _s(6),
+ offset: Offset(0, _s(3)),
+ ),
+ ],
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: _s(16), color: fgColor),
+ SizedBox(width: _s(6)),
+ Text(
+ label,
+ style: TextStyle(
+ fontSize: _s(14),
+ fontWeight: FontWeight.w800,
+ color: fgColor,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/favorite_home/components/book_collection.dart b/lib/feature/favorites/presentation/favorite_home/components/book_collection.dart
new file mode 100644
index 0000000..4d14c62
--- /dev/null
+++ b/lib/feature/favorites/presentation/favorite_home/components/book_collection.dart
@@ -0,0 +1,59 @@
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/components/collection_card.dart';
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class BookCollection extends StatelessWidget {
+ final FavoriteHomeController controller;
+
+ const BookCollection({super.key, required this.controller});
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ final items = controller.collections;
+
+ if (items.isEmpty) {
+ return ColoredBox(
+ color: const Color(0xFFC6B2E6),
+ child: ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
+ children: const [
+ SizedBox(height: 80),
+ Center(
+ child: Text(
+ 'Belum ada koleksi buku',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ color: Colors.black54,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return ColoredBox(
+ color: const Color(0xFFC6B2E6),
+ child: ListView.separated(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
+ itemCount: items.length,
+ separatorBuilder: (_, __) => const SizedBox(height: 14),
+ itemBuilder: (_, index) => CollectionCard(
+ item: items[index],
+ index: index,
+ onTap: () {
+ controller.goToCollectionBookDetail(items[index]);
+ },
+ onDelete: () {
+ controller.deleteBookCollection(items[index]);
+ },
+ ),
+ ),
+ );
+ });
+ }
+}
diff --git a/lib/feature/favorites/presentation/favorite_home/components/collection_card.dart b/lib/feature/favorites/presentation/favorite_home/components/collection_card.dart
new file mode 100644
index 0000000..2afadb1
--- /dev/null
+++ b/lib/feature/favorites/presentation/favorite_home/components/collection_card.dart
@@ -0,0 +1,322 @@
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/domain/entities/children_collection_entity.dart';
+import 'package:flutter/material.dart';
+
+class CollectionCard extends StatelessWidget {
+ final ChildrenCollectionEntity item;
+ final int index;
+ final VoidCallback? onTap;
+ final VoidCallback? onDelete;
+
+ const CollectionCard({
+ super.key,
+ required this.item,
+ required this.index,
+ required this.onTap,
+ this.onDelete,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final categories = item.categories ?? const [];
+ final visibleCategories = categories.isNotEmpty
+ ? categories.take(2).toList()
+ : const ['Kategori 1'];
+ final baseColor = collectionColorAt(item.bgColor ?? index).color;
+ final cardColor = Color.lerp(baseColor, Colors.white, 0.45)!;
+
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ decoration: BoxDecoration(
+ color: cardColor,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: Colors.white, width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: baseColor.withValues(alpha: 0.25),
+ blurRadius: 12,
+ offset: const Offset(0, 5),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _CoverStack(covers: item.bookCovers, accentColor: baseColor),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.only(top: 2),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? 'Nama Koleksi Buku',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w900,
+ color: Color(0xFF111111),
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 6),
+ Text(
+ item.desc ??
+ 'Deskripsi koleksi belum tersedia untuk saat ini.',
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF1D1D1D),
+ height: 1.25,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ children: visibleCategories
+ .map((cat) => _MetaChip(text: cat))
+ .toList(),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(width: 8),
+ _DeleteBadge(onTap: onDelete),
+ ],
+ ),
+ const SizedBox(height: 8),
+ _TotalBooksBadge(total: item.nBooks ?? 0),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _CoverStack extends StatelessWidget {
+ final List? covers;
+ final Color accentColor;
+
+ const _CoverStack({
+ this.covers,
+ required this.accentColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final items = (covers ?? const [])
+ .where((cover) => cover.trim().isNotEmpty)
+ .toList();
+ final displayed = items.take(3).toList();
+ final count = displayed.isEmpty ? 1 : displayed.length;
+
+ return SizedBox(
+ width: 110,
+ height: 128,
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ for (int i = 0; i < count; i++)
+ Positioned(
+ left: i * 7,
+ top: i * 3,
+ child: _CoverBox(
+ width: 94,
+ height: 118,
+ url: displayed.isEmpty
+ ? null
+ : displayed[displayed.length - 1 - i],
+ opacity: 1 - (i * 0.15),
+ accentColor: accentColor,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _CoverBox extends StatelessWidget {
+ final double width;
+ final double height;
+ final String? url;
+ final double opacity;
+ final Color accentColor;
+
+ const _CoverBox({
+ required this.width,
+ required this.height,
+ required this.url,
+ required this.opacity,
+ required this.accentColor,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Opacity(
+ opacity: opacity,
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(18),
+ child: Container(
+ width: width,
+ height: height,
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.84),
+ border: Border.all(color: const Color(0xFF111111), width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: accentColor.withValues(alpha: 0.18),
+ blurRadius: 8,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: url != null
+ ? Image.network(
+ url!,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => _placeholder(),
+ loadingBuilder: (context, child, progress) {
+ if (progress == null) return child;
+ return _placeholder(isLoading: true);
+ },
+ )
+ : _placeholder(),
+ ),
+ ),
+ );
+ }
+
+ Widget _placeholder({bool isLoading = false}) {
+ return Center(
+ child: isLoading
+ ? const SizedBox(
+ height: 18,
+ width: 18,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : const Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.menu_book_rounded,
+ size: 26,
+ color: Color(0xFF6B6B6B),
+ ),
+ SizedBox(height: 4),
+ Text(
+ 'Cover Buku',
+ style: TextStyle(
+ fontSize: 10,
+ color: Color(0xFF6B6B6B),
+ fontWeight: FontWeight.w600,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _MetaChip extends StatelessWidget {
+ final String text;
+
+ const _MetaChip({required this.text});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: const Color(0xFF111111), width: 1),
+ ),
+ child: Text(
+ text,
+ style: const TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF111111),
+ height: 1.1,
+ ),
+ ),
+ );
+ }
+}
+
+class _TotalBooksBadge extends StatelessWidget {
+ final int total;
+
+ const _TotalBooksBadge({required this.total});
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(3),
+ border: Border.all(color: const Color(0xFF111111), width: 1),
+ color: Colors.transparent,
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(
+ Icons.menu_book_rounded,
+ size: 16,
+ color: Color(0xFFA35BE2),
+ ),
+ const SizedBox(width: 4),
+ Text(
+ 'Total Buku : $total',
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF111111),
+ height: 1,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _DeleteBadge extends StatelessWidget {
+ final VoidCallback? onTap;
+
+ const _DeleteBadge({this.onTap});
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ width: 28,
+ height: 28,
+ decoration: BoxDecoration(
+ color: const Color(0xFFFF0000),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: const Icon(
+ Icons.close_rounded,
+ size: 22,
+ color: Colors.white,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/favorites/presentation/favorite_home/components/flashcard_collection.dart b/lib/feature/favorites/presentation/favorite_home/components/flashcard_collection.dart
new file mode 100644
index 0000000..90e09e1
--- /dev/null
+++ b/lib/feature/favorites/presentation/favorite_home/components/flashcard_collection.dart
@@ -0,0 +1,274 @@
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class FlashcardCollection extends StatelessWidget {
+ const FlashcardCollection({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = Get.find();
+
+ return Obx(() {
+ final items = controller.flashcardCollections;
+ if (items.isEmpty) {
+ return ColoredBox(
+ color: const Color(0xFFC6B2E6),
+ child: ListView(
+ physics: const AlwaysScrollableScrollPhysics(),
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
+ children: const [
+ SizedBox(height: 80),
+ Center(
+ child: Text(
+ 'Belum ada koleksi flashcard',
+ style: TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w700,
+ color: Colors.black54,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return ColoredBox(
+ color: const Color(0xFFC6B2E6),
+ child: ListView.separated(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
+ itemBuilder: (context, index) {
+ final item = items[index];
+ return _FlashcardCollectionCard(
+ item: item,
+ index: index,
+ controller: controller,
+ );
+ },
+ separatorBuilder: (_, __) => const SizedBox(height: 14),
+ itemCount: items.length,
+ ),
+ );
+ });
+ }
+}
+
+class _FlashcardCollectionCard extends StatelessWidget {
+ final FlashCardCollectionEntity item;
+ final int index;
+ final FavoriteHomeController controller;
+
+ const _FlashcardCollectionCard({
+ required this.item,
+ required this.controller,
+ required this.index,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final categories = item.categories ?? const [];
+ final totalCards = item.totalCards ?? 0;
+ final totalFlashcard = item.totalFlashcard ?? 0;
+ final paletteColor = collectionColorAt(item.bgColor ?? index);
+ final topColor =
+ Color.lerp(paletteColor.gradient.colors.last, Colors.white, 0.35)!;
+ final coverAsset =
+ _resolveCategoryImage(categories.isNotEmpty ? categories.first : null);
+
+ return GestureDetector(
+ onTap: () {
+ controller.goToCollectionFlashcardDetail(item);
+ },
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 14),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ topColor,
+ Colors.white,
+ ],
+ ),
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: Colors.white, width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: paletteColor.color.withValues(alpha: 0.25),
+ blurRadius: 12,
+ offset: const Offset(0, 5),
+ ),
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Stack(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(65, 2, 36, 0),
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Text(
+ item.title ?? 'Nama Koleksi Flashcard',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.start,
+ style: const TextStyle(
+ fontSize: 17,
+ fontWeight: FontWeight.w900,
+ color: Color(0xFF101010),
+ height: 1,
+ ),
+ ),
+ ),
+ ),
+ Align(
+ alignment: Alignment.topRight,
+ child: GestureDetector(
+ onTap: () {
+ controller.deleteFlashcardCollection(item);
+ },
+ child: Container(
+ width: 28,
+ height: 28,
+ decoration: BoxDecoration(
+ color: const Color(0xFFFF0000),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: const Icon(
+ Icons.close_rounded,
+ size: 22,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Container(
+ width: 100,
+ height: 100,
+ decoration: BoxDecoration(
+ color: Colors.white.withValues(alpha: 0.84),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(
+ color: paletteColor.color,
+ width: 3,
+ ),
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(14),
+ child: Image.asset(
+ coverAsset,
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.desc ??
+ 'Deskripsi koleksi belum tersedia untuk saat ini.',
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 13,
+ height: 1.25,
+ color: Color(0xFF1F1F1F),
+ fontWeight: FontWeight.w500,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ crossAxisAlignment: WrapCrossAlignment.center,
+ spacing: 6,
+ runSpacing: 6,
+ children: [
+ _chip(
+ icon: Icons.collections_bookmark_outlined,
+ text:
+ '${totalCards > 0 ? totalCards : totalFlashcard} kartu',
+ ),
+ _chip(
+ text: categories.isNotEmpty
+ ? categories.first
+ : 'Lainnya',
+ ),
+ Text(
+ '- ${totalFlashcard}x dibaca',
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: Color(0xFF212121),
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _chip({required String text, IconData? icon}) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: const Color(0xFF111111), width: 1),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(icon, size: 14, color: const Color(0xFF111111)),
+ const SizedBox(width: 4),
+ ],
+ Text(
+ text,
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ color: Color(0xFF111111),
+ height: 1.1,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _resolveCategoryImage(String? category) {
+ final normalized = (category ?? '').toLowerCase();
+
+ if (normalized.contains('matematika') ||
+ normalized.contains('ipa') ||
+ normalized.contains('sains')) {
+ 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';
+ }
+}
diff --git a/lib/feature/favorites/presentation/favorite_home/favorite_home_binding.dart b/lib/feature/favorites/presentation/favorite_home/favorite_home_binding.dart
new file mode 100644
index 0000000..31b3523
--- /dev/null
+++ b/lib/feature/favorites/presentation/favorite_home/favorite_home_binding.dart
@@ -0,0 +1,14 @@
+import 'package:epic_story_app/data/modules/collection_module.dart';
+import 'package:epic_story_app/domain/usecases/collection_usecase.dart';
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
+import 'package:get/get.dart';
+
+class FavoriteHomeBinding extends Bindings {
+ @override
+ void dependencies() {
+ CollectionModule();
+ Get.lazyPut(() => FavoriteHomeController(
+ collectionUsecase: Get.find(),
+ ));
+ }
+}
diff --git a/lib/feature/favorites/presentation/favorite_home/favorite_home_controller.dart b/lib/feature/favorites/presentation/favorite_home/favorite_home_controller.dart
new file mode 100644
index 0000000..481581b
--- /dev/null
+++ b/lib/feature/favorites/presentation/favorite_home/favorite_home_controller.dart
@@ -0,0 +1,151 @@
+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_collection_entity.dart';
+import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
+import 'package:epic_story_app/domain/usecases/collection_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:flutter/material.dart';
+import 'package:get/get.dart';
+
+class FavoriteHomeController extends GetxController {
+ var collections = [].obs;
+ var flashcardCollections = [].obs;
+
+ final mainController = Get.find();
+ final navController = Get.find();
+
+ var isBook = true.obs;
+
+ final CollectionUsecase collectionUsecase;
+
+ FavoriteHomeController({
+ required this.collectionUsecase,
+ });
+
+ @override
+ void onInit() async {
+ super.onInit();
+ await refreshData();
+
+ ever(navController.refreshCollectionHome, (value) {
+ refreshData(refreshRemote: true);
+ });
+ }
+
+ Future refreshData({bool refreshRemote = false}) async {
+ try {
+ EpicLog.debug('Refreshing collections data');
+ flashcardCollections.clear();
+ if (refreshRemote) {
+ await mainController.childrenUsecase.syncChildrenData();
+ }
+ var children = await mainController.childrenUsecase.getCurrentChildren();
+ collections.clear();
+ flashcardCollections.clear();
+ collections.addAll(children?.collections ?? []);
+ flashcardCollections.addAll(children?.flashcardCollections ?? []);
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'refreshData');
+ }
+ }
+
+ Future onRefresh() async {
+ await refreshData(refreshRemote: true);
+ }
+
+ Future _showDeleteConfirmation({
+ required String title,
+ required String subtitle,
+ }) async {
+ final result = await Get.dialog(
+ AlertDialog(
+ title: Text(title),
+ content: Text(subtitle),
+ actions: [
+ TextButton(
+ onPressed: () => Get.back(result: false),
+ child: const Text('Batal'),
+ ),
+ FilledButton(
+ onPressed: () => Get.back(result: true),
+ style: FilledButton.styleFrom(
+ backgroundColor: const Color(0xFFFF0000),
+ ),
+ child: const Text('Hapus'),
+ ),
+ ],
+ ),
+ barrierDismissible: true,
+ );
+
+ return result ?? false;
+ }
+
+ Future deleteBookCollection(
+ ChildrenCollectionEntity data,
+ ) async {
+ try {
+ if (data.id.isEmpty) return;
+ final isConfirmed = await _showDeleteConfirmation(
+ title: 'Hapus koleksi buku?',
+ subtitle: 'Koleksi "${data.title ?? '-'}" akan dihapus permanen.',
+ );
+ if (!isConfirmed) return;
+
+ await collectionUsecase.removeCollection(
+ collectionId: data.id,
+ );
+ await refreshData(refreshRemote: true);
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'deleteBookCollection');
+ }
+ }
+
+ Future deleteFlashcardCollection(
+ FlashCardCollectionEntity data,
+ ) async {
+ try {
+ if (data.collectionId.isEmpty) return;
+ final isConfirmed = await _showDeleteConfirmation(
+ title: 'Hapus koleksi flashcard?',
+ subtitle: 'Koleksi "${data.title ?? '-'}" akan dihapus permanen.',
+ );
+ if (!isConfirmed) return;
+
+ await collectionUsecase.removeFlashcardCollection(
+ collectionId: data.collectionId,
+ );
+ await refreshData(refreshRemote: true);
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, this, 'deleteFlashcardCollection');
+ }
+ }
+
+ void goToCreateCollection() async {
+ await Get.toNamed(
+ EpicRoutes.createCollection,
+ );
+ await refreshData(refreshRemote: true);
+ }
+
+ void goToCollectionBookDetail(
+ ChildrenCollectionEntity data,
+ ) async {
+ await Get.toNamed(
+ EpicRoutes.detailCollection,
+ arguments: data.id,
+ );
+ await refreshData(refreshRemote: true);
+ }
+
+ void goToCollectionFlashcardDetail(
+ FlashCardCollectionEntity data,
+ ) async {
+ await Get.toNamed(
+ EpicRoutes.detailCollectionFlashcard,
+ arguments: data.collectionId,
+ );
+ await refreshData(refreshRemote: true);
+ }
+}
diff --git a/lib/feature/favorites/presentation/favorite_home/favorite_home_page.dart b/lib/feature/favorites/presentation/favorite_home/favorite_home_page.dart
new file mode 100644
index 0000000..a5d0425
--- /dev/null
+++ b/lib/feature/favorites/presentation/favorite_home/favorite_home_page.dart
@@ -0,0 +1,209 @@
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/components/book_collection.dart';
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/components/flashcard_collection.dart';
+import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class FavoriteHomePage extends StatelessWidget {
+ const FavoriteHomePage({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final controller = Get.find();
+
+ return Scaffold(
+ backgroundColor: Colors.white,
+ floatingActionButton: FloatingActionButton(
+ onPressed: () {
+ controller.goToCreateCollection();
+ },
+ backgroundColor: Colors.transparent,
+ elevation: 0,
+ highlightElevation: 0,
+ child: Image.asset(
+ 'assets/images/subway_add.png',
+ width: 56,
+ height: 56,
+ fit: BoxFit.contain,
+ ),
+ ),
+ 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: _TypeSwitcher(controller: controller),
+ ),
+ Padding(
+ padding: const EdgeInsets.only(top: 156),
+ child: RefreshIndicator(
+ onRefresh: controller.onRefresh,
+ child: controller.isBook.value
+ ? BookCollection(controller: controller)
+ : const FlashcardCollection(),
+ ),
+ ),
+ ],
+ );
+ }),
+ );
+ }
+}
+
+class _TypeSwitcher extends StatelessWidget {
+ final FavoriteHomeController controller;
+
+ const _TypeSwitcher({required this.controller});
+
+ @override
+ Widget build(BuildContext context) {
+ final bool isBookActive = controller.isBook.value;
+
+ return Container(
+ height: 56,
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ begin: Alignment.centerLeft,
+ end: Alignment.centerRight,
+ colors: [Color(0xFFECB63F), Color(0xFF0088FF)],
+ ),
+ border: Border.all(color: Colors.black87, width: 1.4),
+ borderRadius: BorderRadius.circular(24),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x29000000),
+ blurRadius: 10,
+ offset: Offset(0, 3),
+ ),
+ ],
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(4),
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ AnimatedAlign(
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOutCubic,
+ alignment:
+ isBookActive ? Alignment.centerLeft : Alignment.centerRight,
+ child: FractionallySizedBox(
+ widthFactor: 0.5,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 2),
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(18),
+ boxShadow: const [
+ BoxShadow(
+ color: Color(0x33000000),
+ blurRadius: 8,
+ offset: Offset(0, 2),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(18),
+ child: Image.asset(
+ _activeAssetPath(isBookActive),
+ fit: BoxFit.fill,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Row(
+ children: [
+ Expanded(
+ child: _item(
+ label: 'Buku',
+ value: true,
+ active: isBookActive,
+ ),
+ ),
+ Expanded(
+ child: _item(
+ label: 'Flashcard',
+ value: false,
+ active: !isBookActive,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _activeAssetPath(bool isBookActive) {
+ if (isBookActive) {
+ return 'assets/images/button-book-active.png';
+ }
+
+ return 'assets/images/button-flashcard-acive.png';
+ }
+
+ Widget _item({
+ required String label,
+ required bool value,
+ required bool active,
+ }) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(18),
+ onTap: () => controller.isBook.value = value,
+ child: Center(
+ child: AnimatedDefaultTextStyle(
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOut,
+ style: TextStyle(
+ color: active ? Colors.transparent : Colors.white,
+ fontSize: 30,
+ fontWeight: FontWeight.w900,
+ height: 1,
+ shadows: active
+ ? null
+ : const [
+ Shadow(
+ color: Color(0xFF171717),
+ offset: Offset(1.2, 1.2),
+ blurRadius: 0,
+ ),
+ Shadow(
+ color: Color(0xB3000000),
+ offset: Offset(0, 0),
+ blurRadius: 5,
+ ),
+ ],
+ ),
+ child: Text(label),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_histories/flashcard_histories_binding.dart b/lib/feature/flashcards/flashcard_histories/flashcard_histories_binding.dart
new file mode 100644
index 0000000..ab5d452
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_histories/flashcard_histories_binding.dart
@@ -0,0 +1,12 @@
+import 'package:get/get.dart';
+
+import 'flashcard_histories_controller.dart';
+
+class FlashcardHistoriesBinding extends Bindings {
+ @override
+ void dependencies() {
+ Get.lazyPut(
+ () => FlashcardHistoriesController(),
+ );
+ }
+}
\ No newline at end of file
diff --git a/lib/feature/flashcards/flashcard_histories/flashcard_histories_controller.dart b/lib/feature/flashcards/flashcard_histories/flashcard_histories_controller.dart
new file mode 100644
index 0000000..91b25a0
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_histories/flashcard_histories_controller.dart
@@ -0,0 +1,38 @@
+import 'package:epic_story_app/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart';
+import 'package:get/get.dart';
+
+import '../../../domain/entities/flashcards/flashcard_history_entity.dart';
+import '../flashcard_home/flashcard_home_controller.dart';
+
+class FlashcardHistoriesController extends GetxController {
+ late FlashcardHistoryState selectedState;
+ final flashcardHomeController = Get.find();
+
+ var histories = [].obs;
+
+ void openFromHistory(FlashcardHistoryEntity history) {
+ flashcardHomeController.goToFlashcardReadFromHistory(history);
+ }
+
+ var title = ''.obs;
+
+ @override
+ void onInit() {
+ super.onInit();
+ selectedState = Get.arguments;
+ switch (selectedState) {
+ case FlashcardHistoryState.allHistories:
+ histories.value = flashcardHomeController.historyFlashcards;
+ title.value = 'Semua Riwayat';
+ break;
+ case FlashcardHistoryState.learnHistories:
+ histories.value = flashcardHomeController.uncompletedFlashcardHistories;
+ title.value = 'Flashcard yang belum kamu selesaikan';
+ break;
+ case FlashcardHistoryState.quizHistories:
+ histories.value = flashcardHomeController.completedFlashcardHistories;
+ title.value = 'Kuis yang belum kamu kerjakan';
+ break;
+ }
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_histories/flashcard_histories_page.dart b/lib/feature/flashcards/flashcard_histories/flashcard_histories_page.dart
new file mode 100644
index 0000000..270247d
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_histories/flashcard_histories_page.dart
@@ -0,0 +1,232 @@
+import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
+import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import 'flashcard_histories_controller.dart';
+
+class FlashcardHistoriesPage extends GetView {
+ const FlashcardHistoriesPage({super.key});
+
+ static const Color _mainBackground = Color(0xFFE0CEFF);
+
+ static const List _gradientCycle = [
+ Color(0xFFFFE588),
+ Color(0xFF77B4FF),
+ Color(0xFF4EFF4F),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ final histories = controller.histories;
+ final selectedCategory =
+ controller.flashcardHomeController.selectedCategory.value;
+ final activeCategory =
+ selectedCategory.isEmpty ? 'Semua' : selectedCategory;
+
+ return Scaffold(
+ backgroundColor: _mainBackground,
+ body: SafeArea(
+ bottom: false,
+ child: Column(
+ children: [
+ StackedInfoTopBar(
+ titleText: 'History Flashcard',
+ infoText: 'Mata Pelajran :\n$activeCategory',
+ isBackButtonVisible: true,
+ onBackTap: () => Get.back(),
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 14, 14, 12),
+ child: histories.isEmpty
+ ? const Center(
+ child: Text(
+ 'Belum ada riwayat flashcard.',
+ style: TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF2B2B2B),
+ ),
+ ),
+ )
+ : ListView.separated(
+ padding: EdgeInsets.zero,
+ itemCount: histories.length,
+ separatorBuilder: (_, __) =>
+ const SizedBox(height: 12),
+ itemBuilder: (context, index) => _HistoryItem(
+ item: histories[index],
+ index: index,
+ gradientCycle: _gradientCycle,
+ onTap: () =>
+ controller.openFromHistory(histories[index]),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ });
+ }
+}
+
+class _HistoryItem extends StatelessWidget {
+ const _HistoryItem({
+ required this.item,
+ required this.index,
+ required this.gradientCycle,
+ required this.onTap,
+ });
+
+ final FlashcardHistoryEntity item;
+ final int index;
+ final List gradientCycle;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return GestureDetector(
+ onTap: onTap,
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ gradientCycle[index % gradientCycle.length],
+ const Color(0xFFFFFFFF),
+ ],
+ stops: const [0.0, 1.0],
+ ),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: Colors.white, width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.16),
+ blurRadius: 12,
+ offset: const Offset(0, 5),
+ ),
+ ],
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: Container(
+ width: 82,
+ height: 82,
+ color: Colors.white.withOpacity(0.72),
+ child: Image.asset(
+ _resolveCategoryImage(item.category, index),
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ item.summary ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 14,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ children: [
+ _pill(
+ Icons.collections_bookmark_outlined,
+ '${item.totalCards ?? 0} kartu',
+ ),
+ _pill(
+ Icons.quiz_outlined,
+ '${item.completedQuiz ?? 0}/${item.totalQuiz ?? (item.isHaveQuiz == true ? 1 : 0)}',
+ ),
+ _pill(Icons.label_outline, item.category ?? 'Lainnya'),
+ ],
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(width: 8),
+ Padding(
+ padding: const EdgeInsets.only(top: 6),
+ child: Image.asset(
+ (item.isFinished ?? false)
+ ? 'assets/images/icon-selesai.png'
+ : 'assets/images/icon-belum-selesai.png',
+ width: 62,
+ fit: BoxFit.contain,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _pill(IconData icon, String label) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.9),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 13, color: const Color(0xFF151515)),
+ const SizedBox(width: 4),
+ Text(
+ label,
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF151515),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _resolveCategoryImage(String? category, int index) {
+ final normalized = (category ?? '').toLowerCase();
+ if (normalized.contains('matematika') || normalized.contains('ipa')) {
+ return 'assets/images/flashcard-matematika.png';
+ }
+ if (normalized.contains('bahasa') || normalized.contains('indo')) {
+ return 'assets/images/flashcard-bhs-indo.png';
+ }
+ return index % 2 == 0
+ ? 'assets/images/flashcard-bhs-indo.png'
+ : 'assets/images/flashcard-matematika.png';
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart b/lib/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart
new file mode 100644
index 0000000..23dd22a
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart
@@ -0,0 +1,5 @@
+enum FlashcardHistoryState{
+ allHistories,
+ learnHistories,
+ quizHistories,
+}
\ No newline at end of file
diff --git a/lib/feature/flashcards/flashcard_home/flashcard_home_binding.dart b/lib/feature/flashcards/flashcard_home/flashcard_home_binding.dart
new file mode 100644
index 0000000..6a57cdb
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_home/flashcard_home_binding.dart
@@ -0,0 +1,21 @@
+import 'package:epic_story_app/data/modules/children_module.dart';
+import 'package:epic_story_app/data/modules/flashcard_module.dart';
+import 'package:epic_story_app/domain/usecases/children_usecase.dart';
+import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
+import 'package:get/get.dart';
+
+import 'flashcard_home_controller.dart';
+
+class FlashCardHomeBinding extends Bindings {
+ @override
+ void dependencies() {
+ ChildrenModule();
+ FlashCardModule();
+ Get.lazyPut(
+ () => FlashCardHomeController(
+ flashCardUsecase: Get.find(),
+ childrenUsecase: Get.find(),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_home/flashcard_home_controller.dart b/lib/feature/flashcards/flashcard_home/flashcard_home_controller.dart
new file mode 100644
index 0000000..1a4fde2
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_home/flashcard_home_controller.dart
@@ -0,0 +1,155 @@
+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/flashcards/flashcard_entity.dart';
+import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart';
+import 'package:epic_story_app/domain/usecases/children_usecase.dart';
+import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart';
+import 'package:epic_story_app/feature/flashcards/flashcard_histories/state/flashcard_history_state.dart';
+import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart';
+import 'package:get/get.dart';
+
+class FlashCardHomeController extends GetxController {
+ final FlashCardUsecase flashCardUsecase;
+ final ChildrenUsecase childrenUsecase;
+
+ FlashCardHomeController({
+ required this.flashCardUsecase,
+ required this.childrenUsecase,
+ });
+
+ final flashcards = [].obs;
+ final historyFlashcards = [].obs;
+ final categories = [].obs;
+ final selectedCategory = 'Semua'.obs;
+ var isLoading = false.obs;
+
+ final navController = Get.find();
+
+ List get filteredRecommendations {
+ if (selectedCategory.value == 'Semua') return flashcards;
+ return flashcards
+ .where((card) => card.category == selectedCategory.value)
+ .toList();
+ }
+
+ List get uncompletedFlashcardHistories {
+ return historyFlashcards.where((h) {
+ final total = h.totalCards ?? 0;
+ final cardsLen = h.cards?.length ?? 0;
+ return cardsLen < total;
+ }).toList();
+ }
+
+ List get completedFlashcardHistories {
+ return historyFlashcards.where((h) {
+ final total = h.totalCards ?? 0;
+ final cardsLen = h.cards?.length ?? 0;
+ final isFinished = h.isFinished ?? false;
+ return cardsLen == total && !isFinished;
+ }).toList();
+ }
+
+ List get filteredHistory {
+ if (selectedCategory.value == 'Semua') return historyFlashcards;
+ return historyFlashcards
+ .where((card) => card.category == selectedCategory.value)
+ .toList();
+ }
+
+ @override
+ void onInit() async {
+ super.onInit();
+ await fetchFlashcards();
+
+ ever(navController.selectedCategory, (value) {
+ selectedCategory.value = value;
+ });
+ }
+
+ Future fetchFlashcards() async {
+ try {
+ EpicLog.debug(
+ 'FlashCardHomeController - Fetching flashcards and histories : $isLoading');
+ isLoading.value = true;
+ final fetchedHistory = await childrenUsecase.getFlashcardHistories();
+ final fetchedFlashcards = await flashCardUsecase.getFlashcards();
+ historyFlashcards.assignAll(fetchedHistory);
+ flashcards.assignAll(fetchedFlashcards);
+ sortedHistories();
+
+ EpicLog.debug(
+ 'FlashCardHomeController - Finished fetching flashcards and histories : $isLoading');
+
+ final uniqueCategories = {
+ 'Semua',
+ ...historyFlashcards.map((e) => e.category ?? 'Lainnya'),
+ ...flashcards.map((e) => e.category ?? 'Lainnya'),
+ };
+ EpicLog.debug(
+ 'FlashCardHomeController - Unique categories extracted: $uniqueCategories');
+
+ categories.assignAll(uniqueCategories.toList());
+ selectedCategory.value = categories.first;
+ } catch (ex, s) {
+ EpicLog.exception(ex, s, 'Failed to fetch flashcards');
+ } finally {
+ isLoading.value = false;
+ EpicLog.debug(
+ "homeController - Finished fetching flashcards : $isLoading");
+ }
+ }
+
+ void goToFlashcardRead(FlashcardEntity flashcard) async {
+ await Get.toNamed(
+ EpicRoutes.flashCardRead,
+ arguments: flashcard,
+ );
+ var histories = await childrenUsecase.getFlashcardHistories(
+ refreshLocal: true,
+ );
+ historyFlashcards.clear();
+ historyFlashcards.assignAll(histories);
+ sortedHistories();
+ }
+
+ void goToFlashcardReadFromHistory(
+ FlashcardHistoryEntity history,
+ ) async {
+ final selectedFlashcard = flashcards
+ .where((element) => element.flashcardId == history.flashcardId)
+ .firstOrNull;
+
+ await Get.toNamed(
+ EpicRoutes.flashCardRead,
+ arguments: selectedFlashcard,
+ );
+
+ final histories = await childrenUsecase.getFlashcardHistories(
+ refreshLocal: true,
+ );
+
+ historyFlashcards
+ ..clear()
+ ..assignAll(histories);
+
+ sortedHistories();
+ }
+
+ void goToFlashcardHistories(FlashcardHistoryState state) {
+ Get.toNamed(
+ EpicRoutes.flashcardHistories,
+ arguments: state,
+ );
+ }
+
+ void sortedHistories() {
+ historyFlashcards.sort((a, b) =>
+ b.lastReadAt?.compareTo(
+ a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ??
+ 0);
+ }
+
+ void selectCategory(String category) {
+ selectedCategory.value = category;
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_home/flashcard_home_page.dart b/lib/feature/flashcards/flashcard_home/flashcard_home_page.dart
new file mode 100644
index 0000000..20bd4dc
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_home/flashcard_home_page.dart
@@ -0,0 +1,457 @@
+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/flashcards/flashcard_histories/state/flashcard_history_state.dart';
+import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class FlashCardHomePage extends GetView {
+ const FlashCardHomePage({super.key});
+
+ static const Color _mainBackground = Color(0xFFE0CEFF);
+ static const double _historyCardWidth = 200;
+ static const double _historyCardHeight = 120;
+ static const List _gradientCycle = [
+ Color(0xFFFFE588),
+ Color(0xFF77B4FF),
+ Color(0xFF4EFF4F),
+ ];
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ backgroundColor: _mainBackground,
+ body: Obx(
+ () {
+ final history = controller.filteredHistory;
+ final latestHistory = history.take(5).toList();
+ final recommendations = controller.filteredRecommendations;
+ final learnCount = controller.uncompletedFlashcardHistories.length;
+ final quizCount = controller.completedFlashcardHistories.length;
+
+ return SingleChildScrollView(
+ padding: EdgeInsets.only(left: 10, top: 6),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildSectionTitle('Riwayat Flashcard'),
+ const SizedBox(height: 12),
+ if (history.isEmpty)
+ _buildEmptyState('Belum ada riwayat untuk kategori ini.')
+ else
+ _buildStackedHistoryList(latestHistory),
+ const SizedBox(height: 24),
+ _buildActionButtons(learnCount, quizCount),
+ const SizedBox(height: 24),
+ _buildSectionTitle('Flashcard yang dapat kamu pelajari'),
+ const SizedBox(height: 10),
+ if (recommendations.isEmpty)
+ _buildEmptyState(
+ 'Belum ada flashcard yang dapat kamu pelajari.')
+ else
+ ListView.separated(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ itemCount: recommendations.length,
+ separatorBuilder: (_, __) => const SizedBox(height: 12),
+ itemBuilder: (context, index) => _buildRecommendationCard(
+ recommendations[index],
+ index,
+ ),
+ ),
+ SizedBox(height: 24),
+ ],
+ ),
+ );
+ },
+ ),
+ );
+ }
+
+ Widget _buildStackedHistoryList(List latestHistory) {
+ final totalItems = latestHistory.length + 1;
+ final totalColumns = (totalItems / 2).ceil();
+
+ return SizedBox(
+ height: (_historyCardHeight * 2) + 12,
+ child: SingleChildScrollView(
+ scrollDirection: Axis.horizontal,
+ child: Row(
+ children: List.generate(totalColumns, (columnIndex) {
+ final topIndex = columnIndex * 2;
+ final bottomIndex = topIndex + 1;
+
+ return Padding(
+ padding: EdgeInsets.only(
+ right: columnIndex == totalColumns - 1 ? 0 : 12,
+ ),
+ child: Column(
+ children: [
+ _buildStackedHistoryItem(topIndex, latestHistory),
+ const SizedBox(height: 12),
+ _buildStackedHistoryItem(bottomIndex, latestHistory),
+ ],
+ ),
+ );
+ }),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildStackedHistoryItem(
+ int index,
+ List latestHistory,
+ ) {
+ final seeAllIndex = latestHistory.length;
+
+ if (index > seeAllIndex) {
+ return const SizedBox(
+ width: _historyCardWidth,
+ height: _historyCardHeight,
+ );
+ }
+
+ if (index == seeAllIndex) {
+ return _buildSeeAllHistoryButton(index);
+ }
+
+ return _buildHistoryCard(latestHistory[index], index);
+ }
+
+ Widget _buildSectionTitle(String title) {
+ return Text(
+ title,
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ letterSpacing: 0.2,
+ ),
+ );
+ }
+
+ Widget _buildHistoryCard(FlashcardHistoryEntity item, int index) {
+ return GestureDetector(
+ onTap: () {
+ controller.goToFlashcardReadFromHistory(item);
+ },
+ child: Container(
+ width: _historyCardWidth,
+ height: _historyCardHeight,
+ padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
+ decoration: _cardDecoration(index),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 15,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ ),
+ ),
+ const Spacer(),
+ Row(
+ children: [
+ _miniPill(
+ icon: Icons.collections_bookmark_outlined,
+ label: '${item.totalCards ?? 0}',
+ ),
+ const SizedBox(width: 6),
+ _miniPill(
+ icon: Icons.quiz_outlined,
+ label:
+ '${item.completedQuiz ?? 0}/${item.totalQuiz ?? (item.isHaveQuiz == true ? 1 : 0)}',
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildSeeAllHistoryButton(int index) {
+ return GestureDetector(
+ onTap: () {
+ controller.goToFlashcardHistories(
+ FlashcardHistoryState.allHistories,
+ );
+ },
+ child: Container(
+ width: _historyCardWidth,
+ height: _historyCardHeight,
+ decoration: _cardDecoration(index),
+ child: const Center(
+ child: Icon(
+ Icons.arrow_right_alt_rounded,
+ size: 32,
+ color: Colors.black87,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildActionButtons(int learnCount, int quizCount) {
+ return Row(
+ children: [
+ Expanded(
+ child: _buildActionCapsule(
+ label: 'Belajar ($learnCount)',
+ imagePath: 'assets/images/yellow-button.png',
+ onPressed: () {
+ controller
+ .goToFlashcardHistories(FlashcardHistoryState.learnHistories);
+ },
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: _buildActionCapsule(
+ label: 'Kuis ($quizCount)',
+ imagePath: 'assets/images/green-button.png',
+ onPressed: () {
+ controller
+ .goToFlashcardHistories(FlashcardHistoryState.quizHistories);
+ },
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildActionCapsule({
+ required String label,
+ required String imagePath,
+ required VoidCallback onPressed,
+ }) {
+ return GestureDetector(
+ onTap: onPressed,
+ child: SizedBox(
+ height: 60,
+ child: Stack(
+ alignment: Alignment.center,
+ children: [
+ Positioned.fill(
+ child: Image.asset(
+ imagePath,
+ fit: BoxFit.fill,
+ ),
+ ),
+ Text(
+ label,
+ style: const TextStyle(
+ color: Colors.white,
+ fontSize: 16,
+ fontWeight: FontWeight.w900,
+ height: 1,
+ shadows: [
+ Shadow(
+ color: Color(0x66000000),
+ offset: Offset(0, 1),
+ blurRadius: 2,
+ )
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildRecommendationCard(FlashcardEntity item, int index) {
+ return GestureDetector(
+ onTap: () {
+ controller.goToFlashcardRead(item);
+ },
+ child: Container(
+ padding: const EdgeInsets.fromLTRB(10, 10, 12, 10),
+ decoration: _cardDecoration(index),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ ClipRRect(
+ borderRadius: BorderRadius.circular(16),
+ child: Container(
+ width: 82,
+ height: 82,
+ color: Colors.white.withOpacity(0.72),
+ child: Image.asset(
+ _resolveCategoryImage(item.category, index),
+ fit: BoxFit.cover,
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 18,
+ fontWeight: FontWeight.w900,
+ color: Colors.black,
+ height: 1,
+ ),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ item.summary ?? '-',
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: const TextStyle(
+ fontSize: 14,
+ color: Color(0xFF232323),
+ height: 1.2,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Wrap(
+ spacing: 6,
+ runSpacing: 6,
+ children: [
+ _miniPill(
+ icon: Icons.collections_bookmark_outlined,
+ label: '${item.totalCards ?? 0} kartu',
+ ),
+ _miniPill(
+ icon: Icons.label_outline,
+ label: item.category ?? 'Lainnya',
+ ),
+ Text(
+ '${item.reads ?? 0}x dibaca',
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF232323),
+ ),
+ ),
+ ],
+ ),
+ // if (totalQuiz > 0)
+ // Padding(
+ // padding: const EdgeInsets.only(top: 6),
+ // child: _miniPill(
+ // icon: Icons.task_alt_outlined,
+ // label:
+ // '${completedQuiz.clamp(0, totalQuiz)}/${totalQuiz.clamp(0, 99)} kuis',
+ // ),
+ // ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _miniPill({
+ required IconData icon,
+ required String label,
+ }) {
+ return Container(
+ padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.9),
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(color: const Color(0xFF1F1F1F), width: 1),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(icon, size: 13, color: const Color(0xFF151515)),
+ const SizedBox(width: 4),
+ Text(
+ label,
+ style: const TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF151515),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ String _resolveCategoryImage(String? category, int index) {
+ final normalized = (category ?? '').toLowerCase();
+ if (normalized.contains('matematika') || normalized.contains('ipa')) {
+ return 'assets/images/flashcard-matematika.png';
+ }
+ if (normalized.contains('bahasa') || normalized.contains('indo')) {
+ return 'assets/images/flashcard-bhs-indo.png';
+ }
+ return index % 2 == 0
+ ? 'assets/images/flashcard-bhs-indo.png'
+ : 'assets/images/flashcard-matematika.png';
+ }
+
+ BoxDecoration _cardDecoration(int index) => BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ _gradientCycle[index % _gradientCycle.length],
+ const Color(0xFFFFFFFF),
+ ],
+ stops: const [0.0, 1.0],
+ ),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: Colors.white, width: 1.2),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.16),
+ blurRadius: 12,
+ offset: const Offset(0, 5),
+ ),
+ ],
+ );
+
+ Widget _buildEmptyState(String message) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ gradient: const LinearGradient(
+ colors: [
+ Color(0xFF77B4FF),
+ Color(0xFFFFFFFF),
+ ],
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ ),
+ borderRadius: BorderRadius.circular(18),
+ border: Border.all(color: Colors.white, width: 1.2),
+ ),
+ child: Row(
+ children: [
+ const Icon(Icons.info_outline, color: Color(0xFF151515), size: 18),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ message,
+ style: const TextStyle(
+ fontSize: 13,
+ fontWeight: FontWeight.w700,
+ color: Color(0xFF151515),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_read/components/dialog_add_flashcard_coll.dart b/lib/feature/flashcards/flashcard_read/components/dialog_add_flashcard_coll.dart
new file mode 100644
index 0000000..ce9fdc6
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_read/components/dialog_add_flashcard_coll.dart
@@ -0,0 +1,265 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:epic_story_app/data/models/remotes/collection_color_model.dart';
+import 'package:epic_story_app/domain/entities/collections/flashcard_collection_entity.dart';
+import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart';
+import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+const List _fallbackGradientIndexes = [2, 7, 6];
+const List _thumbAssets = [
+ 'assets/images/flashcard-bhs-indo.png',
+ 'assets/images/flashcard-matematika.png',
+ 'assets/images/tipe-flashcard.png',
+];
+
+void showAddFlashcardCollectionSheet(
+ BuildContext context,
+ FlashcardReadController controller,
+ FlashcardEntity flashcard,
+) {
+ Get.bottomSheet(
+ SafeArea(
+ top: false,
+ child: Obx(
+ () {
+ final items = controller.collections;
+
+ return Container(
+ decoration: BoxDecoration(
+ color: const Color(0xFFC6B2E6),
+ borderRadius: BorderRadius.vertical(
+ top: Radius.circular(EpicSize.calculatedSize(34)),
+ ),
+ ),
+ padding: EdgeInsets.fromLTRB(
+ EpicSize.calculatedSize(16),
+ EpicSize.calculatedSize(14),
+ EpicSize.calculatedSize(16),
+ EpicSize.calculatedSize(16),
+ ),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ 'Collection',
+ style: EpicSize.titleLarge.copyWith(
+ fontSize: EpicSize.calculatedSize(28),
+ fontWeight: FontWeight.w800,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ SizedBox(height: EpicSize.calculatedSize(20)),
+ if (items.isEmpty)
+ Padding(
+ padding: EdgeInsets.symmetric(
+ vertical: EpicSize.calculatedSize(24),
+ ),
+ child: Text(
+ 'Belum ada koleksi flashcard.',
+ style: EpicSize.bodyMedium.copyWith(
+ fontSize: EpicSize.calculatedSize(16),
+ color: const Color(0xFF333333),
+ ),
+ ),
+ )
+ else
+ ConstrainedBox(
+ constraints: BoxConstraints(
+ maxHeight: MediaQuery.of(context).size.height * 0.6,
+ ),
+ child: ListView.separated(
+ shrinkWrap: true,
+ itemCount: items.length,
+ separatorBuilder: (_, __) =>
+ SizedBox(height: EpicSize.calculatedSize(12)),
+ itemBuilder: (_, index) {
+ final item = items[index];
+ final colorIndex = item.bgColor ??
+ _fallbackGradientIndexes[
+ index % _fallbackGradientIndexes.length];
+ final gradient = collectionColorAt(colorIndex).gradient;
+ final thumbAsset =
+ _thumbAssets[index % _thumbAssets.length];
+ final category = (item.categories != null &&
+ item.categories!.isNotEmpty)
+ ? item.categories!.first
+ : '-';
+
+ return _FlashcardCollectionSheetTile(
+ item: item,
+ category: category,
+ thumbnailAsset: thumbAsset,
+ gradient: gradient,
+ onAdd: () {
+ controller.addFlashcardToCollection(
+ collectionId: item.collectionId,
+ flashcardId: flashcard.flashcardId ?? '',
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ },
+ ),
+ ),
+ isScrollControlled: true,
+ backgroundColor: Colors.transparent,
+ );
+}
+
+class _FlashcardCollectionSheetTile extends StatelessWidget {
+ const _FlashcardCollectionSheetTile({
+ required this.item,
+ required this.category,
+ required this.thumbnailAsset,
+ required this.gradient,
+ required this.onAdd,
+ });
+
+ final FlashCardCollectionEntity item;
+ final String category;
+ final String thumbnailAsset;
+ final LinearGradient gradient;
+ final VoidCallback onAdd;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ padding: EdgeInsets.all(EpicSize.calculatedSize(10)),
+ decoration: BoxDecoration(
+ gradient: gradient,
+ borderRadius: BorderRadius.circular(EpicSize.calculatedSize(20)),
+ border: Border.all(color: const Color(0x99FFFFFF), width: 1.1),
+ boxShadow: [
+ BoxShadow(
+ color: const Color(0x22000000),
+ blurRadius: EpicSize.calculatedSize(8),
+ offset: Offset(0, EpicSize.calculatedSize(2)),
+ ),
+ ],
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ SizedBox(width: EpicSize.calculatedSize(10)),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ item.title ?? '-',
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: EpicSize.titleMedium.copyWith(
+ fontSize: EpicSize.calculatedSize(20),
+ fontWeight: FontWeight.w900,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ SizedBox(height: EpicSize.calculatedSize(7)),
+ Wrap(
+ spacing: EpicSize.calculatedSize(6),
+ runSpacing: EpicSize.calculatedSize(6),
+ children: [
+ _chip(
+ icon: Icons.folder_open_outlined,
+ text: '${item.totalFlashcard ?? 0} kartu',
+ ),
+ _chip(text: category),
+ ],
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: EpicSize.calculatedSize(8)),
+ Align(
+ alignment: Alignment.centerRight,
+ child: GestureDetector(
+ onTap: onAdd,
+ child: Container(
+ width: EpicSize.calculatedSize(28),
+ height: EpicSize.calculatedSize(28),
+ decoration: const BoxDecoration(
+ color: Color.fromARGB(255, 47, 85, 255),
+ shape: BoxShape.circle,
+ ),
+ child: Icon(
+ Icons.add_rounded,
+ size: EpicSize.calculatedSize(20),
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _chip({required String text, IconData? icon}) {
+ return Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: EpicSize.calculatedSize(12),
+ vertical: EpicSize.calculatedSize(9),
+ ),
+ decoration: BoxDecoration(
+ color: Colors.white.withOpacity(0.55),
+ borderRadius: BorderRadius.circular(EpicSize.calculatedSize(6)),
+ border: Border.all(color: const Color(0xFF111111), width: 0.9),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (icon != null) ...[
+ Icon(icon,
+ size: EpicSize.calculatedSize(13),
+ color: const Color(0xFF111111)),
+ SizedBox(width: EpicSize.calculatedSize(4)),
+ ],
+ Text(
+ text,
+ style: EpicSize.bodySmall.copyWith(
+ fontSize: EpicSize.calculatedSize(12),
+ fontWeight: FontWeight.w600,
+ color: const Color(0xFF111111),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _Thumbnail extends StatelessWidget {
+ const _Thumbnail({required this.assetPath});
+
+ final String assetPath;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ width: EpicSize.calculatedSize(64),
+ height: EpicSize.calculatedSize(64),
+ decoration: BoxDecoration(
+ color: const Color(0xFFFFFFFF),
+ borderRadius: BorderRadius.circular(EpicSize.calculatedSize(14)),
+ border: Border.all(color: const Color(0x44FFFFFF), width: 1.1),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Image.asset(
+ assetPath,
+ fit: BoxFit.cover,
+ errorBuilder: (_, __, ___) => Icon(
+ Icons.style_rounded,
+ color: const Color(0xFF666666),
+ size: EpicSize.calculatedSize(24),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_read/components/flash_read_bottom_content.dart b/lib/feature/flashcards/flashcard_read/components/flash_read_bottom_content.dart
new file mode 100644
index 0000000..f7c09b9
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_read/components/flash_read_bottom_content.dart
@@ -0,0 +1,128 @@
+import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+import 'dialog_add_flashcard_coll.dart';
+
+class FlashReadBottomContent extends StatelessWidget {
+ const FlashReadBottomContent({
+ super.key,
+ required this.controller,
+ });
+
+ final FlashcardReadController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(
+ () {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(16, 8, 16, 10),
+ child: Row(
+ children: [
+ _navArrowButton(
+ onTap: controller.goPrev,
+ enabled: !controller.isFlipping.value &&
+ controller.currentIndex.value > 0,
+ isPrevious: true,
+ ),
+ const SizedBox(width: 12),
+ _navArrowButton(
+ onTap: controller.goNext,
+ enabled: !controller.isFlipping.value,
+ isPrevious: false,
+ ),
+ const Spacer(),
+ Row(
+ children: [
+ _actionButton(
+ icon: controller.isSoundOn.value
+ ? Icons.volume_up_rounded
+ : Icons.volume_off_rounded,
+ onTap: controller.toggleSound,
+ enabled: !controller.isFlipping.value,
+ backgroundColor: const Color(0xFF999999),
+ iconColor: controller.isSoundOn.value
+ ? const Color(0xFFF3D86A)
+ : const Color(0xFFE4E4E4),
+ ),
+ const SizedBox(width: 12),
+ _actionButton(
+ icon: controller.isBookmarked.value
+ ? Icons.bookmark
+ : Icons.bookmark_border,
+ onTap: () {
+ showAddFlashcardCollectionSheet(
+ context,
+ controller,
+ controller.flashcard,
+ );
+ },
+ enabled: true,
+ backgroundColor: const Color(0xFFFFED8C),
+ iconColor: const Color(0xFF8D8D8D),
+ ),
+ ],
+ ),
+ ],
+ ),
+ );
+ },
+ );
+ }
+
+ Widget _navArrowButton({
+ required VoidCallback onTap,
+ required bool enabled,
+ required bool isPrevious,
+ }) {
+ return _actionButton(
+ icon: Icons.play_arrow_rounded,
+ onTap: onTap,
+ enabled: enabled,
+ backgroundColor: const Color(0xFF66B0FF),
+ iconColor: const Color(0xFF151515),
+ isPrevious: isPrevious,
+ );
+ }
+
+ Widget _actionButton({
+ required IconData icon,
+ required VoidCallback onTap,
+ required bool enabled,
+ required Color backgroundColor,
+ required Color iconColor,
+ bool isPrevious = false,
+ }) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: enabled ? onTap : null,
+ borderRadius: BorderRadius.circular(12),
+ child: Ink(
+ width: 62,
+ height: 62,
+ decoration: BoxDecoration(
+ color: backgroundColor,
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: const Color(0xFF2A2A2A),
+ width: 1.2,
+ ),
+ ),
+ child: Center(
+ child: Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.identity()..rotateZ(isPrevious ? 3.14159 : 0),
+ child: Icon(
+ icon,
+ size: 36,
+ color: iconColor,
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_read/components/flash_read_main_content.dart b/lib/feature/flashcards/flashcard_read/components/flash_read_main_content.dart
new file mode 100644
index 0000000..395b0c5
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_read/components/flash_read_main_content.dart
@@ -0,0 +1,453 @@
+import 'dart:math';
+
+import 'package:epic_story_app/core/styles/epic_box_colors.dart';
+import 'package:epic_story_app/core/styles/epic_app_size.dart';
+import 'package:epic_story_app/core/widgets/cards/box_shadow.dart';
+import 'package:epic_story_app/domain/entities/flashcards/flashcard_card_entity.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/gestures.dart';
+import 'package:get/get.dart';
+
+import '../flashcard_read_controller.dart';
+
+class FlashReadMainContent extends StatelessWidget {
+ const FlashReadMainContent({
+ super.key,
+ required this.controller,
+ });
+
+ final FlashcardReadController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ return Expanded(
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final cardWidth = EpicAppSize.screenWidth * 0.85;
+ final cardHeight = constraints.maxHeight * 0.95;
+
+ return Center(
+ child: GestureDetector(
+ onHorizontalDragUpdate: (details) {
+ controller.updateDrag(details.delta.dx);
+ },
+ onHorizontalDragEnd: (_) {
+ controller.endDrag();
+ },
+ child: Obx(() {
+ final dragX = controller.dragOffset.value;
+ final isQuizCard = controller.isQuiz;
+ return TweenAnimationBuilder(
+ tween: Tween(begin: 0, end: dragX),
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOut,
+ builder: (context, dx, child) {
+ final rotationZ = (dx / 320) * 0.18; // softer tilt
+ final lift = -(dx.abs() / 320) * 10; // softer lift
+
+ return AnimatedBuilder(
+ animation: controller.flipAnimation,
+ builder: (context, animatedChild) {
+ final flipValue = controller.flipAnimation.value;
+ final direction = controller.flipDirection.value;
+ final rawAngle = flipValue * pi * direction;
+ final isSecondHalf = flipValue > 0.5;
+ final correctedAngle = isSecondHalf
+ ? rawAngle - (pi * direction)
+ : rawAngle;
+
+ final flipCenter = (flipValue - 0.5).abs();
+ final scale = 0.96 +
+ (0.04 * (1 - flipCenter * 2).clamp(0.0, 1.0));
+
+ return Transform(
+ alignment: Alignment.center,
+ transform: Matrix4.identity()
+ ..setEntry(3, 2, 0.0022)
+ ..translate(dx, lift)
+ ..rotateZ(rotationZ)
+ ..rotateY(correctedAngle)
+ ..scale(scale),
+ child: animatedChild,
+ );
+ },
+ child: child,
+ );
+ },
+ child: Stack(
+ clipBehavior: Clip.none,
+ children: [
+ ShadowBox(
+ key: ValueKey(controller.currentIndex.value),
+ width: cardWidth,
+ height: cardHeight,
+ padding: const EdgeInsets.all(18),
+ cardColor: BoxColors.cardColorQuiz,
+ cardGradient: isQuizCard
+ ? null
+ : const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Color(0xFF118B99),
+ Color(0xFF1CE7FF),
+ ],
+ stops: [0.1, 1.0],
+ ),
+ shadowGradient: isQuizCard
+ ? null
+ : const LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Color(0xFF0D6874),
+ Color(0xFF118B99),
+ ],
+ ),
+ radius: BorderRadius.circular(
+ EpicAppSize.calculatedSize(24),
+ ),
+ child: LayoutBuilder(
+ builder: (context, boxConstraints) {
+ return SingleChildScrollView(
+ child: ConstrainedBox(
+ constraints: BoxConstraints(
+ minHeight: boxConstraints.maxHeight,
+ ),
+ child: Center(
+ child: Obx(() {
+ if (controller.isLoading.value) {
+ return const CircularProgressIndicator();
+ }
+ final image = controller.currentImage.value;
+ final text = controller.currentText.value;
+ final hStart =
+ controller.highlightedStart.value;
+ final hEnd =
+ controller.highlightedEnd.value;
+ const baseStyle = TextStyle(
+ fontSize: 24,
+ color: Colors.black,
+ fontWeight: FontWeight.bold,
+ height: 1.4,
+ );
+
+ InlineSpan _buildWordSpan(
+ String word,
+ int start,
+ TextStyle style,
+ int? hStart,
+ int? hEnd,
+ ) {
+ final end = start + word.length;
+ final hasHighlight = hStart != null &&
+ hEnd != null &&
+ !(end <= hStart || start >= hEnd);
+ final wordStyle = hasHighlight
+ ? style.copyWith(
+ backgroundColor: Colors.yellow
+ .withOpacity(0.4),
+ )
+ : style;
+
+ return TextSpan(
+ text: word,
+ style: wordStyle,
+ recognizer: TapGestureRecognizer()
+ ..onTap = () => controller
+ .speakWordAt(start, end),
+ );
+ }
+
+ List _buildSpans(
+ String fullText) {
+ final spans = [];
+ int cursor = 0;
+ final regex = RegExp(r'\s+');
+ for (final match
+ in regex.allMatches(fullText)) {
+ final word = fullText.substring(
+ cursor, match.start);
+ if (word.isNotEmpty) {
+ spans.add(_buildWordSpan(word, cursor,
+ baseStyle, hStart, hEnd));
+ }
+
+ final space = fullText.substring(
+ match.start, match.end);
+ spans.add(TextSpan(
+ text: space, style: baseStyle));
+ cursor = match.end;
+ }
+
+ if (cursor < fullText.length) {
+ final tail = fullText.substring(cursor);
+ spans.add(_buildWordSpan(tail, cursor,
+ baseStyle, hStart, hEnd));
+ }
+ return spans;
+ }
+
+ if (controller.isQuiz) {
+ return controller.getQuizWidget();
+ }
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Visibility(
+ visible:
+ image != null && image.isNotEmpty,
+ child: Column(
+ children: [
+ ClipRRect(
+ borderRadius:
+ BorderRadius.circular(16),
+ child: Image.network(
+ image ?? '',
+ width: cardWidth * 0.9,
+ height: cardHeight * 0.4,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error,
+ stackTrace) {
+ return Container(
+ width: cardWidth * 0.9,
+ height: cardHeight * 0.4,
+ color: Colors.black12,
+ alignment:
+ Alignment.center,
+ child: const Icon(
+ Icons
+ .broken_image_outlined,
+ size: 48,
+ color: Colors.black45,
+ ),
+ );
+ },
+ ),
+ ),
+ const SizedBox(height: 12),
+ ],
+ ),
+ ),
+ RichText(
+ textAlign: TextAlign.center,
+ text: TextSpan(
+ children: _buildSpans(text),
+ ),
+ ),
+ ],
+ );
+ }),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ Positioned(
+ top: -20,
+ right: 16,
+ child: Obx(() {
+ if (controller.isLoading.value) {
+ return const SizedBox();
+ }
+ final isCorrect =
+ controller.quizController.isAnswerCorrect.value;
+ String? explanation;
+ if (!controller.isQuiz) {
+ return const SizedBox();
+ }
+ FlashcardCardEntity? currentCard =
+ controller.currentCard.value;
+ final quizType = currentCard?.quizType;
+ if (quizType == 1) {
+ explanation =
+ currentCard?.quiz?.multipleChoice?.explanation;
+ } else if (quizType == 2) {
+ explanation =
+ currentCard?.quiz?.fillBlank?.explanation;
+ } else if (quizType == 3) {
+ explanation = currentCard?.quiz?.group?.explanation;
+ }
+
+ if (isCorrect) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(12),
+ onTap: () => controller.quizController
+ .showExplanationDialog(explanation),
+ child: Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: Color(0xFFFFD700),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: Colors.black,
+ width: 2,
+ ),
+ ),
+ child: Icon(
+ Icons.help_outline,
+ size: 22,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ );
+ }
+
+ if (isCorrect) {
+ return const SizedBox();
+ }
+ final retries = controller.retryQuestion.value;
+
+ if (retries <= 0) {
+ return Row(
+ children: [
+ _PulsingRetryButton(
+ onPressed: () {
+ controller.generateQuiz(
+ forceGenerate: true,
+ );
+ },
+ ),
+ SizedBox(width: 8),
+ Material(
+ color: Colors.transparent,
+ child: InkWell(
+ borderRadius: BorderRadius.circular(12),
+ onTap: () => controller.quizController
+ .showExplanationDialog(explanation),
+ child: Container(
+ padding: const EdgeInsets.all(10),
+ decoration: BoxDecoration(
+ color: Color(0xFFFFD700),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: Colors.black,
+ width: 1.2,
+ ),
+ ),
+ child: Icon(
+ Icons.help_outline,
+ size: 22,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 10, vertical: 6),
+ decoration: BoxDecoration(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: BoxColors.purplePrimary.backgroundColor,
+ width: 1.1,
+ ),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: List.generate(3, (index) {
+ final filled = retries > index;
+ return Padding(
+ padding:
+ EdgeInsets.only(left: index == 0 ? 0 : 8),
+ child: Icon(
+ filled
+ ? Icons.favorite
+ : Icons.favorite_border,
+ size: 30,
+ color: filled
+ ? Colors.pink
+ : BoxColors.purplePrimary.textColor,
+ ),
+ );
+ }),
+ ),
+ );
+ }),
+ ),
+ ],
+ ),
+ );
+ }),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+class _PulsingRetryButton extends StatefulWidget {
+ const _PulsingRetryButton({required this.onPressed});
+
+ final VoidCallback onPressed;
+
+ @override
+ State<_PulsingRetryButton> createState() => _PulsingRetryButtonState();
+}
+
+class _PulsingRetryButtonState extends State<_PulsingRetryButton>
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _controller;
+ late final Animation _scaleAnimation;
+
+ @override
+ void initState() {
+ super.initState();
+ _controller = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 900),
+ )..repeat(reverse: true);
+
+ _scaleAnimation = Tween(begin: 0.94, end: 1.06).animate(
+ CurvedAnimation(
+ parent: _controller,
+ curve: Curves.easeInOut,
+ ),
+ );
+ }
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ScaleTransition(
+ scale: _scaleAnimation,
+ child: TextButton(
+ style: TextButton.styleFrom(
+ backgroundColor: Color(0xFF0DFF00),
+ foregroundColor: Colors.black,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+ side: BorderSide(
+ color: Colors.black,
+ width: 1.1,
+ ),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(14),
+ ),
+ ),
+ onPressed: widget.onPressed,
+ child: const Text(
+ 'Coba Lagi',
+ style: TextStyle(
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_read/components/flash_read_top_content.dart b/lib/feature/flashcards/flashcard_read/components/flash_read_top_content.dart
new file mode 100644
index 0000000..73b22a6
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_read/components/flash_read_top_content.dart
@@ -0,0 +1,189 @@
+import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart';
+import 'package:flutter/material.dart';
+import 'package:get/get.dart';
+
+class FlashReadTopContent extends StatelessWidget {
+ const FlashReadTopContent({
+ super.key,
+ required this.controller,
+ });
+
+ final FlashcardReadController controller;
+
+ @override
+ Widget build(BuildContext context) {
+ return Obx(() {
+ final current = controller.currentIndex.value + 1;
+ final total = controller.totalCards;
+ final progress = total <= 0 ? 0.0 : (current / total).clamp(0.0, 1.0);
+
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(14, 8, 14, 4),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ _TopBackButton(onTap: Get.back),
+ const SizedBox(width: 10),
+ Expanded(
+ child: SizedBox(
+ height: 55,
+ child: Stack(
+ children: [
+ Positioned.fill(
+ child: Image.asset(
+ 'assets/images/judul-kartu.png',
+ fit: BoxFit.fill,
+ ),
+ ),
+ Positioned.fill(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 15,
+ ),
+ child: Center(
+ child: FittedBox(
+ fit: BoxFit.scaleDown,
+ child: Padding(
+ padding: EdgeInsets.only(bottom: 8.0),
+ child: Text(
+ controller.flashcard.title ??
+ 'Judul Flashcard',
+ maxLines: 1,
+ textAlign: TextAlign.center,
+ softWrap: false,
+ style: const TextStyle(
+ color: Colors.white,
+ height: 1,
+ fontSize: 22,
+ fontWeight: FontWeight.w800,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(width: 10),
+ ],
+ ),
+ const SizedBox(height: 8),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 14),
+ child: _ReadProgressBar(
+ progress: progress,
+ current: current,
+ total: total,
+ ),
+ ),
+ ],
+ ),
+ );
+ });
+ }
+}
+
+class _TopBackButton extends StatelessWidget {
+ const _TopBackButton({required this.onTap});
+
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ return Material(
+ color: Colors.transparent,
+ child: InkWell(
+ onTap: onTap,
+ borderRadius: BorderRadius.circular(14),
+ child: Ink(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(14),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withOpacity(0.18),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: const Icon(
+ Icons.arrow_back_ios_new_rounded,
+ size: 21,
+ color: Colors.black,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _ReadProgressBar extends StatelessWidget {
+ const _ReadProgressBar({
+ required this.progress,
+ required this.current,
+ required this.total,
+ });
+
+ final double progress;
+ final int current;
+ final int total;
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ height: 16,
+ decoration: BoxDecoration(
+ color: const Color(0xFFD7C7EF),
+ borderRadius: BorderRadius.circular(12),
+ border: Border.all(
+ color: const Color(0xFF2B2451),
+ width: 1,
+ ),
+ ),
+ child: Stack(
+ children: [
+ Align(
+ alignment: Alignment.centerLeft,
+ child: FractionallySizedBox(
+ widthFactor: progress,
+ child: Container(
+ margin: const EdgeInsets.all(1.5),
+ decoration: BoxDecoration(
+ color: const Color(0xFF1CE7A6),
+ borderRadius: BorderRadius.horizontal(
+ left: const Radius.circular(10),
+ right: progress >= 0.99
+ ? const Radius.circular(10)
+ : Radius.zero,
+ ),
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ right: 20,
+ top: 0,
+ bottom: 0,
+ child: Center(
+ child: Text(
+ '$current/$total',
+ style: const TextStyle(
+ color: Colors.black,
+ fontSize: 12,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_read/components/flashcard_collection_tile.dart b/lib/feature/flashcards/flashcard_read/components/flashcard_collection_tile.dart
new file mode 100644
index 0000000..c479f2b
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_read/components/flashcard_collection_tile.dart
@@ -0,0 +1,87 @@
+import 'package:epic_story_app/core/constants/size/epic_size.dart';
+import 'package:flutter/material.dart';
+
+class CollectionTile extends StatelessWidget {
+ final String title;
+ final int totalBooks;
+ final VoidCallback onAdd;
+
+ const CollectionTile({
+ super.key,
+ required this.title,
+ required this.totalBooks,
+ required this.onAdd,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ return Container(
+ decoration: BoxDecoration(
+ borderRadius: EpicSize.radiusSmall,
+ border: Border.all(color: Colors.black26),
+ ),
+ padding: EdgeInsets.all(EpicSize.sizeSmall),
+ child: Row(
+ children: [
+ Container(
+ width: EpicSize.calculatedSize(32),
+ height: EpicSize.calculatedSize(32),
+ decoration: BoxDecoration(
+ color: Colors.black12,
+ borderRadius: EpicSize.radiusExtraSmall,
+ ),
+ child: const Icon(Icons.image, size: 18, color: Colors.black45),
+ ),
+ SizedBox(width: EpicSize.sizeSmall),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style:
+ EpicSize.titleSmall.copyWith(fontWeight: FontWeight.w700),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ SizedBox(height: EpicSize.sizeSuperSmall),
+ Container(
+ padding: EdgeInsets.symmetric(
+ horizontal: EpicSize.sizeSmall,
+ vertical: EpicSize.sizeSuperSmall,
+ ),
+ decoration: BoxDecoration(
+ borderRadius: EpicSize.radiusExtraSmall,
+ border: Border.all(color: Colors.black45),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const Icon(Icons.book_outlined, size: 14),
+ SizedBox(width: EpicSize.sizeSuperSmall),
+ Text('Total Buku : $totalBooks',
+ style: EpicSize.bodySmall),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ SizedBox(width: EpicSize.sizeSmall),
+ OutlinedButton(
+ onPressed: onAdd,
+ style: OutlinedButton.styleFrom(
+ padding: EdgeInsets.symmetric(
+ horizontal: EpicSize.sizeSmall,
+ vertical: EpicSize.sizeSuperSmall,
+ ),
+ side: const BorderSide(color: Colors.black45),
+ ),
+ child: Icon(Icons.add,
+ color: Colors.black45, size: EpicSize.calculatedSize(20)),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/feature/flashcards/flashcard_read/components/quiz/components/confetti_burst.dart b/lib/feature/flashcards/flashcard_read/components/quiz/components/confetti_burst.dart
new file mode 100644
index 0000000..b93f29b
--- /dev/null
+++ b/lib/feature/flashcards/flashcard_read/components/quiz/components/confetti_burst.dart
@@ -0,0 +1,203 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+/// Lightweight, reusable confetti burst that fires upward from the bottom
+/// center. Trigger via [ConfettiBurstController.fire()].
+class ConfettiBurstController extends ChangeNotifier {
+ void fire() => notifyListeners();
+}
+
+class ConfettiBurst extends StatefulWidget {
+ const ConfettiBurst({
+ super.key,
+ required this.controller,
+ required this.child,
+ this.colors = const [
+ Color(0xFF6B42B8),
+ Color(0xFF2ECC71),
+ Color(0xFFF39C12),
+ Color(0xFFE74C3C),
+ Color(0xFF3498DB),
+ ],
+ this.particleCount = 140,
+ this.duration = const Duration(milliseconds: 1600),
+ });
+
+ final ConfettiBurstController controller;
+ final Widget child;
+ final List