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 colors; + final int particleCount; + final Duration duration; + + @override + State createState() => _ConfettiBurstState(); +} + +class _ConfettiBurstState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _anim; + final List<_ConfettiParticle> _particles = []; + final math.Random _rand = math.Random(); + + @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 ConfettiBurst 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() { + _particles + ..clear() + ..addAll(List.generate(widget.particleCount, (_) => _spawnParticle())); + _anim.forward(from: 0); + } + + _ConfettiParticle _spawnParticle() { + final fan = 1.7; // wider spread for big burst + final angle = -math.pi / 2 + (_rand.nextDouble() * fan * 2 - fan); + final speed = + _rand.nextDouble() * 900 + 1100; // px/s equivalent for higher throw + final size = _rand.nextDouble() * 7 + 7; + final sway = _rand.nextDouble() * math.pi * 2; + final spin = _rand.nextDouble() * math.pi * 2.2; + final color = widget.colors[_rand.nextInt(widget.colors.length)]; + return _ConfettiParticle( + angle: angle, + speed: speed, + size: size, + sway: sway, + spin: spin, + color: color, + ); + } + + @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: _ConfettiPainter( + progress: _anim.value, + particles: _particles, + durationSeconds: widget.duration.inMilliseconds / 1000, + ), + ), + ), + ), + ], + ); + }, + child: widget.child, + ); + } +} + +class _ConfettiParticle { + _ConfettiParticle({ + required this.angle, + required this.speed, + required this.size, + required this.sway, + required this.spin, + required this.color, + }); + + final double angle; + final double speed; + final double size; + final double sway; + final double spin; + final Color color; +} + +class _ConfettiPainter extends CustomPainter { + _ConfettiPainter({ + required this.progress, + required this.particles, + required this.durationSeconds, + }); + + final double progress; + final List<_ConfettiParticle> particles; + final double durationSeconds; + + @override + void paint(Canvas canvas, Size size) { + if (progress <= 0 || particles.isEmpty) return; + final centerX = size.width / 2; + final baseY = size.height; + const gravity = 420.0; // slower pull so pieces climb higher before falling + final paint = Paint(); + + for (final p in particles) { + final t = progress * durationSeconds; + final vx = math.cos(p.angle) * p.speed; + final vy = math.sin(p.angle) * p.speed; + + var x = centerX + (vx * t); + // start from bottom, shoot upward (vy negative), then gravity pulls down + var y = baseY + (vy * t) + 0.5 * gravity * math.pow(t, 2); + // horizontal sway + x += math.sin(t * 8 + p.sway) * 12; + + // Cap so we don't paint way off canvas + if (y < -80 || y > size.height + 140) continue; + + final rotation = p.spin * t; + paint.color = p.color.withOpacity((1 - progress).clamp(0.0, 1.0)); + + canvas.save(); + canvas.translate(x, y); + canvas.rotate(rotation); + canvas.drawRRect( + RRect.fromRectAndCorners( + Rect.fromCenter( + center: Offset.zero, width: p.size, height: p.size * 2), + topLeft: const Radius.circular(3), + topRight: const Radius.circular(3), + bottomLeft: const Radius.circular(3), + bottomRight: const Radius.circular(3), + ), + paint, + ); + canvas.restore(); + } + } + + @override + bool shouldRepaint(covariant _ConfettiPainter oldDelegate) { + return oldDelegate.progress != progress || + oldDelegate.particles != particles || + oldDelegate.durationSeconds != durationSeconds; + } +} diff --git a/lib/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart b/lib/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart new file mode 100644 index 0000000..0f1db7c --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart @@ -0,0 +1,165 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// Small burst of rays that can be triggered from a controller. +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({ + 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/flashcards/flashcard_read/components/quiz/fill_blank_quiz.dart b/lib/feature/flashcards/flashcard_read/components/quiz/fill_blank_quiz.dart new file mode 100644 index 0000000..bbc4abf --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/components/quiz/fill_blank_quiz.dart @@ -0,0 +1,355 @@ +import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class FillBlankQuiz extends StatelessWidget { + const FillBlankQuiz({ + super.key, + required this.quizController, + required this.quizData, + }); + + final FlashcardQuizController 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.flashcardReadController.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 FlashcardQuizController 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 FlashcardQuizController 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.flashcardReadController + .currentCard.value?.quiz?.fillBlank?.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.flashcardReadController.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 FlashcardQuizController quizController; + final bool isLocked; + + @override + State<_OptionsTray> createState() => _OptionsTrayState(); +} + +class _OptionsTrayState extends State<_OptionsTray> { + final ScrollController _scrollController = ScrollController(); + late final RayBurstController _rayController; + late final List _shuffledOptions; + + List _unassigned() { + return _shuffledOptions.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(); + _shuffledOptions = List.from(widget.options)..shuffle(); + } + + @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/flashcards/flashcard_read/components/quiz/group_quiz.dart b/lib/feature/flashcards/flashcard_read/components/quiz/group_quiz.dart new file mode 100644 index 0000000..5411d09 --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/components/quiz/group_quiz.dart @@ -0,0 +1,482 @@ +import 'package:epic_story_app/domain/entities/quiz/quiz_group_entity.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/components/quiz/components/ray_burst.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class GroupQuiz extends StatelessWidget { + const GroupQuiz({ + super.key, + required this.quizController, + required this.quizData, + }); + + final FlashcardQuizController 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.flashcardReadController.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.flashcardReadController.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 FlashcardQuizController 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 FlashcardQuizController 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 FlashcardQuizController 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 FlashcardQuizController quizController; + final bool isLocked; + + @override + State<_UnassignedOptionsRow> createState() => _UnassignedOptionsRowState(); +} + +class _UnassignedOptionsRowState extends State<_UnassignedOptionsRow> { + final ScrollController _scrollController = ScrollController(); + late final RayBurstController _rayController; + late final List _shuffledOptions; + + List _unassigned() { + return _shuffledOptions.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(); + _shuffledOptions = List.from(widget.options)..shuffle(); + } + + @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: 120, + child: Scrollbar( + controller: _scrollController, + thumbVisibility: true, + trackVisibility: true, + interactive: 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/flashcards/flashcard_read/components/quiz/multiple_choice_quiz.dart b/lib/feature/flashcards/flashcard_read/components/quiz/multiple_choice_quiz.dart new file mode 100644 index 0000000..5d89cdc --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/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/flashcards/flashcard_read/flashcard_quiz_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MultipleChoiceQuiz extends StatelessWidget { + const MultipleChoiceQuiz({ + super.key, + required this.quizController, + required this.quizData, + }); + + final FlashcardQuizController 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); // A, B, C, ... + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Obx(() { + final isSelected = quizController.assignedChoice.value == index; + final isChecked = quizController.isAnswerChecked.value; + final retryLeft = + quizController.flashcardReadController.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/flashcards/flashcard_read/flashcard_quiz_controller.dart b/lib/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart new file mode 100644 index 0000000..1b945fc --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/flashcard_quiz_controller.dart @@ -0,0 +1,876 @@ +import 'dart:convert'; + +import 'package:confetti/confetti.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_group_mapper.dart'; +import 'package:epic_story_app/data/models/mappers/quiz_fill_blank_mapper.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_answer_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_group_entity.dart'; +import 'package:epic_story_app/domain/entities/quiz/quiz_fill_blank_entity.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_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 FlashcardQuizController extends GetxController { + FlashcardQuizController({required this.flashcardReadController}); + + final FlashcardReadController flashcardReadController; + + final navController = Get.find(); + final isAnswerChecked = false.obs; + final isAnswerCorrect = false.obs; + final assignedChoice = (-1).obs; + final assignedGroupByOption = {}.obs; + final assignedBlankByOption = {}.obs; + final ConfettiController confettiController = + ConfettiController(duration: const Duration(milliseconds: 1500)); + final ConfettiController dialogConfettiController = + ConfettiController(duration: const Duration(milliseconds: 2000)); + + void syncCurrentQuiz() { + EpicLog.debug("_syncCurrentQuiz - Syncing quiz state with current card"); + final currentCard = flashcardReadController.currentCard.value; + if (currentCard == null) return; + EpicLog.debug( + "_syncCurrentQuiz - Current card page: ${currentCard.nPage}, isQuiz: ${currentCard.quizType != null}"); + if (flashcardReadController.isQuiz) { + final history = flashcardReadController.flashcardHistory.value; + if (history == null) return; + EpicLog.debug( + "_syncCurrentQuiz - Found flashcard history with ${history.cards?.length ?? 0} cards"); + + final cardHistoryIndex = + history.cards?.indexWhere((h) => h.nPage == currentCard.nPage); + if (cardHistoryIndex == null || cardHistoryIndex < 0) return; + EpicLog.debug( + "_syncCurrentQuiz - Found matching card history at index: $cardHistoryIndex"); + + final cardHistory = history.cards![cardHistoryIndex]; + final historyAnswer = cardHistory.historyAnswer; + EpicLog.debug( + "_syncCurrentQuiz - historyAnswer ${historyAnswer?.answer}"); + if (historyAnswer == null) return; + EpicLog.debug( + "_syncCurrentQuiz - Found history answer for current card - quizType: ${historyAnswer.quizType}, retry: ${historyAnswer.retry}"); + final quizType = currentCard.quizType ?? 0; + if (quizType == 1) { + assignedChoice.value = + historyAnswer.multipleChoiceAnswer?.selectedAnswer ?? -1; + isAnswerChecked.value = true; + isAnswerCorrect.value = cardHistory.correctAnswer ?? false; + EpicLog.debug( + "_syncCurrentQuiz - Synced multiple choice answer: selected index ${assignedChoice.value}"); + } else if (quizType == 2) { + assignedBlankByOption.clear(); + assignedBlankByOption + .addAll(historyAnswer.fillBlankAnswer?.assignedBlankByOption ?? {}); + isAnswerChecked.value = true; + isAnswerCorrect.value = cardHistory.correctAnswer ?? false; + EpicLog.debug( + "_syncCurrentQuiz - Synced fill blank answer: assigned blanks $assignedBlankByOption"); + } else if (quizType == 3) { + assignedGroupByOption.clear(); + assignedGroupByOption + .addAll(historyAnswer.groupAnswer?.assignedGroupByOption ?? {}); + isAnswerChecked.value = true; + isAnswerCorrect.value = cardHistory.correctAnswer ?? false; + EpicLog.debug( + "_syncCurrentQuiz - Synced group quiz answer: assigned groups $assignedGroupByOption"); + } + } + } + + void selectChoice(int index) { + assignedChoice.value = index; + isAnswerChecked.value = false; + } + + void resetState() { + assignedChoice.value = -1; + isAnswerChecked.value = false; + isAnswerCorrect.value = false; + assignedGroupByOption.clear(); + assignedBlankByOption.clear(); + } + + @override + void onClose() { + confettiController.dispose(); + dialogConfettiController.dispose(); + super.onClose(); + } + + void _showCongratsDialog({ + String title = "Selamat!", + String message = "Jawaban kamu benar.", + String? explanation, + }) { + dialogConfettiController.play(); + Get.dialog( + Stack( + alignment: Alignment.center, + children: [ + // Confetti + Align( + alignment: Alignment.center, + child: ConfettiWidget( + confettiController: dialogConfettiController, + blastDirectionality: BlastDirectionality.explosive, + emissionFrequency: 0.18, + numberOfParticles: 90, + gravity: 0.22, + minBlastForce: 8, + maxBlastForce: 16, + minimumSize: const Size(6, 10), + maximumSize: const Size(14, 20), + shouldLoop: true, + colors: const [ + Color(0xFF6B42B8), + Color(0xFF2ECC71), + Color(0xFFF39C12), + Color(0xFFE74C3C), + Color(0xFF3498DB), + ], + ), + ), + // Dialog + Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.symmetric(horizontal: 28), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: [ + // Main card + Container( + margin: const EdgeInsets.only(top: 44), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFFFFF7D0), + Color(0xFFFFE566), + Color(0xFFFFCF20), + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: const Color(0xFFE8A800), + width: 3, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE8A800).withOpacity(0.4), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + padding: const EdgeInsets.fromLTRB(22, 58, 22, 22), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('⭐', style: TextStyle(fontSize: 22)), + const SizedBox(width: 8), + Flexible( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: Color(0xFF4A148C), + letterSpacing: 0.5, + ), + ), + ), + const SizedBox(width: 8), + const Text('⭐', style: TextStyle(fontSize: 22)), + ], + ), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.65), + borderRadius: BorderRadius.circular(14), + ), + child: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: Color(0xFF4E342E), + height: 1.4, + ), + ), + ), + const SizedBox(height: 20), + GestureDetector( + onTap: () { + Get.back(); + showExplanationDialog( + explanation, + showNextButton: true, + ); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFF6B42B8), + borderRadius: BorderRadius.circular(50), + border: Border.all( + color: Colors.black, + width: 2, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black, + offset: Offset(0, 4), + blurRadius: 0, + ), + ], + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Lanjut', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + SizedBox(width: 6), + Text('🚀', style: TextStyle(fontSize: 18)), + ], + ), + ), + ), + ], + ), + ), + // Trophy badge + Positioned( + top: 0, + child: Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: const Color(0xFFE8A800), + width: 3.5, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFFE8A800).withOpacity(0.5), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], + ), + child: const Center( + child: Text('🏆', style: TextStyle(fontSize: 46)), + ), + ), + ), + ], + ), + ), + ], + ), + barrierDismissible: false, + barrierColor: Colors.black.withOpacity(0.5), + ); + } + + void showExplanationDialog( + String? explanation, { + bool showNextButton = false, + }) { + 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( + explanation ?? 'Penjelasan belum tersedia.', + textAlign: TextAlign.left, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Color(0xFF37474F), + height: 1.6, + ), + ), + ), + ), + const SizedBox(height: 20), + if (showNextButton) + GestureDetector( + onTap: () { + Get.back(); + flashcardReadController.goToResult(); + }, + child: Container( + width: double.infinity, + padding: + const EdgeInsets.symmetric(vertical: 13), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF6B42B8), + Color(0xFF9C5FD4), + ], + ), + borderRadius: BorderRadius.circular(50), + border: Border.all( + color: Colors.black, + width: 1.5, + ), + boxShadow: const [ + BoxShadow( + color: Colors.black, + offset: Offset(0, 3), + blurRadius: 0, + ), + ], + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Selanjutnya', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + SizedBox(width: 6), + Text( + '✨', + style: TextStyle(fontSize: 16), + ), + ], + ), + ), + ) + else + 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: -10, + 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: !showNextButton, + barrierColor: Colors.black.withOpacity(0.4), + ); + } + + void assignOptionToGroup( + {required String optionId, required String? groupId}) { + // Enforce max 4 items per group (unless reassigning same option to same group) + 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", + "Each group can only hold 4 items.", + ); + return; + } + } + + if (optionId.isEmpty) return; + if (groupId == null || groupId.isEmpty) { + assignedGroupByOption.remove(optionId); + } else { + assignedGroupByOption[optionId] = groupId; + } + // reset check state on change + 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, + QuizChoiceAnswerEntity? multipleChoiceAnswer, + QuizChoiceEntity? multipleChoiceQuestion, + QuizGroupAnswerEntity? groupAnswer, + QuizGroupEntity? groupQuestion, + QuizFillBlankAnswerEntity? fillBlankAnswer, + QuizFillBlankEntity? fillBlankQuestion, + }) async { + final currentCard = flashcardReadController.currentCard.value; + if (currentCard == null) return; + + final history = flashcardReadController.flashcardHistory.value; + if (history == null) return; + + final cardHistoryIndex = + history.cards?.indexWhere((h) => h.nPage == currentCard.nPage); + if (cardHistoryIndex == null || cardHistoryIndex < 0) return; + + final cardHistory = history.cards![cardHistoryIndex]; + var updatedAnswer = FlashcardHistoryAnswerEntity( + quizType: quizType, + retry: flashcardReadController.retryQuestion.value, + question: question, + answer: answer, + ); + + if (quizType == 1) { + updatedAnswer = updatedAnswer.copyWith( + multipleChoiceQuestion: multipleChoiceQuestion, + multipleChoiceAnswer: multipleChoiceAnswer, + ); + } else if (quizType == 2) { + updatedAnswer = updatedAnswer.copyWith( + fillBlankQuestion: fillBlankQuestion, + fillBlankAnswer: fillBlankAnswer, + ); + } else if (quizType == 3) { + updatedAnswer = updatedAnswer.copyWith( + groupQuestion: groupQuestion, + groupAnswer: groupAnswer, + ); + } + + final updatedCardHistory = cardHistory.copyWith( + correctAnswer: isCorrect, + completedQuiz: + isCorrect || flashcardReadController.retryQuestion.value <= 0, + historyAnswer: updatedAnswer, + ); + EpicLog.debug( + "updateHistoryAnswer - ${updatedAnswer.groupAnswer?.assignedGroupByOption} - isCorrect: $isCorrect, retry left: ${flashcardReadController.retryQuestion.value}"); + + var updatedCards = [...?history.cards]; + updatedCards[cardHistoryIndex] = updatedCardHistory; + + var totalCard = history.totalCards ?? 0; + var totalQuiz = history.totalQuiz ?? 0; + var completedQuiz = history.completedQuiz ?? 0; + if (isCorrect) { + if (completedQuiz < totalQuiz) { + completedQuiz += 1; + } + } + + completedQuiz = completedQuiz; + var isAllQuizCompleted = (completedQuiz >= totalQuiz); + var readPages = (history.cards?.length ?? 0) + 1; + var isAllRead = readPages >= totalCard; + var isFinished = isAllRead && isAllQuizCompleted; + var updatedHistory = history.copyWith( + cards: updatedCards, + completedQuiz: completedQuiz, + isFinished: isFinished, + ); + await flashcardReadController.childrenUsecase.updateFlashcardHistory( + flashcardHistory: updatedHistory, + ); + flashcardReadController.flashcardHistory.value = updatedHistory; + } + + void onCheckAnswer() { + EpicLog.debug("Checking answer - step 1: validate state"); + if (!flashcardReadController.isQuiz) return; + + int retryCount = flashcardReadController.retryQuestion.value; + if (retryCount <= 0) { + EpicSnackBar.showErrorSnackBar( + "No Attempts Left", + "You have used all your attempts for this question.", + ); + return; + } + + final quizType = flashcardReadController.currentCard.value?.quizType ?? 0; + final quizData = flashcardReadController.currentCard.value?.quiz; + + EpicLog.debug( + "Checking answer - step 2: determine quiz type and validate answer"); + + if (quizType == 1) { + EpicLog.debug( + "Checking answer - step 3: processing multiple choice quiz"); + var quizDataEntity = quizData?.multipleChoice; + if (quizDataEntity == null) return; + final correctIndex = quizDataEntity.correctedIndex; + if (correctIndex == null) return; + + EpicLog.debug( + "Checking answer - step 4: comparing selected index with correct index"); + isAnswerCorrect.value = assignedChoice.value == correctIndex; + isAnswerChecked.value = true; + + flashcardReadController.retryQuestion.value--; + var answerData = QuizChoiceAnswerEntity( + selectedAnswer: assignedChoice.value, + ); + var questionJson = + QuizChoiceMapper.entityToRemote(quizDataEntity).toJson(); + var answerJson = + QuizChoiceAnswerMapper.entityToRemote(answerData).toJson(); + if (isAnswerCorrect.value) { + _showCongratsDialog( + title: "Selamat!", + message: "Jawaban kamu benar.", + explanation: quizDataEntity.explanation, + ); + confettiController.play(); + updateHistoryAnswer( + quizType: quizType, + isCorrect: true, + question: jsonEncode(questionJson), + answer: jsonEncode(answerJson), + multipleChoiceQuestion: quizDataEntity, + multipleChoiceAnswer: answerData, + ); + } else { + EpicSnackBar.showErrorSnackBar( + "Wrong Answer", + "Please try again.", + ); + updateHistoryAnswer( + quizType: quizType, + isCorrect: false, + question: jsonEncode(questionJson), + answer: jsonEncode(answerJson), + multipleChoiceQuestion: quizDataEntity, + multipleChoiceAnswer: answerData, + ); + } + return; + } + + if (quizType == 3) { + EpicLog.debug("Checking answer - step 3: processing group quiz"); + final QuizGroupEntity? quizDataEntity = quizData?.group; + if (quizDataEntity == null) return; + final options = quizDataEntity.options ?? []; + if (options.isEmpty) return; + + EpicLog.debug("Checking answer - step 4: validating group assignments"); + + bool allAssigned = true; + bool allCorrect = true; + + for (final option in options) { + final optId = option.id; + final correctGroupId = option.correctGroupId; + if (optId == null) { + continue; + } + final assigned = assignedGroupByOption[optId]; + if (assigned == null) { + allAssigned = false; + allCorrect = false; + continue; + } + if (correctGroupId != null && assigned != correctGroupId) { + allCorrect = false; + } + } + + isAnswerChecked.value = true; + isAnswerCorrect.value = allAssigned && allCorrect; + + flashcardReadController.retryQuestion.value--; + final groupAssignments = Map.from(assignedGroupByOption); + var answerData = QuizGroupAnswerEntity( + assignedGroupByOption: groupAssignments, + ); + var questionJson = + QuizGroupMapper.entityToRemote(quizDataEntity).toJson(); + var answerJson = + QuizGroupAnswerMapper.entityToRemote(answerData).toJson(); + + if (isAnswerCorrect.value) { + _showCongratsDialog( + title: "Selamat!", + message: "Semua item sudah di kelompok yang benar.", + explanation: quizDataEntity.explanation, + ); + confettiController.play(); + updateHistoryAnswer( + quizType: quizType, + isCorrect: true, + question: jsonEncode(questionJson), + answer: jsonEncode(answerJson), + groupQuestion: quizDataEntity, + groupAnswer: answerData, + ); + } else { + final message = allAssigned + ? "Some items are in the wrong group." + : "Please assign all items to a group."; + EpicSnackBar.showErrorSnackBar("Try Again", message); + updateHistoryAnswer( + quizType: quizType, + isCorrect: false, + question: jsonEncode(questionJson), + answer: jsonEncode(answerJson), + groupQuestion: quizDataEntity, + groupAnswer: answerData, + ); + } + return; + } + + if (quizType == 2) { + EpicLog.debug("Checking answer - step 3: processing fill blank quiz"); + final QuizFillBlankEntity? quizDataEntity = quizData?.fillBlank; + if (quizDataEntity == null) return; + final options = quizDataEntity.options ?? []; + if (options.isEmpty) return; + + bool allAssigned = true; + bool allCorrect = true; + + for (final option in options) { + final optId = option.id; + final correctBlankId = option.correctBlankId; + if (optId == null) continue; + final assigned = assignedBlankByOption[optId]; + if (assigned == null) { + allAssigned = false; + allCorrect = false; + continue; + } + if (correctBlankId != null && assigned != correctBlankId) { + allCorrect = false; + } + } + + isAnswerChecked.value = true; + isAnswerCorrect.value = allAssigned && allCorrect; + + flashcardReadController.retryQuestion.value--; + final fillAssignments = Map.from(assignedBlankByOption); + var answerData = QuizFillBlankAnswerEntity( + assignedBlankByOption: fillAssignments, + ); + var questionJson = + QuizFillBlankMapper.entityToRemote(quizDataEntity).toJson(); + var answerJson = + QuizFillBlankAnswerMapper.entityToRemote(answerData).toJson(); + if (isAnswerCorrect.value) { + _showCongratsDialog( + title: "Selamat!", + message: "Semua isian sudah benar.", + explanation: quizDataEntity.explanation, + ); + confettiController.play(); + updateHistoryAnswer( + quizType: quizType, + isCorrect: true, + question: jsonEncode(questionJson), + answer: jsonEncode(answerJson), + fillBlankQuestion: quizDataEntity, + fillBlankAnswer: answerData, + ); + } else { + final message = allAssigned + ? "Some blanks are incorrect." + : "Please fill all blanks."; + EpicSnackBar.showErrorSnackBar("Try Again", message); + updateHistoryAnswer( + quizType: quizType, + isCorrect: false, + question: jsonEncode(questionJson), + answer: jsonEncode(answerJson), + fillBlankQuestion: quizDataEntity, + fillBlankAnswer: answerData, + ); + } + return; + } + + EpicSnackBar.showErrorSnackBar("Error", "Quiz type not supported"); + } +} diff --git a/lib/feature/flashcards/flashcard_read/flashcard_read_binding.dart b/lib/feature/flashcards/flashcard_read/flashcard_read_binding.dart new file mode 100644 index 0000000..959185a --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/flashcard_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/flashcards/flashcard_read/flashcard_read_controller.dart'; +import 'package:get/get.dart'; + +class FlashCardReadBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + CollectionModule(); + QuizModule(); + Get.lazyPut( + () => FlashcardReadController( + childrenUsecase: Get.find(), + collectionUsecase: Get.find(), + quizUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/flashcards/flashcard_read/flashcard_read_controller.dart b/lib/feature/flashcards/flashcard_read/flashcard_read_controller.dart new file mode 100644 index 0000000..9172d03 --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/flashcard_read_controller.dart @@ -0,0 +1,1106 @@ +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/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/collections/flashcard_collection_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_card_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_answer_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_card_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_quiz_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/flashcards/flashcard_read/components/quiz/group_quiz.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/components/quiz/fill_blank_quiz.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/components/quiz/multiple_choice_quiz.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_quiz_controller.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/cupertino.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:get/get.dart'; + +class FlashcardReadController extends GetxController + with GetSingleTickerProviderStateMixin { + late FlashcardEntity flashcard; + var flashcardHistory = Rxn(FlashcardHistoryEntity()); + + final currentCard = Rxn(); + final currentText = ''.obs; + final currentImage = RxnString(); + final isQuizCard = false.obs; + + final isLoading = false.obs; + var isSoundOn = true.obs; + + final currentIndex = 0.obs; + final isBookmarked = false.obs; + final dragOffset = 0.0.obs; + final isFlipping = false.obs; + final flipDirection = 1.obs; // 1: next, -1: prev + final pendingIndex = RxnInt(); + final hasSwapped = false.obs; + final highlightedStart = RxnInt(); + final highlightedEnd = RxnInt(); + final ttsBaseOffset = RxnInt(); + + final FlutterTts _tts = FlutterTts(); + + late AnimationController flipController; + late Animation flipAnimation; + final navController = Get.find(); + + List get cards => flashcard.cards ?? []; + int get totalCards => cards.isEmpty ? 1 : cards.length; + + FlashcardCardEntity? get currentCardEntity => currentCard.value; + + bool get isQuiz => isQuizCard.value; + + double get progress => (currentIndex.value + 1) / totalCards; + + final ChildrenUsecase childrenUsecase; + final CollectionUsecase collectionUsecase; + final QuizUsecase quizUsecase; + + FlashcardReadController({ + required this.childrenUsecase, + required this.collectionUsecase, + required this.quizUsecase, + }); + + var collections = [].obs; + + final mainController = Get.find(); + late final FlashcardQuizController quizController; + var retryQuestion = 3.obs; + + var allTextTheory = ''.obs; // berisi gabungan semua teks dari kartu + + @override + void onClose() { + _stopTts(); + flipController.dispose(); + pushHistoryToFirestore(); + super.onClose(); + } + + @override + void onInit() async { + super.onInit(); + + quizController = + Get.put(FlashcardQuizController(flashcardReadController: this)); + + flipController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 520), + ); + flipAnimation = CurvedAnimation( + parent: flipController, + curve: Curves.easeInOutCubicEmphasized, + ); + + flipController.addListener(_handleFlipMidpoint); + flipController.addStatusListener(_handleFlipStatus); + _initTts(); + + await _initBook(); + _syncCurrentCard(); + updateHistory(); + ever(currentIndex, (_) => _syncCurrentCard()); + ever(flashcardHistory, (_) => _refreshQuizMeta()); + var children = await childrenUsecase.getChildrenRemoteModel(); + collections.value = children?.flashcardCollections ?? []; + } + + Future updateHistory() async { + try { + var history = flashcardHistory.value; + var historyCard = history?.cards; + + bool isExist = historyCard != null && + historyCard.any((card) => card.nPage == currentCard.value?.nPage); + final quizType = currentCard.value?.quizType ?? 0; + if (!isExist) { + history = history?.copyWith( + cards: [ + ...?historyCard, + FlashcardHistoryCardEntity( + nPage: currentCard.value?.nPage ?? 0, + isRead: true, + isQuiz: quizType > 0, + ) + ], + ); + flashcardHistory.value = history; + } else { + history = history?.copyWith( + cards: historyCard.map((card) { + if (card.nPage == currentCard.value?.nPage) { + return card.copyWith(isRead: true, isQuiz: quizType > 0); + } + return card; + }).toList(), + ); + flashcardHistory.value = history; + } + if (history != null) { + var totalCard = history.totalCards ?? 0; + var totalQuiz = history.totalQuiz ?? 0; + var completedQuiz = history.completedQuiz ?? 0; + var isAllQuizCompleted = (completedQuiz >= totalQuiz); + var readPages = (history.cards?.length ?? 0) + 1; + var isAllRead = readPages >= totalCard; + EpicLog.debug( + "updateHistory - totalCard: $totalCard, totalQuiz: $totalQuiz, completedQuiz: $completedQuiz, readPages: $readPages, isAllRead: $isAllRead, isAllQuizCompleted: $isAllQuizCompleted"); + var isFinished = isAllRead && isAllQuizCompleted; + var updatedHistory = history.copyWith( + lastReadAt: DateTime.now(), + isFinished: isFinished, + finishedAt: currentIndex.value == totalCards - 1 + ? DateTime.now() + : history.finishedAt, + ); + bool result = await childrenUsecase.updateFlashcardHistory( + flashcardHistory: updatedHistory, + ); + EpicLog.debug( + 'updateHistory - updateFlashcardHistory result: $result'); + flashcardHistory.value = updatedHistory; + } + } catch (ex, s) { + EpicLog.exception(ex, s, 'Error reading flashcard page'); + } + } + + void _handleFlipMidpoint() { + // Swap content when rotation passes halfway to avoid mirrored text. + if (!hasSwapped.value && flipAnimation.value >= 0.5) { + final target = pendingIndex.value; + if (target != null && target >= 0 && target < totalCards) { + currentIndex.value = target; + _syncCurrentCard(); + updateHistory(); + } + hasSwapped.value = true; + } + } + + void _handleFlipStatus(AnimationStatus status) { + if (status == AnimationStatus.completed || + status == AnimationStatus.dismissed) { + _resetFlipState(); + } + } + + void _resetFlipState() { + isFlipping.value = false; + pendingIndex.value = null; + hasSwapped.value = false; + dragOffset.value = 0; + if (flipController.status != AnimationStatus.dismissed) { + flipController.reset(); + } + } + + Future _initBook() async { + try { + isLoading.value = true; + final args = Get.arguments; + if (args is FlashcardEntity) { + flashcard = args; + var history = await childrenUsecase.getFlashcardHistoryById( + flashcard.flashcardId ?? '', + ); + if (history != null) { + flashcardHistory.value = history.copyWith( + lastReadAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + } else { + flashcardHistory.value = FlashcardHistoryEntity( + flashcardId: flashcard.flashcardId ?? '', + title: flashcard.title, + summary: flashcard.summary, + category: flashcard.category, + totalCards: flashcard.cards?.length ?? 0, + totalQuiz: flashcard.totalQuiz ?? 0, + isHaveQuiz: flashcard.isHaveQuiz ?? false, + updatedAt: DateTime.now(), + lastReadAt: DateTime.now(), + isFinished: false, + finishedAt: null, + cards: [], + completedQuiz: 0, + ); + } + EpicLog.debug( + 'Initialized FlashcardReadController with flashcard: ${flashcard.title}, total cards: ${flashcard.cards?.length ?? 0}'); + _buildAllTextTheory(); + await generateQuiz(); + } else { + Get.back(); + } + } catch (ex, s) { + EpicLog.exception(ex, s, this, '_initBook'); + } finally { + isLoading.value = false; + } + } + + Future pushHistoryToFirestore() async { + try { + var history = flashcardHistory.value; + if (history != null) { + await childrenUsecase.updateFlashcardHistory( + flashcardHistory: history, + pushToRemote: true, + ); + flashcardHistory.value = history; + // navController.refresRewardFlashcard.value++; + if (history.isFinished == true && + history.claimedRewards != null && + history.claimedRewards!.isEmpty) { + // navController.refresRewardFlashcard.value++; + } + } + } catch (ex, s) { + EpicLog.exception(ex, s, 'Error pushing flashcard history to Firestore'); + } + } + + Future updateHistoryQuestion({ + required int quizType, + required FlashcardCardEntity card, + QuizChoiceEntity? multipleChoiceQuestion, + QuizGroupEntity? groupQuestion, + QuizFillBlankEntity? fillBlankQuestion, + }) async { + try { + final currentCard = card; + + final history = flashcardHistory.value; + if (history == null) return; + + EpicLog.debug( + 'updateHistoryQuestion - 1Updating history for card nPage: ${currentCard.nPage}, quizType: $quizType, retry: 3'); + + final cardHistoryIndex = history.cards?.indexWhere( + (h) => h.nPage == currentCard.nPage, + ); + if (cardHistoryIndex == null || cardHistoryIndex < 0) return; + + final cardHistory = history.cards![cardHistoryIndex]; + + EpicLog.debug( + 'updateHistoryQuestion - 2Updating history for card nPage: ${currentCard.nPage}, quizType: $quizType, retry: 3'); + final existingAnswer = cardHistory.historyAnswer; + final baseAnswer = existingAnswer ?? + FlashcardHistoryAnswerEntity( + quizType: quizType, + retry: 3, + ); + + String? explanation; + var updatedAnswer = baseAnswer.copyWith( + quizType: quizType, + retry: 3, + answer: existingAnswer?.answer, + ); + + if (quizType == 1 && multipleChoiceQuestion != null) { + updatedAnswer = updatedAnswer.copyWith( + multipleChoiceQuestion: multipleChoiceQuestion, + multipleChoiceAnswer: null, + forceAnswerNull: true, + ); + explanation = multipleChoiceQuestion.explanation; + } else if (quizType == 2 && fillBlankQuestion != null) { + updatedAnswer = updatedAnswer.copyWith( + fillBlankQuestion: fillBlankQuestion, + fillBlankAnswer: null, + forceAnswerNull: true, + ); + explanation = fillBlankQuestion.explanation; + } else if (quizType == 3 && groupQuestion != null) { + updatedAnswer = updatedAnswer.copyWith( + groupQuestion: groupQuestion, + groupAnswer: null, + forceAnswerNull: true, + ); + explanation = groupQuestion.explanation; + } + + final updatedCardHistory = cardHistory.copyWith( + isQuizAlreadyGenerated: true, + historyAnswer: updatedAnswer, + ); + + var updatedCards = [...?history.cards]; + updatedCards[cardHistoryIndex] = updatedCardHistory; + + for (var c in updatedCards) { + EpicLog.debug( + 'updateHistoryQuestion - card nPage: ${c.nPage}, isQuiz: ${c.isQuiz}, historyAnswer: ${c.historyAnswer}'); + } + + final updatedHistory = history.copyWith(cards: updatedCards); + EpicLog.debug( + 'updateHistoryQuestion - 3Updating history for card nPage: ${currentCard.nPage}, quizType: $quizType, retry: ${retryQuestion.value}'); + bool result = await childrenUsecase.updateFlashcardHistory( + flashcardHistory: updatedHistory, + ); + EpicLog.debug('updateHistoryQuestion - result: $result'); + flashcardHistory.value = updatedHistory; + } catch (ex, s) { + EpicLog.exception(ex, s, this, 'updateHistoryQuestion'); + } + } + + void _buildAllTextTheory() { + final texts = flashcard.cards + ?.map((card) => card.text?.trim()) + .where((text) => text != null && text.isNotEmpty) + .map((text) => text!) + .toList() ?? + []; + + allTextTheory.value = texts.join('\n'); + EpicLog.debug( + 'Built allTextTheory : line count: ${texts.length} | ${allTextTheory.value} | '); + } + + Future generateQuiz({ + bool forceGenerate = false, + }) async { + try { + if (forceGenerate) { + isLoading.value = true; + } + var text = allTextTheory.value; + var currentFlashcard = flashcard; + var currentHistory = flashcardHistory.value; + var quizIndexes = currentFlashcard.cards + ?.where((card) => card.quizType != null) + .map((card) => card.nPage) + .toList() ?? + []; + if (currentFlashcard.isGenerateQuizSupported == true) { + var cardHis = currentHistory?.cards ?? []; + var cards = currentFlashcard.cards ?? []; + for (var card in cards) { + var isQuiz = quizIndexes.contains(card.nPage); + if (isQuiz) { + var historyCard = cardHis.firstWhereOrNull( + (h) => h.nPage == card.nPage, + ); + if (historyCard == null) { + EpicLog.debug('generateQuiz - user belum pernah membaca ini'); + EpicLog.debug('generateQuiz - ready for generate'); + var newHistoryCard = FlashcardHistoryCardEntity( + nPage: card.nPage, + isRead: false, + isQuiz: true, + completedQuiz: false, + correctAnswer: false, + isQuizAlreadyGenerated: false, + ); + var history = flashcardHistory.value; + var historyCard = history?.cards; + + history = history?.copyWith( + cards: [ + ...?historyCard, + newHistoryCard, + ], + ); + + if (history != null) { + await childrenUsecase.updateFlashcardHistory( + flashcardHistory: history, + ); + flashcardHistory.value = history; + var quizType = card.quizType ?? 0; + bool isGenerated = await _tryGenerateQuizByQuizType( + quizType: quizType, + text: text, + card: card, + category: flashcard.category, + previousQuestions: _extractPreviousQuestionsFromCard(card), + ); + if (isGenerated) { + EpicLog.debug('generateQuiz - quiz generated successfully'); + _sycnronizedQuizDataWithHistory(card); + } + } else { + EpicLog.debug( + "generateQuiz - failed generate - history is null"); + } + } else { + var his = historyCard; + var isQuiz = his.isQuiz == true; + var isNotCompleted = his.completedQuiz != true; + var totalRetry = his.historyAnswer?.retry ?? 3; + var isQuizAlreadyGenerated = his.isQuizAlreadyGenerated == true; + var firstAnswer = totalRetry == 3; + var quizType = card.quizType ?? 0; + var isCanGenerate = isQuiz && + isNotCompleted && + firstAnswer && + !isQuizAlreadyGenerated; + EpicLog.debug('generateQuiz - user sudah pernah membaca ini'); + if (isCanGenerate || forceGenerate) { + EpicLog.debug( + 'generateQuiz - ready for generate with force generate: $forceGenerate'); + var isGenerated = await _tryGenerateQuizByQuizType( + quizType: quizType, + text: text, + card: card, + category: flashcard.category, + previousQuestions: forceGenerate + ? _extractPreviousQuestionsFromCard(card) + : const [], + ); + if (isGenerated) { + EpicLog.debug('generateQuiz - quiz generated successfully'); + _sycnronizedQuizDataWithHistory(card); + if (forceGenerate) { + EpicSnackBar.showSuccessSnackBar( + "Success", + "Quiz generated successfully", + ); + _syncCurrentCard(); + quizController.resetState(); + // quizController.syncCurrentQuiz(); + } + } + } else { + EpicLog.debug('generateQuiz - rejected for generate'); + _sycnronizedQuizDataWithHistory(card); + } + } + } + } + } + } catch (ex, s) { + EpicLog.exception(ex, s, this, 'generateQuiz'); + } finally { + if (forceGenerate) { + isLoading.value = false; + } + } + } + + Future _updateCardQuiz({ + required FlashcardCardEntity card, + QuizChoiceEntity? choiceQuiz, + QuizFillBlankEntity? fillBlankQuiz, + QuizGroupEntity? groupQuiz, + }) async { + var quizType = card.quizType; + final cards = flashcard.cards ?? []; + final targetIndex = cards.indexWhere((c) => c.nPage == card.nPage); + if (targetIndex < 0) { + return; + } + + final explanationFallback = card.quiz?.explanation; + + FlashcardQuizEntity? updatedQuiz; + if (quizType == 1 && choiceQuiz != null) { + updatedQuiz = FlashcardQuizEntity( + quizType: quizType, + quizData: choiceQuiz.quizDataHelper, + explanation: choiceQuiz.explanation ?? explanationFallback, + multipleChoice: choiceQuiz, + ); + } else if (quizType == 2 && fillBlankQuiz != null) { + updatedQuiz = FlashcardQuizEntity( + quizType: quizType, + quizData: fillBlankQuiz.quizDataHelper, + explanation: fillBlankQuiz.explanation ?? explanationFallback, + fillBlank: fillBlankQuiz, + ); + } else if (quizType == 3 && groupQuiz != null) { + updatedQuiz = FlashcardQuizEntity( + quizType: quizType, + quizData: groupQuiz.quizDataHelper, + explanation: groupQuiz.explanation ?? explanationFallback, + group: groupQuiz, + ); + } else { + return; + } + + final updatedCard = cards[targetIndex].copyWith(quiz: updatedQuiz); + final updatedCards = List.from(cards); + updatedCards[targetIndex] = updatedCard; + + EpicLog.debug( + '_updateCardQuiz - Updating flashcard card with new quiz data for nPage: ${card.nPage}, quizType: $quizType'); + flashcard = flashcard.copyWith(cards: updatedCards); + } + + List _extractPreviousQuestionsFromCard(FlashcardCardEntity card) { + final quiz = card.quiz; + if (quiz == null) return []; + final List previous = []; + if (quiz.multipleChoice?.question != null) { + previous.add(quiz.multipleChoice!.question!); + } + if (quiz.fillBlank?.sentence != null) { + final sentenceText = quiz.fillBlank!.sentence! + .map((s) => s.type == 'blank' ? '___' : (s.value ?? '')) + .join(); + if (sentenceText.trim().isNotEmpty) previous.add(sentenceText); + } + if (quiz.group?.groups != null) { + final groupTitles = quiz.group!.groups! + .map((g) => g.title ?? '') + .where((t) => t.isNotEmpty) + .join(' vs '); + if (groupTitles.isNotEmpty) previous.add('Kelompokkan: $groupTitles'); + } + return previous; + } + + 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; + + 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); + + 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; + + var 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; + } + + var 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; + + var 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; + } + + var 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 _tryGenerateQuizByQuizType({ + required int quizType, + required String text, + required FlashcardCardEntity card, + String? category, + List previousQuestions = const [], + }) async { + try { + if (quizType == 1) { + var choiceQuiz = await _tryToGenerateMultipleChoiceQuiz( + fullText: text, + category: category, + previousQuestions: previousQuestions, + ); + if (choiceQuiz != null) { + await updateHistoryQuestion( + quizType: quizType, + multipleChoiceQuestion: choiceQuiz, + card: card, + ); + _updateCardQuiz( + card: card, + choiceQuiz: choiceQuiz, + ); + return true; + } + } else if (quizType == 2) { + var fillBlankQuiz = await _tryToGenerateFillBlankQuiz( + fullText: text, + category: category, + previousQuestions: previousQuestions, + ); + if (fillBlankQuiz != null) { + await updateHistoryQuestion( + quizType: quizType, + fillBlankQuestion: fillBlankQuiz, + card: card, + ); + _updateCardQuiz( + card: card, + fillBlankQuiz: fillBlankQuiz, + ); + return true; + } + } else if (quizType == 3) { + var groupQuiz = await _tryToGenerateGroupQuiz( + fullText: text, + category: category, + previousQuestions: previousQuestions, + ); + if (groupQuiz != null) { + await updateHistoryQuestion( + quizType: quizType, + groupQuestion: groupQuiz, + card: card, + ); + _updateCardQuiz( + card: card, + groupQuiz: groupQuiz, + ); + return true; + } + } + } catch (ex, s) { + EpicLog.exception(ex, s, this, '_tryGenerateQuizByQuizType'); + } + return false; + } + + Future goToResult() async { + await Get.toNamed( + EpicRoutes.flashCardResult, + arguments: flashcard, + ); + flashcardHistory.value = await childrenUsecase.getFlashcardHistoryById( + flashcard.flashcardId ?? '', + ); + } + + void goNext() async { + if (isFlipping.value) return; + if (currentIndex.value >= totalCards - 1) { + if (flashcardHistory.value?.isFinished == true) { + await updateHistory(); + await goToResult(); + return; + } else { + EpicSnackBar.showWarningSnackBar( + "Warning", "Selesaikan quiz untuk mendapatkan reward"); + } + return; + } + _startFlip(targetIndex: currentIndex.value + 1, direction: 1); + } + + void goPrev() { + if (isFlipping.value) return; + if (currentIndex.value <= 0) return; + _startFlip(targetIndex: currentIndex.value - 1, direction: -1); + } + + void jumpToIndex(int index) { + if (isFlipping.value) return; + if (index < 0 || index >= totalCards) return; + if (index == currentIndex.value) return; + final direction = index > currentIndex.value ? 1 : -1; + _startFlip(targetIndex: index, direction: direction); + } + + void toggleBookmark() { + isBookmarked.toggle(); + } + + void updateDrag(double deltaX) { + if (isFlipping.value) return; + // Clamp to keep animation reasonable + dragOffset.value = (dragOffset.value + deltaX).clamp(-320.0, 320.0); + } + + void endDrag() { + if (isFlipping.value) { + dragOffset.value = 0; + return; + } + final dx = dragOffset.value; + if (dx > 120) { + goPrev(); + } else if (dx < -120) { + goNext(); + } + dragOffset.value = 0; + } + + void _syncCurrentCard() { + if (cards.isEmpty) { + currentCard.value = null; + currentText.value = _placeholderText; + currentImage.value = null; + _refreshQuizMeta(); + } else { + final card = cards[currentIndex.value]; + currentCard.value = card; + currentText.value = card.text ?? _placeholderText; + currentImage.value = card.image; + _refreshQuizMeta(); + } + quizController.resetState(); + quizController.syncCurrentQuiz(); + _clearHighlight(); + _autoReadIfAllowed(); + } + + void _refreshQuizMeta() { + final card = currentCard.value; + if (card == null) { + isQuizCard.value = false; + retryQuestion.value = 3; + return; + } + + final hasQuiz = (card.quiz?.quizData != null); + isQuizCard.value = hasQuiz; + + if (hasQuiz) { + final historyAnswer = flashcardHistory.value?.cards + ?.firstWhereOrNull((h) => h.nPage == card.nPage); + retryQuestion.value = historyAnswer?.historyAnswer?.retry ?? 3; + } else { + retryQuestion.value = 3; + } + } + + void _startFlip({required int targetIndex, required int direction}) { + if (targetIndex == currentIndex.value || + targetIndex < 0 || + targetIndex >= totalCards) { + return; + } + pendingIndex.value = targetIndex; + flipDirection.value = direction; + isFlipping.value = true; + hasSwapped.value = false; + flipController.forward(from: 0); + } + + static const String _placeholderText = 'Quiz??'; + + Future addFlashcardToCollection({ + required String collectionId, + required String flashcardId, + }) async { + try { + Get.back(); + mainController.showLoadingPage(); + await collectionUsecase.addFlashcardToCollection( + collectionId: collectionId, + flashcardId: flashcardId, + ); + navController.refreshCollectionHome.value++; + } catch (ex, s) { + EpicLog.exception(ex, s, this, 'addFlashcardToCollection'); + } finally { + mainController.hideLoadingPage(); + } + } + + void _sycnronizedQuizDataWithHistory(FlashcardCardEntity card) { + final quizType = card.quizType; + if (quizType == null) return; + + final historyCard = flashcardHistory.value?.cards + ?.firstWhereOrNull((h) => h.nPage == card.nPage); + if (historyCard == null) return; + + final historyAnswer = historyCard.historyAnswer; + if (historyAnswer == null) { + EpicLog.debug( + '_sycnronizedQuizDataWithHistory - historyAnswer is null for card nPage: ${card.nPage}'); + return; + } + + if (quizType == 1) { + final quizEntity = historyAnswer.multipleChoiceQuestion; + EpicLog.debug( + '_sycnronizedQuizDataWithHistory - quizEntity from history: ${quizEntity?.question}'); + if (quizEntity != null) { + var tempRemote = QuizChoiceMapper.entityToRemote(quizEntity); + _updateCardQuiz( + card: card, + choiceQuiz: quizEntity.copyWith( + quizDataHelper: tempRemote.toJson().toString(), + ), + ); + } + } else if (quizType == 2) { + final quizEntity = historyAnswer.fillBlankQuestion; + if (quizEntity != null) { + var tempRemote = QuizFillBlankMapper.entityToRemote(quizEntity); + _updateCardQuiz( + card: card, + fillBlankQuiz: quizEntity.copyWith( + quizDataHelper: tempRemote.toJson().toString(), + ), + ); + } + } else if (quizType == 3) { + final quizEntity = historyAnswer.groupQuestion; + if (quizEntity != null) { + var tempRemote = QuizGroupMapper.entityToRemote(quizEntity); + _updateCardQuiz( + card: card, + groupQuiz: quizEntity.copyWith( + quizDataHelper: tempRemote.toJson().toString(), + ), + ); + } + } + } + + Widget getQuizWidget() { + if (currentCard.value?.quizType == 1) { + var cardSakIki = currentCard.value; + var quizData = cardSakIki?.quiz?.multipleChoice; + var quizDataFromHistory = flashcardHistory.value?.cards + ?.firstWhereOrNull((h) => h.nPage == cardSakIki?.nPage) + ?.historyAnswer; + var quizDataHistory = quizDataFromHistory?.multipleChoiceQuestion; + + if (quizData != null) { + return MultipleChoiceQuiz( + quizController: quizController, + quizData: quizDataHistory ?? quizData, + ); + } else { + return const Text("QUIZ DATA NULL"); + } + } + if (currentCard.value?.quizType == 2) { + var quizData = currentCard.value?.quiz?.fillBlank; + var quizDataFromHistory = flashcardHistory.value?.cards + ?.firstWhereOrNull((h) => h.nPage == currentCard.value?.nPage) + ?.historyAnswer; + var quizDataHistory = quizDataFromHistory?.fillBlankQuestion; + if (quizData != null) { + return FillBlankQuiz( + quizController: quizController, + quizData: quizDataHistory ?? quizData, + ); + } else { + return const Text("QUIZ DATA NULL"); + } + } + if (currentCard.value?.quizType == 3) { + var quizData = currentCard.value?.quiz?.group; + var quizDataFromHistory = flashcardHistory.value?.cards + ?.firstWhereOrNull((h) => h.nPage == currentCard.value?.nPage) + ?.historyAnswer; + var quizDataHistory = quizDataFromHistory?.groupQuestion; + if (quizData != null) { + return GroupQuiz( + quizController: quizController, + quizData: quizDataHistory ?? quizData, + ); + } else { + return const Text("QUIZ DATA NULL"); + } + } + // Add more quiz types here as needed + return const SizedBox.shrink(); + } + + Future _initTts() async { + await _tts.setLanguage('id-ID'); + await _tts.setSpeechRate(0.55); + await _tts.setPitch(1.0); + + _tts.setProgressHandler((text, start, end, word) { + final base = ttsBaseOffset.value ?? 0; + highlightedStart.value = base + start; + highlightedEnd.value = base + end; + }); + + _tts.setCompletionHandler(() { + _clearHighlight(); + ttsBaseOffset.value = null; + }); + + _tts.setCancelHandler(() { + _clearHighlight(); + ttsBaseOffset.value = null; + }); + } + + Future _autoReadIfAllowed() async { + if (!isSoundOn.value || isQuizCard.value) { + await _stopTts(); + return; + } + final text = currentText.value.trim(); + if (text.isEmpty || text == _placeholderText) { + await _stopTts(); + return; + } + await _speak(text); + } + + Future _speak(String text, {int baseOffset = 0}) async { + await _stopTts(); + _clearHighlight(); + ttsBaseOffset.value = baseOffset; + await _tts.speak(text); + } + + Future speakWordAt(int start, int end) async { + if (isQuizCard.value) return; + final text = currentText.value; + if (start < 0 || end > text.length || start >= end) return; + final word = text.substring(start, end).trim(); + if (word.isEmpty) return; + highlightedStart.value = start; + highlightedEnd.value = end; + await _stopTts(); + await _speak(word, baseOffset: start); + } + + Future _stopTts() async { + try { + await _tts.stop(); + } catch (_) {} + _clearHighlight(); + ttsBaseOffset.value = null; + } + + void toggleSound() { + isSoundOn.toggle(); + if (isSoundOn.value) { + _autoReadIfAllowed(); + } else { + _stopTts(); + } + } + + void _clearHighlight() { + highlightedStart.value = null; + highlightedEnd.value = null; + } +} diff --git a/lib/feature/flashcards/flashcard_read/flashcard_read_page.dart b/lib/feature/flashcards/flashcard_read/flashcard_read_page.dart new file mode 100644 index 0000000..4a8547b --- /dev/null +++ b/lib/feature/flashcards/flashcard_read/flashcard_read_page.dart @@ -0,0 +1,122 @@ +import 'dart:math' as math; + +import 'package:confetti/confetti.dart'; +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/flashcards/flashcard_read/components/flash_read_bottom_content.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/components/flash_read_main_content.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/components/flash_read_top_content.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_read/flashcard_read_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class FlashCardReadPage extends GetView { + const FlashCardReadPage({super.key}); + + @override + Widget build(BuildContext context) { + EpicAppSize.initialize(context); + + return Scaffold( + backgroundColor: const Color(0xFFE0CEFF), + body: SafeArea( + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + children: [ + FlashReadTopContent( + controller: controller, + ), + FlashReadMainContent( + controller: controller, + ), + FlashReadBottomContent( + controller: controller, + ), + ], + ), + // Confetti overlay aligned from bottom center shooting upward + Positioned.fill( + child: IgnorePointer( + child: Align( + alignment: Alignment.bottomCenter, + child: ConfettiWidget( + confettiController: + controller.quizController.confettiController, + blastDirection: -math.pi / 2, + blastDirectionality: BlastDirectionality.explosive, + emissionFrequency: 0.05, + numberOfParticles: 28, + gravity: 0.25, + minBlastForce: 12, + maxBlastForce: 18, + colors: const [ + Color(0xFF6B42B8), + Color(0xFF2ECC71), + Color(0xFFF39C12), + Color(0xFFE74C3C), + Color(0xFF3498DB), + ], + minimumSize: const Size(6, 8), + maximumSize: const Size(12, 18), + shouldLoop: false, + ), + ), + ), + ), + Obx( + () { + var isAnswerAlreadyCorrect = + controller.quizController.isAnswerCorrect.value; + + var isCanAnswer = (controller.retryQuestion.value > 0) && + isAnswerAlreadyCorrect == false; + var bgColor = isCanAnswer + ? BoxColors.cekJawaban + : isAnswerAlreadyCorrect + ? BoxColors.jawabanBenar + : BoxColors.jawabanSalah; + return controller.isQuiz + ? Positioned( + bottom: EpicAppSize.calculatedSize( + 105, + ), + left: 0, + right: 0, + child: Center( + child: ShadowBox( + cardColor: bgColor, + isCanClick: isCanAnswer, + onTap: controller.quizController.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( + isAnswerAlreadyCorrect + ? 'Jawaban sudah benar!' + : 'Cek Jawaban', + style: TextStyle( + color: bgColor.textColor, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ) + : const SizedBox.shrink(); + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/flashcards/flashcard_result/flashcard_result_binding.dart b/lib/feature/flashcards/flashcard_result/flashcard_result_binding.dart new file mode 100644 index 0000000..e0fdf6c --- /dev/null +++ b/lib/feature/flashcards/flashcard_result/flashcard_result_binding.dart @@ -0,0 +1,14 @@ +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/flashcards/flashcard_result/flashcard_result_controller.dart'; +import 'package:get/get.dart'; + +class FlashCardResultBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + Get.lazyPut(() => FlashCardResultController( + childrenUsecase: Get.find(), + )); + } +} diff --git a/lib/feature/flashcards/flashcard_result/flashcard_result_controller.dart b/lib/feature/flashcards/flashcard_result/flashcard_result_controller.dart new file mode 100644 index 0000000..d60bf6a --- /dev/null +++ b/lib/feature/flashcards/flashcard_result/flashcard_result_controller.dart @@ -0,0 +1,106 @@ +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/flashcards/flashcard_card_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:get/get.dart'; + +class FlashCardResultController extends GetxController { + late FlashcardEntity flashcard; + final correctCount = 0.obs; + final wrongCount = 0.obs; + final totalQuiz = 0.obs; + final completedQuiz = 0.obs; + final totalCards = 0.obs; + var isRewardClaimed = false.obs; + + final ChildrenUsecase childrenUsecase; + + FlashCardResultController({ + required this.childrenUsecase, + }); + + @override + void onInit() { + super.onInit(); + _initArgs(); + } + + List get cards => flashcard.cards ?? []; + var flashcardHistory = Rxn(FlashcardHistoryEntity()); + + void _initArgs() async { + flashcard = const FlashcardEntity(title: 'Flashcard Title'); + + final args = Get.arguments; + if (args is FlashcardEntity) { + final argFlashcard = args; + flashcard = argFlashcard; + flashcardHistory.value = await childrenUsecase.getFlashcardHistoryById( + flashcard.flashcardId ?? '', + ); + + correctCount.value = flashcardHistory.value?.cards + ?.where((card) => card.completedQuiz == true) + .length ?? + 0; + wrongCount.value = flashcardHistory.value?.cards + ?.where((card) => card.completedQuiz == false) + .length ?? + 0; + totalQuiz.value = + flashcard.cards?.where((card) => card.quizType != null).length ?? 0; + totalCards.value = (flashcard.cards?.length ?? 0) - totalQuiz.value; + } + + _updateClaimedFlag(); + EpicLog.debug( + 'Flashcard history loaded with claimed rewards: ${flashcardHistory.value?.claimedRewards}'); + EpicLog.debug( + 'Initialized FlashCardResultController with flashcard: ${flashcard.title}, total cards: ${flashcard.cards?.length ?? 0}, correct: ${correctCount.value}, wrong: ${wrongCount.value}'); + } + + void goToClaimReward() async { + if (isRewardClaimed.value) { + EpicSnackBar.showSuccessSnackBar( + "Info", + "Reward already claimed for this flashcard.", + ); + return; + } + + bool result = await Get.toNamed( + EpicRoutes.flashcardReward, + arguments: flashcard, + ); + + if (result == true) { + try { + final history = await childrenUsecase.getFlashcardHistoryById( + flashcard.flashcardId ?? '', + ); + + if (history != null) { + flashcardHistory.value = history; + isRewardClaimed.value = + flashcardHistory.value?.claimedRewards != null && + flashcardHistory.value!.claimedRewards!.isNotEmpty; + _updateClaimedFlag(); + EpicLog.debug('Flashcard history refreshed after claiming rewards.'); + } else { + EpicLog.debug( + 'No flashcard history found to refresh after claiming rewards.'); + } + } catch (ex, s) { + EpicLog.exception(ex, s, this, 'goToClaimReward - refresh history'); + } + } + } + + void _updateClaimedFlag() { + final claimed = flashcardHistory.value?.claimedRewards; + isRewardClaimed.value = claimed != null && claimed.isNotEmpty; + } +} diff --git a/lib/feature/flashcards/flashcard_result/flashcard_result_page.dart b/lib/feature/flashcards/flashcard_result/flashcard_result_page.dart new file mode 100644 index 0000000..fe0d32b --- /dev/null +++ b/lib/feature/flashcards/flashcard_result/flashcard_result_page.dart @@ -0,0 +1,431 @@ +import 'package:epic_story_app/domain/entities/flashcards/flashcard_card_entity.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_result/flashcard_result_controller.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class FlashCardResultPage extends GetView { + const FlashCardResultPage({super.key}); + + @override + Widget build(BuildContext context) { + final mainController = Get.find(); + + return Scaffold( + backgroundColor: const Color(0xFFE0CEFF), + body: SafeArea( + child: Obx(() { + final cards = controller.cards; + final claimedRewards = + controller.flashcardHistory.value?.claimedRewards ?? []; + final rewards = controller.flashcard.rewards ?? []; + final isRewardClaimed = controller.isRewardClaimed.value; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Image.asset('assets/images/hasil-flashcard.png'), + const SizedBox(height: 10), + _resultRewardPanel( + correctCount: controller.correctCount.value, + totalQuiz: controller.totalQuiz.value, + isRewardClaimed: isRewardClaimed, + onTapReward: controller.goToClaimReward, + ), + const SizedBox(height: 18), + if (cards.isEmpty) + _emptyPlaceholder() + else + Column( + children: [ + for (int i = 0; i < cards.length; i += 2) ...[ + _cardRow( + left: cards[i], + right: i + 1 < cards.length ? cards[i + 1] : null, + ), + const SizedBox(height: 16), + ], + ], + ), + if (claimedRewards.isNotEmpty) ...[ + const SizedBox(height: 8), + _claimedRewardSection( + rewards: rewards, + claimed: claimedRewards, + onTapThumb: (urls, index) { + mainController.showRewardPhotoViewer( + imageUrls: urls, + initialIndex: index, + ); + }, + ), + ], + ], + ), + ); + }), + ), + ); + } + + Widget _resultRewardPanel({ + required int correctCount, + required int totalQuiz, + required bool isRewardClaimed, + required VoidCallback onTapReward, + }) { + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: [ + Positioned( + top: 6, + child: IgnorePointer( + child: Container( + width: 220, + height: 160, + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment.topCenter, + radius: 0.85, + colors: [Color(0x66FFF3A5), Color(0x00FFF3A5)], + ), + ), + ), + ), + ), + AspectRatio( + aspectRatio: 1, + child: LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + final h = constraints.maxHeight; + + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/images/icon-get-reward.png', + fit: BoxFit.contain, + ), + ), + Positioned( + top: h * 0.55, + left: 0, + right: 0, + child: Column( + children: [ + const Text( + 'Total Kuis', + style: TextStyle( + fontSize: 23, + fontWeight: FontWeight.w600, + color: Color(0xFFF08B39), + height: 1, + ), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/icon-perment.png', + width: 30, + height: 30, + ), + const SizedBox(width: 6), + Text( + '$correctCount/$totalQuiz', + style: const TextStyle( + fontSize: 23, + fontWeight: FontWeight.w700, + color: Color(0xFFEB6E17), + height: 1, + ), + ), + ], + ), + ], + ), + ), + Positioned( + left: w * 0.17, + right: w * 0.17, + bottom: h * 0.1, + child: _RewardPanelButton( + isClaimed: isRewardClaimed, + onTap: onTapReward, + ), + ), + ], + ); + }, + ), + ), + ], + ); + } + + Widget _cardRow( + {required FlashcardCardEntity left, FlashcardCardEntity? right}) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _cardTile(left)), + const SizedBox(width: 14), + if (right != null) + Expanded(child: _cardTile(right)) + else + const Spacer(), + ], + ); + } + + Widget _cardTile(FlashcardCardEntity card) { + final isQuiz = (card.quizType ?? 0) > 0 || card.quiz?.quizData != null; + + return isQuiz + // ? _cardBox( + // const Center( + // child: Text( + // '?', + // style: TextStyle( + // fontSize: 34, + // fontWeight: FontWeight.w900, + // color: Colors.black, + // ), + // ), + // ), + // ) + ? SizedBox.shrink() + : _cardBox( + Center( + child: Text( + card.text ?? 'Show Quiz', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.black, + height: 1.35, + ), + ), + ), + ); + } + + Widget _cardBox(Widget child) { + return AspectRatio( + aspectRatio: 0.74, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Color(0xFFFFFFFF), + Color(0xFF5EA7FF), + ], + stops: [0.0, 1.0], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: const [ + BoxShadow( + color: Color(0x2E000000), + blurRadius: 8, + offset: Offset(0, 4), + ), + ], + ), + child: child, + ), + ); + } + + Widget _claimedRewardSection({ + required List rewards, + required List claimed, + required void Function(List urls, int index) onTapThumb, + }) { + final urls = claimed + .where((i) => i >= 0 && i < rewards.length) + .map((i) => rewards[i]) + .toList(); + + if (urls.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Hadiah yang sudah kamu dapatkan', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(urls.length, (index) { + return GestureDetector( + onTap: () => onTapThumb(urls, index), + child: SizedBox( + width: 96, + child: AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.black, width: 1.1), + borderRadius: BorderRadius.circular(14), + ), + child: _rewardThumb(urls[index]), + ), + ), + ), + ), + ); + }), + ), + ], + ); + } + + Widget _rewardThumb(String url) { + return Image.network( + url, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.grey.shade200, + alignment: Alignment.center, + child: const Icon( + Icons.broken_image_outlined, + size: 22, + color: Colors.black54, + ), + ), + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + color: Colors.grey.shade100, + alignment: Alignment.center, + child: const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + ); + } + + Widget _emptyPlaceholder() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: const Text( + 'Belum ada hasil untuk flashcard ini.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: Colors.black87), + ), + ); + } +} + +class _RewardPanelButton extends StatefulWidget { + const _RewardPanelButton({required this.isClaimed, required this.onTap}); + + final bool isClaimed; + final VoidCallback onTap; + + @override + State<_RewardPanelButton> createState() => _RewardPanelButtonState(); +} + +class _RewardPanelButtonState extends State<_RewardPanelButton> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 900), + ); + _scale = Tween(begin: 0.96, end: 1.04).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeInOut), + ); + _syncAnimation(); + } + + @override + void didUpdateWidget(covariant _RewardPanelButton oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.isClaimed != widget.isClaimed) { + _syncAnimation(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _syncAnimation() { + if (widget.isClaimed) { + _controller.stop(); + _controller.value = 0.5; + } else { + _controller.repeat(reverse: true); + } + } + + @override + Widget build(BuildContext context) { + final animation = + widget.isClaimed ? const AlwaysStoppedAnimation(1) : _scale; + + return GestureDetector( + onTap: widget.onTap, + child: ScaleTransition( + scale: animation, + child: SizedBox( + height: 58, + child: Stack( + fit: StackFit.expand, + children: [ + Image.asset('assets/images/bg-button-get-reward.png', + fit: BoxFit.fill), + Center( + child: Text( + widget.isClaimed ? 'Claimed' : 'Reward!!!', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: Colors.white, + height: 1, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/flashcards/flashcard_reward/flashcard_reward_binding.dart b/lib/feature/flashcards/flashcard_reward/flashcard_reward_binding.dart new file mode 100644 index 0000000..0d7d910 --- /dev/null +++ b/lib/feature/flashcards/flashcard_reward/flashcard_reward_binding.dart @@ -0,0 +1,16 @@ +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/flashcards/flashcard_reward/flashcard_reward_controller.dart'; +import 'package:get/get.dart'; + +class FlashcardRewardBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + Get.lazyPut( + () => FlashcardRewardController( + childrenUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/flashcards/flashcard_reward/flashcard_reward_controller.dart b/lib/feature/flashcards/flashcard_reward/flashcard_reward_controller.dart new file mode 100644 index 0000000..a3d4d0c --- /dev/null +++ b/lib/feature/flashcards/flashcard_reward/flashcard_reward_controller.dart @@ -0,0 +1,126 @@ +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/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/feature/others/main_controller/main_controller.dart'; +import 'package:get/get.dart'; + +class FlashcardRewardController extends GetxController { + late FlashcardEntity flashcard; + final rewards = [].obs; + final selectedQueue = [].obs; + + static const int maxSelection = 3; + + final mainController = Get.find(); + + String? get selectedReward => + selectedQueue.isNotEmpty && selectedQueue.last < rewards.length + ? rewards[selectedQueue.last] + : null; + + List get selectedRewardsOrdered => selectedQueue + .where((index) => index >= 0 && index < rewards.length) + .map((index) => rewards[index]) + .toList(); + + int get remainingSelection => + (maxSelection - selectedCount).clamp(0, maxSelection); + + int get selectedCount => selectedQueue.length; + + bool get hasRewards => rewards.isNotEmpty; + int get totalReward => rewards.length; + + var flashcardHistory = Rxn(FlashcardHistoryEntity()); + + final ChildrenUsecase childrenUsecase; + + FlashcardRewardController({ + required this.childrenUsecase, + }); + + @override + void onInit() async { + super.onInit(); + flashcard = const FlashcardEntity(rewards: []); + + final argument = Get.arguments; + if (argument != null && argument is FlashcardEntity) { + flashcard = argument; + flashcardHistory.value = await childrenUsecase.getFlashcardHistoryById( + flashcard.flashcardId ?? '', + ); + } + + rewards.assignAll(flashcard.rewards ?? []); + } + + void selectReward(int index) { + if (index < 0 || index >= rewards.length) return; + + // Toggle off if already selected + if (selectedQueue.contains(index)) { + selectedQueue.remove(index); + EpicLog.debug('Selected rewards: ${selectedQueue.join(' - ')}'); + return; + } + + // Keep only the latest [maxSelection] picks; drop the oldest if over limit. + if (selectedQueue.length >= maxSelection) { + selectedQueue.removeAt(0); + } + + selectedQueue.add(index); + + EpicLog.debug('Selected rewards: ${selectedQueue.join(' - ')}'); + } + + void removeSelectionAt(int slotIndex) { + if (slotIndex < 0 || slotIndex >= selectedQueue.length) return; + selectedQueue.removeAt(slotIndex); + EpicLog.debug('Removed selection at slot $slotIndex'); + } + + void claimReward() async { + try { + if (selectedQueue.length < maxSelection) { + EpicLog.debug('Please select $maxSelection rewards before claiming.'); + EpicSnackBar.showWarningSnackBar( + "Pilih Reward", + "Silakan pilih $maxSelection gambar terlebih dahulu.", + ); + return; + } + + var newHistory = flashcardHistory.value?.copyWith( + claimedRewards: selectedQueue, + ); + + if (newHistory != null) { + mainController.showLoadingPage(); + bool result = await childrenUsecase.updateFlashcardHistory( + flashcardHistory: newHistory, + pushToRemote: true, + ); + flashcardHistory.value = newHistory; + if (result) { + mainController.hideLoadingPage(); + Get.back(result: true); + EpicSnackBar.showSuccessSnackBar( + "Success", "Rewards claimed successfully!"); + } else { + EpicSnackBar.showErrorSnackBar( + "Error", "Failed to claim rewards. Please try again."); + } + } else { + EpicLog.debug('No flashcard history found to update.'); + } + } catch (ex, s) { + EpicLog.exception(ex, s, this, 'claimReward'); + } finally { + mainController.hideLoadingPage(); + } + } +} diff --git a/lib/feature/flashcards/flashcard_reward/flashcard_reward_page.dart b/lib/feature/flashcards/flashcard_reward/flashcard_reward_page.dart new file mode 100644 index 0000000..79bfeee --- /dev/null +++ b/lib/feature/flashcards/flashcard_reward/flashcard_reward_page.dart @@ -0,0 +1,373 @@ +import 'package:epic_story_app/feature/flashcards/flashcard_reward/flashcard_reward_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class FlashcardRewardPage extends GetView { + const FlashcardRewardPage({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, + // appBar: const CustomAppBar(title: 'Dapatkan Reward'), + 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), + Text( + 'Silahkan pilih 3 gambar\nsebagai reward', + textAlign: TextAlign.center, + style: const 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: Row( + children: [ + const Icon(Icons.info_outline, size: 18, color: Color(0xFF2F2249)), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Belum ada reward untuk flashcard ini.', + style: const 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 == FlashcardRewardController.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 FlashcardRewardController controller; + + @override + Widget build(BuildContext context) { + final selected = controller.selectedRewardsOrdered; + final maxSlot = FlashcardRewardController.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/home/components/category_state_widget.dart b/lib/feature/home/components/category_state_widget.dart new file mode 100644 index 0000000..153011a --- /dev/null +++ b/lib/feature/home/components/category_state_widget.dart @@ -0,0 +1,334 @@ +import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart'; +import 'package:epic_story_app/feature/home/enums/home_state.dart'; +import 'package:epic_story_app/feature/home/home_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/state_manager.dart'; + +class CategoryStateWidget extends StatelessWidget { + final HomeController controller; + + static const Color _mainBackground = Color(0xFFE0CEFF); + + const CategoryStateWidget({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return Obx(() { + final bool isBookMode = controller.navController.isBook.value; + final categories = _displayedCategories(controller.categories); + + return Container( + width: double.infinity, + color: _mainBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + StackedInfoTopBar( + titleText: isBookMode ? 'Buku' : 'Flashcard', + infoText: + 'Silahkan pilih mata pelajaran apa yang ingin kamu pelajari', + isBackButtonVisible: true, + onBackTap: () { + controller.state.value = HomeState.home; + }, + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 68, 18, 20), + child: ListView( + physics: const BouncingScrollPhysics(), + children: [ + // _buildAllCategoryButton( + // onTap: () { + // controller.state.value = HomeState.content; + // controller.navController.selectedCategory.value = + // 'Semua'; + // }, + // ), + if (categories.isEmpty) ...[ + const SizedBox(height: 16), + _buildEmptyCategory(), + ] else ...[ + const SizedBox(height: 16), + ...List.generate(categories.length, (index) { + final category = categories[index]; + return Padding( + padding: EdgeInsets.only( + bottom: + index == categories.length - 1 ? 0 : 16), + child: _buildCategoryCard( + category: category, + index: index, + isBookMode: isBookMode, + onTap: () { + controller.state.value = HomeState.content; + controller.navController.selectedCategory + .value = category; + }, + ), + ); + }), + ], + ], + ), + ), + ), + ], + ), + ), + ); + }); + } + + List _displayedCategories(List categories) { + final filtered = categories + .where((category) => + category.trim().isNotEmpty && category.toLowerCase() != 'semua') + .toList(); + + if (filtered.isNotEmpty) { + return filtered; + } + + return categories + .where((category) => category.trim().isNotEmpty) + .toList(growable: false); + } + + Widget _buildCategoryCard({ + required String category, + required int index, + required bool isBookMode, + required VoidCallback onTap, + }) { + if (isBookMode) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 126, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFF262626), width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.16), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.asset( + _resolveBookAsset(category, index), + fit: BoxFit.cover, + errorBuilder: (context, _, __) => _buildFallbackLabel( + category, + textColor: Colors.black, + ), + ), + ), + ), + ); + } + + return GestureDetector( + onTap: onTap, + child: Container( + height: 126, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + gradient: _flashcardGradient(index), + borderRadius: BorderRadius.circular(24), + border: Border.all(color: const Color(0xFF1F1F1F), width: 2), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.16), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + children: [ + Container( + width: 108, + height: 108, + padding: const EdgeInsets.all(4), + alignment: Alignment.center, + child: Image.asset( + _resolveFlashcardAsset(category, index), + fit: BoxFit.contain, + errorBuilder: (context, _, __) => const Icon( + Icons.auto_stories, + size: 44, + color: Color(0xFF303030), + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + category, + style: const TextStyle( + color: Color(0xFF111111), + fontSize: 28, + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFallbackLabel( + String label, { + required Color textColor, + }) { + return Container( + color: Colors.white, + alignment: Alignment.center, + child: Text( + label, + textAlign: TextAlign.center, + style: TextStyle( + color: textColor, + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + ); + } + + Widget _buildAllCategoryButton({ + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 58, + padding: const EdgeInsets.symmetric(horizontal: 18), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF7E32AB), + Color(0xFF4D1E68), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFF311045), width: 1.5), + boxShadow: [ + BoxShadow( + color: const Color(0xFF7E32AB).withOpacity(0.38), + blurRadius: 12, + offset: const Offset(0, 6), + ), + ], + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.grid_view_rounded, + size: 20, + color: Colors.white, + ), + SizedBox(width: 8), + Text( + 'Semua', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + ); + } + + Widget _buildEmptyCategory() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFD2B6EA)), + ), + child: const Text( + 'Tidak ada kategori', + textAlign: TextAlign.center, + style: TextStyle( + color: Color(0xFF4D4060), + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + String _resolveBookAsset(String category, int index) { + final normalized = category.toLowerCase(); + + if (normalized.contains('matematika')) { + return 'assets/images/book-matematika.png'; + } + if (normalized.contains('bahasa') || normalized.contains('indo')) { + return 'assets/images/book-bhs-indo.png'; + } + + return index.isEven + ? 'assets/images/book-bhs-indo.png' + : 'assets/images/book-matematika.png'; + } + + String _resolveFlashcardAsset(String category, int index) { + final normalized = category.toLowerCase(); + + if (normalized.contains('matematika')) { + return 'assets/images/flashcard-matematika.png'; + } + if (normalized.contains('bahasa') || normalized.contains('indo')) { + return 'assets/images/flashcard-bhs-indo.png'; + } + + return index.isEven + ? 'assets/images/flashcard-bhs-indo.png' + : 'assets/images/flashcard-matematika.png'; + } + + LinearGradient _flashcardGradient(int index) { + if (index % 2 == 0) { + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFFFFF), + Color(0xFFFFE588), + ], + ); + } + + return const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFFFFFF), + Color(0xFF77B4FF), + ], + ); + } +} diff --git a/lib/feature/home/components/home_content_state.dart b/lib/feature/home/components/home_content_state.dart new file mode 100644 index 0000000..c44ee6a --- /dev/null +++ b/lib/feature/home/components/home_content_state.dart @@ -0,0 +1,65 @@ +import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart'; +import 'package:epic_story_app/feature/books/presentation/book_home/book_home_page.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_page.dart'; +import 'package:epic_story_app/feature/home/enums/home_state.dart'; +import 'package:epic_story_app/feature/home/home_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/state_manager.dart'; + +class HomeContentState extends StatelessWidget { + final HomeController controller; + + static const Color _mainBackground = Color(0xFFE0CEFF); + + const HomeContentState({ + super.key, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return Obx(() { + final bool isBookMode = controller.navController.isBook.value; + final String selectedCategory = + controller.navController.selectedCategory.value; + final String activeCategory = + selectedCategory.isEmpty ? 'Semua' : selectedCategory; + final String infoText = 'Mata Pelajaran :\n$activeCategory'; + + return Container( + width: double.infinity, + color: _mainBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + StackedInfoTopBar( + titleText: isBookMode ? 'Buku' : 'Flashcard', + infoText: infoText, + isBackButtonVisible: true, + onBackTap: () { + controller.state.value = HomeState.category; + }, + ), + const SizedBox(height: 4), + Expanded( + child: Container( + margin: const EdgeInsets.fromLTRB(14, 0, 14, 0), + decoration: BoxDecoration( + color: const Color(0xFFE0CEFF), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(24)), + ), + clipBehavior: Clip.antiAlias, + child: isBookMode + ? const BookHomePage() + : const FlashCardHomePage(), + ), + ), + ], + ), + ), + ); + }); + } +} diff --git a/lib/feature/home/components/home_state_widget.dart b/lib/feature/home/components/home_state_widget.dart new file mode 100644 index 0000000..4b9f5b7 --- /dev/null +++ b/lib/feature/home/components/home_state_widget.dart @@ -0,0 +1,171 @@ +import 'package:epic_story_app/feature/home/enums/home_state.dart'; +import 'package:flutter/material.dart'; +import 'package:epic_story_app/core/widgets/cards/stacked_info_top_bar.dart'; +import 'package:get/get.dart'; + +import '../home_controller.dart'; + +class HomeStateWidget extends StatelessWidget { + const HomeStateWidget({ + super.key, + required this.controller, + }); + + final HomeController controller; + + static const Color _mainBackground = Color(0xFFE0CEFF); + static const Color _bookCardBg = Color(0xFFFFCF25); + static const Color _flashcardCardBg = Color(0xFF1A6EFC); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + color: _mainBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + Obx( + () => StackedInfoTopBar( + titleText: 'Tipe Belajar', + infoText: 'Silahkan pilih tipe belajar\nyang kamu mau', + isBackButtonVisible: controller.state.value != HomeState.home, + onBackTap: controller.onBack, + ), + ), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 70, 18, 120), + child: Column( + children: [ + const Text( + 'Pilih Tipe Belajar Kamu', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w900, + color: Colors.black, + shadows: [ + Shadow( + color: Color(0x40000000), + offset: Offset(0, 1), + blurRadius: 1, + ), + ], + ), + ), + const SizedBox(height: 26), + _buildBookCard(), + const SizedBox(height: 24), + _buildFlashcardCard(), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildBookCard() { + return GestureDetector( + onTap: () { + controller.navController.isBook.value = true; + controller.state.value = HomeState.category; + }, + child: Container( + width: double.infinity, + height: 142, + padding: const EdgeInsets.symmetric(horizontal: 12), + margin: const EdgeInsets.symmetric(horizontal: 25), + decoration: BoxDecoration( + color: _bookCardBg, + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.16), + blurRadius: 12, + spreadRadius: 1, + offset: const Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/tipe-book.png', + width: 128, + height: 128, + ), + const SizedBox(width: 8), // atur jarak di sini + const Text( + 'Buku', + style: TextStyle( + color: Colors.black, + fontSize: 38, + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ], + ), + ), + ); + } + + Widget _buildFlashcardCard() { + return GestureDetector( + onTap: () { + controller.navController.isBook.value = false; + controller.state.value = HomeState.category; + }, + child: Container( + width: double.infinity, + height: 142, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + margin: const EdgeInsets.symmetric(horizontal: 25), + decoration: BoxDecoration( + color: _flashcardCardBg, + borderRadius: BorderRadius.circular(30), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.16), + blurRadius: 12, + spreadRadius: 1, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/tipe-flashcard.png', + width: 168, + height: 82, + ), + const SizedBox(height: 2), + const Text( + 'Flashcard', + style: TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/home/components/home_type.dart b/lib/feature/home/components/home_type.dart new file mode 100644 index 0000000..eb05ef7 --- /dev/null +++ b/lib/feature/home/components/home_type.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:zoom_tap_animation/zoom_tap_animation.dart'; + +class HomeType extends StatelessWidget { + const HomeType({ + super.key, + required this.title, + required this.onTap, + }); + + final String title; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ZoomTapAnimation( + onTap: onTap, + child: Container( + height: 150, + width: 300, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.black, + width: 2, + ), + ), + child: Center( + child: Text( + title, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/home/enums/home_state.dart b/lib/feature/home/enums/home_state.dart new file mode 100644 index 0000000..a445d02 --- /dev/null +++ b/lib/feature/home/enums/home_state.dart @@ -0,0 +1,5 @@ +enum HomeState { + home, + category, + content +} \ No newline at end of file diff --git a/lib/feature/home/home_binding.dart b/lib/feature/home/home_binding.dart new file mode 100644 index 0000000..51d5507 --- /dev/null +++ b/lib/feature/home/home_binding.dart @@ -0,0 +1,29 @@ +import 'package:epic_story_app/data/modules/book_module.dart'; +import 'package:epic_story_app/data/modules/children_module.dart'; +import 'package:epic_story_app/data/modules/collection_module.dart'; +import 'package:epic_story_app/data/modules/flashcard_module.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/collection_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:get/get.dart'; + +import 'home_controller.dart'; + +class HomeBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + CollectionModule(); + FlashCardModule(); + BookModule(); + Get.lazyPut( + () => HomeController( + collectionUsecase: Get.find(), + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/home/home_controller.dart b/lib/feature/home/home_controller.dart new file mode 100644 index 0000000..a1665bd --- /dev/null +++ b/lib/feature/home/home_controller.dart @@ -0,0 +1,125 @@ +import 'package:epic_story_app/core/utils/epic_log.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/collection_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_controller.dart'; +import 'package:epic_story_app/feature/home/components/category_state_widget.dart'; +import 'package:epic_story_app/feature/home/components/home_content_state.dart'; +import 'package:epic_story_app/feature/home/components/home_state_widget.dart'; +import 'package:epic_story_app/feature/home/enums/home_state.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class HomeController extends GetxController { + final FlashCardUsecase flashCardUsecase; + final ChildrenUsecase childrenUsecase; + final BookUsecase bookUsecase; + final CollectionUsecase collectionUsecase; + + HomeController({ + required this.flashCardUsecase, + required this.childrenUsecase, + required this.bookUsecase, + required this.collectionUsecase, + }); + + var isLoading = false.obs; + var state = HomeState.home.obs; + + final mainController = Get.find(); + final navController = Get.find(); + final flashcardController = Get.find(); + final bookController = Get.find(); + + final categories = [].obs; + Worker? _bookModeWorker; + int _switchTicket = 0; + + @override + void onInit() { + super.onInit(); + + _bookModeWorker = ever(navController.isBook, (value) { + _syncContentForMode(value); + }); + + _syncContentForMode(navController.isBook.value); + } + + @override + void onClose() { + _bookModeWorker?.dispose(); + super.onClose(); + } + + Future _syncContentForMode( + bool isBookMode, { + bool forceReload = false, + }) async { + final ticket = ++_switchTicket; + final shouldLoad = forceReload || + (isBookMode + ? bookController.books.isEmpty + : flashcardController.flashcards.isEmpty); + + if (shouldLoad) { + isLoading.value = true; + } + + try { + if (isBookMode) { + if (forceReload || bookController.books.isEmpty) { + await bookController.loadBooks(); + } + if (_isStale(ticket)) return; + categories.assignAll(bookController.categories); + } else { + if (forceReload || flashcardController.flashcards.isEmpty) { + await flashcardController.fetchFlashcards(); + } + if (_isStale(ticket)) return; + categories.assignAll(flashcardController.categories); + } + } catch (ex, s) { + EpicLog.exception(ex, s, this, '_syncContentForMode'); + } finally { + if (!_isStale(ticket)) { + isLoading.value = false; + } + } + } + + bool _isStale(int ticket) => isClosed || ticket != _switchTicket; + + Future refreshCurrentView() async { + await _syncContentForMode( + navController.isBook.value, + forceReload: true, + ); + } + + Widget getState() { + switch (state.value) { + case HomeState.home: + return HomeStateWidget(controller: this); + case HomeState.category: + return CategoryStateWidget(controller: this); + case HomeState.content: + return HomeContentState(controller: this); + } + } + + void onBack() { + isLoading.value = false; + if (state.value == HomeState.home) return; + if (state.value == HomeState.category) { + state.value = HomeState.home; + } else if (state.value == HomeState.content) { + state.value = HomeState.category; + } + } +} diff --git a/lib/feature/home/home_page.dart b/lib/feature/home/home_page.dart new file mode 100644 index 0000000..c6e6a84 --- /dev/null +++ b/lib/feature/home/home_page.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import 'home_controller.dart'; + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + + return Obx( + () => PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) { + controller.onBack(); + }, + child: Scaffold( + backgroundColor: const Color(0xFFE0CEFF), + body: controller.isLoading.value + ? const Center(child: CircularProgressIndicator()) + : controller.getState(), + ), + ), + ); + } +} diff --git a/lib/feature/others/main_controller/main_controller.dart b/lib/feature/others/main_controller/main_controller.dart new file mode 100644 index 0000000..843ef3f --- /dev/null +++ b/lib/feature/others/main_controller/main_controller.dart @@ -0,0 +1,326 @@ +import 'dart:typed_data'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; +import 'package:epic_story_app/core/services/firebase_service.dart'; +import 'package:epic_story_app/core/services/google_auth_service.dart'; +import 'package:epic_story_app/core/services/isar_service.dart'; +import 'package:epic_story_app/core/utils/epic_log.dart'; +import 'package:epic_story_app/core/utils/epic_snackbar.dart'; +import 'package:epic_story_app/core/widgets/dialogs/loading_dialog.dart'; +import 'package:epic_story_app/core/widgets/dialogs/reward_photo_viewer_dialog.dart'; +import 'package:epic_story_app/domain/entities/children_entity.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:gal/gal.dart'; +import 'package:get/get.dart'; +import 'package:google_sign_in/google_sign_in.dart'; + +class MainController extends GetxController { + final FirebaseService firebaseService = Get.find(); + final authService = Get.find(); + final isarService = Get.find(); + + final RxBool _isLoadingPage = false.obs; + int _loadingDialogToken = 0; + + bool get isLoadingPage => _isLoadingPage.value; + + var childrenData = Rxn(); + + final ChildrenUsecase childrenUsecase; + final FlashCardUsecase flashCardUsecase; + final BookUsecase bookUsecase; + + MainController({ + required this.childrenUsecase, + required this.flashCardUsecase, + required this.bookUsecase, + }); + + @override + void onInit() { + super.onInit(); + // Safety net for hot-restart/resume where a modal barrier can remain visible. + WidgetsBinding.instance.addPostFrameCallback((_) { + hideLoadingPage(force: true); + }); + } + + Future isConnectedToInternet() async { + try { + var connectivityResult = await Connectivity().checkConnectivity(); + + // Check if device has network connectivity + if (connectivityResult.contains(ConnectivityResult.mobile) || + connectivityResult.contains(ConnectivityResult.wifi)) { + // Additional check: try to reach a reliable server + try { + return true; + // final result = await InternetAddress.lookup('google.com'); + // if (result.isNotEmpty && result[0].rawAddress.isNotEmpty) { + // EpicLog.debug('✅ Internet connection: Available'); + // return true; + // } + } catch (e) { + EpicLog.debug('❌ Internet connection: DNS lookup failed - $e'); + return false; + } + } else { + EpicLog.debug('❌ Internet connection: No network connectivity'); + return false; + } + } catch (e) { + EpicLog.debug('❌ Internet connection: Error checking connectivity - $e'); + return false; + } + } + + void showLoadingPage() { + if (!_isLoadingPage.value && !(Get.isDialogOpen ?? false)) { + _isLoadingPage.value = true; + final token = ++_loadingDialogToken; + try { + Get.dialog( + const UnconstrainedBox( + child: Material( + color: Colors.transparent, + child: LoadingDialog(), + ), + ), + barrierDismissible: false, // Prevent dismissing by tapping outside + barrierColor: Colors.black.withOpacity(0.5), + useSafeArea: true, + ); + Future.delayed( + const Duration(seconds: 10), + () { + if (_isLoadingPage.value && token == _loadingDialogToken) { + hideLoadingPage(); + } + }, + ); + } catch (e) { + _isLoadingPage.value = false; + EpicLog.debug('Error showing loading dialog: $e'); + } + } + } + + void hideLoadingPage({bool force = false}) { + if (!_isLoadingPage.value && !force) return; + + _isLoadingPage.value = false; + if (Get.isDialogOpen ?? false) { + try { + if (Get.isSnackbarOpen) { + Get.closeCurrentSnackbar(); + } + Get.back(); + } catch (e) { + EpicLog.debug('Error closing loading dialog: $e'); + } + } + } + + void showRewardPhotoViewer({ + required List imageUrls, + int initialIndex = 0, + }) { + final urls = imageUrls + .whereType() + .map((url) => url.trim()) + .where((url) => url.isNotEmpty) + .toList(growable: false); + + if (urls.isEmpty) { + EpicSnackBar.showWarningSnackBar( + 'Peringatan', + 'Gambar reward tidak tersedia.', + ); + return; + } + + final safeInitialIndex = initialIndex.clamp(0, urls.length - 1).toInt(); + + Get.dialog( + RewardPhotoViewerDialog( + imageUrls: urls, + initialIndex: safeInitialIndex, + onDownload: downloadRewardImage, + ), + barrierDismissible: true, + barrierColor: Colors.black.withOpacity(0.85), + useSafeArea: true, + ); + } + + Future downloadRewardImage(String imageUrl) async { + if (!GetPlatform.isAndroid && !GetPlatform.isIOS) { + EpicSnackBar.showWarningSnackBar( + 'Tidak Didukung', + 'Fitur simpan gambar hanya tersedia di Android/iOS.', + ); + return; + } + + try { + EpicSnackBar.showPendingSnackBar( + 'Download', + 'Menyimpan gambar reward ke perangkat...', + showLog: false, + ); + + final response = await Dio().get>( + imageUrl, + options: Options(responseType: ResponseType.bytes), + ); + + final bytes = response.data; + if (bytes == null || bytes.isEmpty) { + EpicSnackBar.showErrorSnackBar( + 'Gagal', + 'Gagal mengunduh gambar reward.', + ); + return; + } + + final hasAccess = await Gal.hasAccess(); + if (!hasAccess) { + final isGranted = await Gal.requestAccess(); + if (!isGranted) { + EpicSnackBar.showWarningSnackBar( + 'Izin Ditolak', + 'Aplikasi butuh izin galeri untuk menyimpan gambar reward.', + ); + return; + } + } + + final fileName = 'reward_${DateTime.now().millisecondsSinceEpoch}'; + await Gal.putImageBytes( + Uint8List.fromList(bytes), + name: fileName, + album: 'Epic Story', + ); + + EpicSnackBar.showSuccessSnackBar( + 'Berhasil', + 'Gambar reward berhasil disimpan di galeri.', + showLog: false, + ); + } on MissingPluginException catch (e, s) { + EpicLog.exception(e, s, this, 'downloadRewardImage - missing plugin'); + EpicSnackBar.showWarningSnackBar( + 'Perlu Restart Aplikasi', + 'Plugin simpan gambar belum aktif. Tutup aplikasi lalu jalankan ulang penuh.', + ); + } on GalException catch (e, s) { + EpicLog.exception(e, s, this, 'downloadRewardImage - gal exception'); + if (e.type == GalExceptionType.accessDenied) { + EpicSnackBar.showWarningSnackBar( + 'Izin Ditolak', + 'Akses galeri ditolak. Izinkan akses galeri untuk menyimpan reward.', + ); + return; + } + EpicSnackBar.showErrorSnackBar( + 'Gagal', + 'Tidak bisa menyimpan gambar reward. Coba lagi.', + ); + } catch (e, s) { + EpicLog.exception(e, s, this, 'downloadRewardImage'); + EpicSnackBar.showErrorSnackBar( + 'Gagal', + 'Terjadi kesalahan saat menyimpan gambar reward.', + ); + } + } + + Future loginGoogle({ + GoogleSignInAccount? googleSignIn, + }) async { + try { + showLoadingPage(); + final bool isConnected = await isConnectedToInternet(); + + if (!isConnected) { + EpicLog.debug( + 'loginGoogle - Google Login Error: No internet connection'); + EpicSnackBar.showErrorSnackBar( + "Failure", "Please check your internet connection."); + return false; + } + + GoogleSignInAccount? googleUser = googleSignIn; + if (googleUser == null) { + await authService.signOut(); + googleUser = await authService.signIn(); + if (googleUser == null) { + EpicLog.debug( + 'loginGoogle - Google Login Error: User cancelled sign in'); + return false; + } + } + + if (!await firebaseService.loginWithGoogle( + googleSignIn: googleUser, ignoreAnonymous: false)) { + EpicLog.debug( + 'loginGoogle - Google Login Error: Firebase authentication failed'); + + EpicSnackBar.showErrorSnackBar( + "Failure", "Failed to authenticate with Firebase."); + } + + await isarService.clearAllData(); + + final remoteStudent = await childrenUsecase.getChildrenRemoteModel(); + childrenData.value = remoteStudent; + + EpicLog.debug('loginGoogle - Google Login: Login successful'); + return true; + } catch (e, s) { + EpicLog.exception(e, s, this, + 'loginGoogle - Error occurred while logging in with Google'); + return false; + } finally { + hideLoadingPage(); + } + } + + Future studentLogout() async { + try { + showLoadingPage(); + + await Future.wait([ + childrenUsecase.logout(), + firebaseService.logout(), + authService.signOut(), + isarService.clearAllData(), + ]); + + EpicLog.debug('✅ Logout: User logged out successfully'); + } catch (e, s) { + EpicLog.exception(e, s, this, 'studentLogout'); + } finally { + hideLoadingPage(); + } + } + + Future testFetchFlashcardHistories({ + bool refreshLocal = false, + }) async { + try { + EpicLog.debug('controller getFlashcardHistories'); + final histories = await childrenUsecase.testGetFlashcardHistories( + refreshLocal: refreshLocal, + ); + EpicLog.debug('controller getFlashcardHistories - result: $histories'); + } catch (e, s) { + EpicLog.exception(e, s, this, 'testFetchFlashcardHistories'); + } + } +} diff --git a/lib/feature/others/main_controller/test_gemini.dart b/lib/feature/others/main_controller/test_gemini.dart new file mode 100644 index 0000000..779bf9f --- /dev/null +++ b/lib/feature/others/main_controller/test_gemini.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:epic_story_app/core/constants/api_key.dart'; +import 'package:epic_story_app/data/models/remotes/gemini_response.dart'; + +Future generate({required String prompt}) async { + try { + final Dio _dio = Dio(); + // generate response from gemini api + final response = await _dio.post( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + queryParameters: { + "key": ApiKey.gemini, + }, + data: json.encode( + { + "contents": [ + { + "role": "user", + "parts": [ + {"text": prompt} + ] + }, + ], + "generationConfig": { + "temperature": 1, + "topK": 0, + "topP": 0.95, + "maxOutputTokens": 8192, + "stopSequences": [] + }, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE" + } + ] + }, + ), + options: Options( + headers: {'Content-Type': 'application/json'}, + validateStatus: (status) { + return status == HttpStatus.ok || + status == HttpStatus.badRequest || + status == HttpStatus.internalServerError || + status == HttpStatus.notFound; + }, + ), + ); + + // get payload from response + final dynamic payload = response.data; + + // check response is successfully or not + if (response.statusCode == HttpStatus.ok) { + // get prompt result + GeminiResponse geminiResponse = GeminiResponse.fromJson(payload); + var geminiPayload = + geminiResponse.candidates?[0].content?.parts?[0].text ?? '{}'; + + return geminiPayload; + } else { + return "error"; + } + } on DioException catch (ex) { + return "exception : ${ex.toString()}"; + } +} + +void main() async { + var prompt = """ +Teks: +"Aku ada sepeda mainan. Ayah bisa mengendarai sepeda besar. Aku berharap bisa mengendarai sepeda dan ikut ayah bekerja. Ini motor. Pakai motor bisa melaju lebih cepat dari sepeda. Ada mobil di jalan juga. Motor dapat mengejar mobil. Aku tidak sabar sampai aku cukup umur untuk mengendarai mobil. Aku akan bisa pergi ke tempat yang aku inginkan. Apa itu di sebelah? Ada bis besar." + +Instruksi: +Buat hanya 1 soal. +Soal harus berdasarkan isi teks. +Sediakan 4 pilihan jawaban (A, B, C, D). +Hanya 1 jawaban yang benar. +Gunakan format JSON berikut dan pastikan JSON valid. +Jangan tambahkan penjelasan apa pun di luar JSON. + +Format output: +{ +"question": "Pertanyaan berdasarkan teks", +"choices": [ +"Pilihan A", +"Pilihan B", +"Pilihan C", +"Pilihan D" +], +"correctedIndex": 0 +} +"""; + var response = await generate(prompt: prompt); + print("$response"); +} diff --git a/lib/feature/others/main_controller/test_generate_quiz.dart b/lib/feature/others/main_controller/test_generate_quiz.dart new file mode 100644 index 0000000..40a6a54 --- /dev/null +++ b/lib/feature/others/main_controller/test_generate_quiz.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:epic_story_app/core/constants/api_key.dart'; +import 'package:epic_story_app/data/models/remotes/gemini_response.dart'; + +String _normalizeToJsonString(String input) { + // Try to extract JSON content even if wrapped in code fences or extra text. + String candidate = input; + + final fencedMatch = + RegExp(r"```(?:json)?\s*([\s\S]*?)\s*```", multiLine: true) + .firstMatch(input); + if (fencedMatch != null) { + candidate = fencedMatch.group(1) ?? candidate; + } + + String? _tryDecode(String text) { + try { + final decoded = json.decode(text); + return json.encode(decoded); + } catch (_) { + return null; + } + } + + final start = candidate.indexOf('{'); + final end = candidate.lastIndexOf('}'); + if (start != -1 && end != -1 && end > start) { + final slice = candidate.substring(start, end + 1); + final normalized = _tryDecode(slice); + if (normalized != null) return normalized; + } + + final normalized = _tryDecode(candidate); + if (normalized != null) return normalized; + + return json.encode({"error": "invalid_json", "raw": input.trim()}); +} + +Future generate({required String prompt}) async { + try { + final Dio _dio = Dio(); + // generate response from gemini api + final response = await _dio.post( + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent", + queryParameters: { + "key": ApiKey.gemini, + }, + data: json.encode( + { + "contents": [ + { + "role": "user", + "parts": [ + {"text": prompt} + ] + }, + ], + "generationConfig": { + "temperature": 1, + "topK": 0, + "topP": 0.95, + "maxOutputTokens": 8192, + "stopSequences": [] + }, + "safetySettings": [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "threshold": "BLOCK_NONE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "threshold": "BLOCK_NONE" + } + ] + }, + ), + options: Options( + headers: {'Content-Type': 'application/json'}, + validateStatus: (status) { + return status == HttpStatus.ok || + status == HttpStatus.badRequest || + status == HttpStatus.internalServerError || + status == HttpStatus.notFound; + }, + ), + ); + + // get payload from response + final dynamic payload = response.data; + + // check response is successfully or not + if (response.statusCode == HttpStatus.ok) { + // get prompt result + GeminiResponse geminiResponse = GeminiResponse.fromJson(payload); + var geminiPayload = + geminiResponse.candidates?[0].content?.parts?[0].text ?? '{}'; + + return _normalizeToJsonString(geminiPayload); + } else { + return json.encode({ + "error": "bad_response", + "statusCode": response.statusCode, + "payload": payload, + }); + } + } on DioException catch (ex) { + return json.encode({"error": "exception", "message": ex.toString()}); + } +} + +void main() async { + var prompt = """ +Teks: +"Aku ada sepeda mainan. Ayah bisa mengendarai sepeda besar. Aku berharap bisa mengendarai sepeda dan ikut ayah bekerja. Ini motor. Pakai motor bisa melaju lebih cepat dari sepeda. Ada mobil di jalan juga. Motor dapat mengejar mobil. Aku tidak sabar sampai aku cukup umur untuk mengendarai mobil. Aku akan bisa pergi ke tempat yang aku inginkan. Apa itu di sebelah? Ada bis besar." + +Instruksi: +Buat hanya 1 soal. +Soal harus berdasarkan isi teks. +Sediakan 4 pilihan jawaban (A, B, C, D). +Hanya 1 jawaban yang benar. +Gunakan format JSON berikut dan pastikan JSON valid. +Jangan tambahkan penjelasan apa pun di luar JSON. + +Format output: +{ +"question": "Pertanyaan berdasarkan teks", +"quizType": 1, +"choices": [ +"Pilihan A", +"Pilihan B", +"Pilihan C", +"Pilihan D" +], +"correctedIndex": 0 +} +"""; + var response = await generate(prompt: prompt); + print("$response"); +} diff --git a/lib/feature/profiles/presentation/profile_binding.dart b/lib/feature/profiles/presentation/profile_binding.dart new file mode 100644 index 0000000..b8048ce --- /dev/null +++ b/lib/feature/profiles/presentation/profile_binding.dart @@ -0,0 +1,15 @@ +import 'package:epic_story_app/data/modules/children_module.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/feature/profiles/presentation/profile_controller.dart'; +import 'package:get/get.dart'; + +class ProfileBinding extends Bindings{ + @override + void dependencies() { + ChildrenModule(); + + Get.lazyPut(() => ProfileController( + childrenUsecase: Get.find(), + )); + } +} \ No newline at end of file diff --git a/lib/feature/profiles/presentation/profile_controller.dart b/lib/feature/profiles/presentation/profile_controller.dart new file mode 100644 index 0000000..5925a78 --- /dev/null +++ b/lib/feature/profiles/presentation/profile_controller.dart @@ -0,0 +1,40 @@ +import 'package:epic_story_app/core/routes/epic_routes.dart'; +import 'package:epic_story_app/domain/entities/children_entity.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ProfileController extends GetxController { + final ChildrenUsecase childrenUsecase; + + ProfileController({ + required this.childrenUsecase, + }); + + final mainController = Get.find(); + + var childrenData = Rx(null); + + @override + void onInit() async { + super.onInit(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + childrenData.value = await childrenUsecase.getCurrentChildren(); + }); + } + + Future logout() async { + Get.defaultDialog( + title: "Konfirmasi", + middleText: "Yakin mau logout?", + textConfirm: "Ya", + textCancel: "Batal", + confirmTextColor: Colors.white, + onConfirm: () async { + await mainController.studentLogout(); + Get.offAllNamed(EpicRoutes.welcome); + }, + ); + } +} diff --git a/lib/feature/profiles/presentation/profile_page.dart b/lib/feature/profiles/presentation/profile_page.dart new file mode 100644 index 0000000..e7ff46a --- /dev/null +++ b/lib/feature/profiles/presentation/profile_page.dart @@ -0,0 +1,220 @@ +import 'package:epic_story_app/core/constants/size/epic_size.dart'; +import 'package:epic_story_app/core/styles/epic_app_size.dart'; +import 'package:epic_story_app/domain/entities/children_entity.dart'; +import 'package:epic_story_app/feature/profiles/presentation/profile_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key}); + + static const Color _pageBackground = Color(0xFFC6B2E6); + static const Color _profileBox = Color(0xFFB400FF); + static const Color _logoutButton = Color(0xFFFF0000); + + @override + Widget build(BuildContext context) { + final ProfileController controller = Get.find(); + + return Scaffold( + backgroundColor: _pageBackground, + body: SafeArea( + child: Obx( + () { + final ChildrenEntity? children = controller.childrenData.value; + if (children == null) { + return const Center( + child: CircularProgressIndicator( + color: Color(0xFF7E32AB), + ), + ); + } + + final photoUrl = children.photoPicture; + final initials = _initials(children.fullName ?? ''); + + return Column( + children: [ + SizedBox( + height: 146, + child: Stack( + clipBehavior: Clip.none, + children: [ + Container( + height: EpicAppSize.calculatedSize(95), + width: double.infinity, + padding: const EdgeInsets.fromLTRB(10, 8, 125, 8), + decoration: const BoxDecoration( + borderRadius: BorderRadius.vertical( + bottom: Radius.circular(18), + ), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF2C0B3A), + Color(0xFF7E32AB), + ], + stops: [0.1, 1.0], + ), + ), + child: Row( + children: [ + SizedBox(width: EpicAppSize.calculatedSize(50)), + Image.asset( + 'assets/images/coin-gancet.png', + width: EpicAppSize.calculatedSize(60), + height: EpicAppSize.calculatedSize(60), + fit: BoxFit.contain, + ), + SizedBox(width: EpicAppSize.calculatedSize(8)), + Text( + 'Profil Kamu', + style: TextStyle( + color: Colors.white, + fontSize: EpicAppSize.calculatedSize(30), + fontWeight: FontWeight.w900, + height: 1, + shadows: [ + Shadow( + color: Color(0x3D000000), + blurRadius: 2, + offset: Offset(0, 2), + ), + ], + ), + ), + ], + ), + ), + Positioned( + top: -12, + right: 6, + child: Image.asset( + 'assets/images/icon-top-bar.png', + width: EpicAppSize.calculatedSize(100), + height: EpicAppSize.calculatedSize(100), + fit: BoxFit.contain, + ), + ), + ], + ), + ), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.fromLTRB(24, 6, 24, 24), + child: Column( + children: [ + Container( + width: 240, + padding: const EdgeInsets.fromLTRB(18, 16, 18, 18), + decoration: BoxDecoration( + color: _profileBox, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x29000000), + blurRadius: 8, + offset: Offset(0, 3), + ), + ], + ), + child: Column( + children: [ + CircleAvatar( + radius: 56, + backgroundColor: const Color(0xFFC8A2E8), + backgroundImage: + photoUrl != null && photoUrl.isNotEmpty + ? NetworkImage(photoUrl) + : null, + child: photoUrl == null || photoUrl.isEmpty + ? Text( + initials, + style: const TextStyle( + color: Colors.white, + fontSize: 34, + fontWeight: FontWeight.w900, + ), + ) + : null, + ), + const SizedBox(height: 14), + Text( + children.fullName ?? 'Nama tidak tersedia', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: EpicSize.headlineLarge.copyWith( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: EpicAppSize.calculatedSize(30), + height: 0.95, + ), + ), + const SizedBox(height: 6), + Text( + children.email ?? '-', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: EpicAppSize.calculatedSize(18), + fontWeight: FontWeight.w500, + height: 1, + ), + ), + ], + ), + ), + const SizedBox(height: 68), + SizedBox( + width: 122, + height: 52, + child: ElevatedButton( + onPressed: controller.logout, + style: ElevatedButton.styleFrom( + backgroundColor: _logoutButton, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + // side: const BorderSide( + // color: Colors.white, + // width: 1.1, + // ), + ), + ), + child: Text( + 'Logout', + style: TextStyle( + color: Colors.white, + fontSize: EpicAppSize.calculatedSize(22), + fontWeight: FontWeight.w900, + height: 1, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + String _initials(String name) { + final parts = name.trim().split(RegExp(r'\s+')); + if (parts.isEmpty) return '??'; + final first = parts.first.isNotEmpty ? parts.first[0] : ''; + final last = parts.length > 1 && parts.last.isNotEmpty ? parts.last[0] : ''; + final result = (first + last).toUpperCase(); + return result.isEmpty ? '??' : result; + } +} diff --git a/lib/feature/quizes/multiple_choices/multiple_choice_binding.dart b/lib/feature/quizes/multiple_choices/multiple_choice_binding.dart new file mode 100644 index 0000000..6a26c18 --- /dev/null +++ b/lib/feature/quizes/multiple_choices/multiple_choice_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'multiple_choice_controller.dart'; + +class MultipleChoiceBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => MultipleChoiceController()); + } +} \ No newline at end of file diff --git a/lib/feature/quizes/multiple_choices/multiple_choice_controller.dart b/lib/feature/quizes/multiple_choices/multiple_choice_controller.dart new file mode 100644 index 0000000..cd3822d --- /dev/null +++ b/lib/feature/quizes/multiple_choices/multiple_choice_controller.dart @@ -0,0 +1,12 @@ +import 'package:epic_story_app/domain/entities/quiz/quiz_choice_entity.dart'; +import 'package:get/get.dart'; + +class MultipleChoiceController extends GetxController { + late QuizChoiceEntity quizChoice; + + @override + void onInit() { + super.onInit(); + quizChoice = Get.arguments as QuizChoiceEntity; + } +} \ No newline at end of file diff --git a/lib/feature/quizes/multiple_choices/multiple_choice_page.dart b/lib/feature/quizes/multiple_choices/multiple_choice_page.dart new file mode 100644 index 0000000..2dac38d --- /dev/null +++ b/lib/feature/quizes/multiple_choices/multiple_choice_page.dart @@ -0,0 +1,264 @@ +import 'package:epic_story_app/feature/quizes/multiple_choices/multiple_choice_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:flutter/services.dart'; + +class MultipleChoicePage extends StatefulWidget { + const MultipleChoicePage({super.key}); + + @override + State createState() => _MultipleChoicePageState(); +} + +class _MultipleChoicePageState extends State { + final MultipleChoiceController _controller = + Get.find(); + + int? _selectedIndex; + + @override + void initState() { + super.initState(); + SystemChrome.setPreferredOrientations( + [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown + ], + ); + } + + @override + void dispose() { + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final Color borderColor = Colors.grey.shade800; + final Color panelColor = Colors.grey.shade200; + final Color cardColor = Colors.grey.shade100; + final Color shadowColor = Colors.black.withOpacity(0.12); + + final String question = _controller.quizChoice.question ?? ''; + final List options = _controller.quizChoice.choices ?? []; + + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + elevation: 0, + title: const Text('Multiple Choice Quiz'), + ), + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: panelColor, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: borderColor, width: 2), + boxShadow: [ + BoxShadow( + color: shadowColor, + blurRadius: 18, + offset: const Offset(10, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor, width: 2), + boxShadow: [ + BoxShadow( + color: shadowColor, + blurRadius: 12, + offset: const Offset(6, 6), + ), + ], + ), + child: Text( + question, + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 16), + if (options.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + 'No quiz data available.', + textAlign: TextAlign.center, + ), + ) + else + ...options + .asMap() + .entries + .map((MapEntry entry) { + final bool isSelected = entry.key == _selectedIndex; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _OptionTile( + label: entry.value, + isSelected: isSelected, + borderColor: borderColor, + shadowColor: shadowColor, + cardColor: cardColor, + leadingLabel: _indexToLetter(entry.key), + onTap: () => + setState(() => _selectedIndex = entry.key), + ), + ); + }), + const SizedBox(height: 4), + SizedBox( + height: 50, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: cardColor, + foregroundColor: Colors.black, + elevation: 6, + shadowColor: shadowColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: borderColor, width: 2), + ), + ), + onPressed: _onCheckAnswers, + child: const Text('Check Answers'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + void _onCheckAnswers() { + if (_selectedIndex == null) { + _showMessage('Please select an option first.'); + return; + } + + final int? correctIndex = _controller.quizChoice.correctedIndex; + final bool isCorrect = + correctIndex != null && correctIndex == _selectedIndex; + final String message = isCorrect + ? 'Correct answer!' + : 'Incorrect. Correct option is ${correctIndex != null ? _indexToLetter(correctIndex) : '-'}.'; + + _showMessage(message); + } + + void _showMessage(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 2), + ), + ); + } + + String _indexToLetter(int index) => String.fromCharCode(65 + index); +} + +class _OptionTile extends StatelessWidget { + const _OptionTile({ + required this.label, + required this.isSelected, + required this.borderColor, + required this.shadowColor, + required this.cardColor, + required this.leadingLabel, + required this.onTap, + }); + + final String label; + final bool isSelected; + final Color borderColor; + final Color shadowColor; + final Color cardColor; + final String leadingLabel; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Ink( + decoration: BoxDecoration( + color: cardColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? Colors.black : borderColor, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: shadowColor, + blurRadius: 12, + offset: const Offset(6, 6), + ), + ], + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14), + child: Row( + children: [ + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? Colors.black : Colors.white, + border: Border.all(color: borderColor, width: 2), + ), + alignment: Alignment.center, + child: Text( + leadingLabel, + style: TextStyle( + color: isSelected ? Colors.white : Colors.black, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/book_reward.dart b/lib/feature/rewards/reward_home/components/book_reward.dart new file mode 100644 index 0000000..b6307f3 --- /dev/null +++ b/lib/feature/rewards/reward_home/components/book_reward.dart @@ -0,0 +1,347 @@ +import 'package:epic_story_app/domain/entities/book_entity.dart'; +import 'package:epic_story_app/domain/entities/book_histories_entity.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/components/reward_claim_button.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class BookReward extends StatelessWidget { + const BookReward({super.key, required this.controller}); + + final RewardHomeController controller; + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + final books = controller.books; + if (books.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 18), + children: const [ + SizedBox(height: 80), + Center( + child: Text( + 'Belum ada hadiah buku yang tersedia.', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.black54, + ), + ), + ), + ], + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 18), + itemCount: books.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final book = books[index]; + final history = controller.historyForBook(book.bookId); + return GestureDetector( + onTap: () => controller.goToBookRead(book), + child: _BookRewardCard( + itemIndex: index, + book: book, + history: history, + onClaim: () => controller.openBookRewardClaim(book), + ), + ); + }, + ); + }); + } +} + +class _BookRewardCard extends StatefulWidget { + const _BookRewardCard({ + required this.itemIndex, + required this.book, + required this.history, + required this.onClaim, + }); + + final int itemIndex; + final BookEntity book; + final BookHistoryEntity? history; + final VoidCallback onClaim; + + @override + State<_BookRewardCard> createState() => _BookRewardCardState(); +} + +class _BookRewardCardState extends State<_BookRewardCard> + with SingleTickerProviderStateMixin { + final MainController mainController = Get.find(); + + late final AnimationController _btnScaleController; + late final Animation _btnScale; + bool _animating = false; + + @override + void initState() { + super.initState(); + _btnScaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + _btnScale = Tween(begin: 0.96, end: 1.04).animate( + CurvedAnimation(parent: _btnScaleController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _btnScaleController.dispose(); + super.dispose(); + } + + void _syncButtonAnimation(bool shouldAnimate) { + if (shouldAnimate && !_animating) { + _btnScaleController.repeat(reverse: true); + _animating = true; + } else if (!shouldAnimate && _animating) { + _btnScaleController.stop(); + _btnScaleController.value = 0.5; + _animating = false; + } + } + + @override + Widget build(BuildContext context) { + final book = widget.book; + final history = widget.history; + final isColorOne = widget.itemIndex.isEven; + + final title = history?.title ?? book.title ?? 'Judul Buku'; + final summary = book.summary ?? '-'; + final category = history?.category ?? book.category ?? '-'; + final totalPages = history?.totalPages ?? book.totalPages ?? 0; + final isFinished = history?.isFinished == true; + final isClaimed = (history?.claimedRewards?.isNotEmpty ?? false); + + final rewardImages = book.rewards ?? []; + final rewardsClaimed = history?.claimedRewards ?? []; + final claimedThumbs = rewardsClaimed + .where((i) => i >= 0 && i < rewardImages.length) + .map((i) => rewardImages[i]) + .take(3) + .toList(); + + final shouldAnimateButton = !isClaimed && isFinished; + _syncButtonAnimation(shouldAnimateButton); + + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isColorOne + ? const [Color(0xFFFFDAA0), Color(0xFFFFFFFF)] + : const [Color(0xFFAAFFC0), Color(0xFFFFFFFF)], + stops: const [0.0, 1.0], + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black.withOpacity(0.6), width: 1.1), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BookCover(url: history?.bookCover ?? book.bookCover), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w800, + color: Colors.black, + height: 1.1, + ), + ), + const SizedBox(height: 8), + Text( + 'Category : $category', + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Total Halaman : $totalPages', + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + summary, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + height: 1.25, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + if (claimedThumbs.isNotEmpty) + Row( + children: List.generate(claimedThumbs.length, (i) { + final isLast = i == claimedThumbs.length - 1; + return Padding( + padding: EdgeInsets.only(right: isLast ? 0 : 10), + child: _Thumb( + url: claimedThumbs[i], + onTap: () { + mainController.showRewardPhotoViewer( + imageUrls: claimedThumbs, + initialIndex: i, + ); + }, + ), + ); + }), + ), + if (shouldAnimateButton) ...[ + const SizedBox(height: 3), + ScaleTransition( + scale: _btnScale, + child: RewardClaimButton(onPressed: widget.onClaim), + ), + ], + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isClaimed ? 'Hadiah sudah diklaim' : 'Hadiah belum diklaim', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(width: 4), + Image.asset( + isClaimed + ? 'assets/images/reward-claimed.png' + : 'assets/images/reward-not-claimed.png', + width: 16, + height: 16, + ), + ], + ), + ], + ), + ); + } +} + +class _BookCover extends StatelessWidget { + const _BookCover({required this.url}); + + final String? url; + + @override + Widget build(BuildContext context) { + return Container( + width: 110, + height: 150, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.black.withOpacity(0.6), width: 1.0), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: url == null + ? const Center( + child: Text( + 'Cover Buku', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.black87, + ), + ), + ) + : Image.network( + url!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const Center( + child: + Icon(Icons.broken_image_outlined, color: Colors.black54), + ), + ), + ), + ); + } +} + +class _Thumb extends StatelessWidget { + const _Thumb({required this.url, required this.onTap}); + + final String? url; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: Colors.black.withOpacity(0.6), width: 1.0), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: url == null + ? const Icon(Icons.image_outlined, color: Colors.black54) + : Image.network( + url!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.broken_image_outlined, + color: Colors.black54, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/flashcard_reward.dart b/lib/feature/rewards/reward_home/components/flashcard_reward.dart new file mode 100644 index 0000000..5459db2 --- /dev/null +++ b/lib/feature/rewards/reward_home/components/flashcard_reward.dart @@ -0,0 +1,310 @@ +import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/components/reward_claim_button.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class FlashcardReward extends StatelessWidget { + const FlashcardReward({super.key, required this.controller}); + + final RewardHomeController controller; + + @override + Widget build(BuildContext context) { + return Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + + final cards = controller.flashcards; + if (cards.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 18), + children: const [ + SizedBox(height: 80), + Center( + child: Text( + 'Belum ada hadiah yang tersedia.', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.black54, + ), + ), + ), + ], + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 18), + itemCount: cards.length, + separatorBuilder: (_, __) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final flashcard = cards[index]; + final history = controller.historyFor(flashcard.flashcardId); + return GestureDetector( + onTap: () { + controller.goToFlashcardRead(flashcard); + }, + child: _RewardCard( + itemIndex: index, + flashcard: flashcard, + history: history, + onClaim: () => controller.openRewardClaim(flashcard), + ), + ); + }, + ); + }); + } +} + +class _RewardCard extends StatefulWidget { + const _RewardCard({ + required this.itemIndex, + required this.flashcard, + required this.history, + required this.onClaim, + }); + + final int itemIndex; + final FlashcardEntity flashcard; + final FlashcardHistoryEntity? history; + final VoidCallback onClaim; + + @override + State<_RewardCard> createState() => _RewardCardState(); +} + +class _RewardCardState extends State<_RewardCard> + with SingleTickerProviderStateMixin { + final MainController mainController = Get.find(); + + late final AnimationController _btnScaleController; + late final Animation _btnScale; + bool _animating = false; + + @override + void initState() { + super.initState(); + _btnScaleController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 950), + ); + _btnScale = Tween(begin: 0.96, end: 1.04).animate( + CurvedAnimation(parent: _btnScaleController, curve: Curves.easeInOut), + ); + } + + @override + void dispose() { + _btnScaleController.dispose(); + super.dispose(); + } + + void _syncButtonAnimation(bool shouldAnimate) { + if (shouldAnimate && !_animating) { + _btnScaleController.repeat(reverse: true); + _animating = true; + } else if (!shouldAnimate && _animating) { + _btnScaleController.stop(); + _btnScaleController.value = 0.5; // center at scale ~1.0 + _animating = false; + } + } + + @override + Widget build(BuildContext context) { + final flashcard = widget.flashcard; + final history = widget.history; + final isColorOne = widget.itemIndex.isEven; + + final title = flashcard.title ?? 'Judul Flashcard'; + final summary = flashcard.summary ?? ''; + final category = flashcard.category ?? '-'; + final totalCards = flashcard.totalCards ?? 0; + final totalQuiz = history?.totalQuiz ?? flashcard.totalQuiz ?? 0; + final completedQuiz = history?.completedQuiz ?? 0; + final claimed = (history?.claimedRewards?.isNotEmpty ?? false); + final rewards = flashcard.rewards ?? []; + final rewardsClaimed = history?.claimedRewards ?? []; + final claimedThumbs = rewardsClaimed + .where((i) => i >= 0 && i < rewards.length) + .map((i) => rewards[i]) + .take(3) + .toList(); + final isFinished = history?.isFinished == true; + + final shouldAnimateButton = !claimed && isFinished; + _syncButtonAnimation(shouldAnimateButton); + + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isColorOne + ? const [Color(0xFFFFE588), Color(0xFFFFFFFF)] + : const [Color(0xFF4EFF4F), Color(0xFFFFFFFF)], + stops: const [0.0, 1.0], + ), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.black.withOpacity(0.6), width: 1.1), + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: Colors.black, + ), + ), + const SizedBox(height: 4), + Text( + summary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + height: 1.3, + ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _Chip(label: '$totalCards kartu'), + _Chip(label: '$completedQuiz/$totalQuiz'), + _Chip(label: category), + ], + ), + const SizedBox(height: 12), + if (claimedThumbs.isNotEmpty) + Row( + children: List.generate(claimedThumbs.length, (i) { + final isLast = i == claimedThumbs.length - 1; + return Padding( + padding: EdgeInsets.only(right: isLast ? 0 : 10), + child: _Thumb( + url: claimedThumbs[i], + onTap: () { + mainController.showRewardPhotoViewer( + imageUrls: claimedThumbs, + initialIndex: i, + ); + }, + ), + ); + }), + ), + if (shouldAnimateButton) ...[ + const SizedBox(height: 3), + ScaleTransition( + scale: _btnScale, + child: RewardClaimButton(onPressed: widget.onClaim), + ), + ], + const SizedBox(height: 10), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + claimed ? 'Hadiah sudah diklaim' : 'Hadiah belum diklaim', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const SizedBox(width: 4), + Image.asset( + claimed + ? 'assets/images/reward-claimed.png' + : 'assets/images/reward-not-claimed.png', + width: 16, + height: 16, + ), + ], + ), + ], + ), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({required this.label}); + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.black.withOpacity(0.7), width: 1.1), + ), + child: Text( + label, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + ); + } +} + +class _Thumb extends StatelessWidget { + const _Thumb({required this.url, required this.onTap}); + final String? url; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: Colors.black.withOpacity(0.6), width: 1.0), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: url == null + ? const Icon(Icons.image_outlined, color: Colors.black54) + : Image.network( + url!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => const Icon( + Icons.broken_image_outlined, + color: Colors.black54, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/reward_claim_button.dart b/lib/feature/rewards/reward_home/components/reward_claim_button.dart new file mode 100644 index 0000000..5ee4241 --- /dev/null +++ b/lib/feature/rewards/reward_home/components/reward_claim_button.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class RewardClaimButton extends StatelessWidget { + const RewardClaimButton({super.key, required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 41, + child: OutlinedButton( + style: OutlinedButton.styleFrom( + side: const BorderSide( + color: Color(0xFFFF1F1F), + width: 1.6, + ), + foregroundColor: Colors.black, + backgroundColor: const Color(0xFFFFE588), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: onPressed, + child: const Text( + 'Dapatkan Reward', + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16, + color: Colors.black, + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/components/reward_type_switcher.dart b/lib/feature/rewards/reward_home/components/reward_type_switcher.dart new file mode 100644 index 0000000..346d06a --- /dev/null +++ b/lib/feature/rewards/reward_home/components/reward_type_switcher.dart @@ -0,0 +1,139 @@ +import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart'; +import 'package:flutter/material.dart'; + +class RewardTypeSwitcher extends StatelessWidget { + const RewardTypeSwitcher({super.key, required this.controller}); + + final RewardHomeController controller; + + @override + Widget build(BuildContext context) { + final bool isBookActive = controller.isBook.value; + + return Container( + height: 56, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFECB63F), Color(0xFF0088FF)], + ), + border: Border.all(color: Colors.black87, width: 1.4), + borderRadius: BorderRadius.circular(24), + boxShadow: const [ + BoxShadow( + color: Color(0x29000000), + blurRadius: 10, + offset: Offset(0, 3), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedAlign( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: + isBookActive ? Alignment.centerLeft : Alignment.centerRight, + child: FractionallySizedBox( + widthFactor: 0.5, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: Image.asset( + _activeAssetPath(isBookActive), + fit: BoxFit.fill, + ), + ), + ), + ), + ), + ), + Row( + children: [ + Expanded( + child: _item( + label: 'Buku', + value: true, + active: isBookActive, + ), + ), + Expanded( + child: _item( + label: 'Flashcard', + value: false, + active: !isBookActive, + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _activeAssetPath(bool isBookActive) { + if (isBookActive) { + return 'assets/images/button-book-active.png'; + } + + return 'assets/images/button-flashcard-acive.png'; + } + + Widget _item({ + required String label, + required bool value, + required bool active, + }) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: () => controller.isBook.value = value, + child: Center( + child: AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + style: TextStyle( + color: active ? Colors.transparent : Colors.white, + fontSize: 30, + fontWeight: FontWeight.w900, + height: 1, + shadows: active + ? null + : const [ + Shadow( + color: Color(0xFF171717), + offset: Offset(1.2, 1.2), + blurRadius: 0, + ), + Shadow( + color: Color(0xB3000000), + offset: Offset(0, 0), + blurRadius: 5, + ), + ], + ), + child: Text(label), + ), + ), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/reward_home_binding.dart b/lib/feature/rewards/reward_home/reward_home_binding.dart new file mode 100644 index 0000000..7b6bd2f --- /dev/null +++ b/lib/feature/rewards/reward_home/reward_home_binding.dart @@ -0,0 +1,29 @@ +import 'package:epic_story_app/data/modules/book_module.dart'; +import 'package:epic_story_app/data/modules/children_module.dart'; +import 'package:epic_story_app/data/modules/collection_module.dart'; +import 'package:epic_story_app/data/modules/flashcard_module.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/collection_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:get/get.dart'; + +import 'reward_home_controller.dart'; + +class RewardHomeBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + CollectionModule(); + FlashCardModule(); + BookModule(); + Get.lazyPut( + () => RewardHomeController( + collectionUsecase: Get.find(), + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/rewards/reward_home/reward_home_controller.dart b/lib/feature/rewards/reward_home/reward_home_controller.dart new file mode 100644 index 0000000..53507fc --- /dev/null +++ b/lib/feature/rewards/reward_home/reward_home_controller.dart @@ -0,0 +1,171 @@ +import 'dart:async'; + +import 'package:epic_story_app/core/routes/epic_routes.dart'; +import 'package:epic_story_app/core/utils/epic_log.dart'; +import 'package:epic_story_app/domain/entities/book_entity.dart'; +import 'package:epic_story_app/domain/entities/book_histories_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_entity.dart'; +import 'package:epic_story_app/domain/entities/flashcards/flashcard_history_entity.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/collection_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart'; +import 'package:get/get.dart'; + +class RewardHomeController extends GetxController { + final CollectionUsecase collectionUsecase; + final ChildrenUsecase childrenUsecase; + final FlashCardUsecase flashCardUsecase; + final BookUsecase bookUsecase; + + RewardHomeController({ + required this.collectionUsecase, + required this.childrenUsecase, + required this.flashCardUsecase, + required this.bookUsecase, + }); + + final navController = Get.find(); + final mainController = Get.find(); + + var isBook = true.obs; + var isLoading = false.obs; + + final flashcards = [].obs; + final historyFlashcards = [].obs; + + final books = [].obs; + final historyBooks = [].obs; + + List _allBooks = []; + List _allFlashcards = []; + + StreamSubscription>? _bookHistorySub; + StreamSubscription>? _flashcardHistorySub; + + @override + void onInit() async { + super.onInit(); + await _fetchBaseData(); + _subscribeToHistories(); + } + + @override + void onClose() { + _bookHistorySub?.cancel(); + _flashcardHistorySub?.cancel(); + super.onClose(); + } + + Future _fetchBaseData() async { + try { + isLoading.value = true; + final fetchedBooks = await bookUsecase.getBooks(); + final fetchedFlashcards = await flashCardUsecase.getFlashcards(); + _allBooks = fetchedBooks.books; + _allFlashcards = fetchedFlashcards; + } catch (ex, s) { + EpicLog.exception(ex, s, 'Error fetching base data'); + } finally { + isLoading.value = false; + } + } + + void _subscribeToHistories() { + _bookHistorySub = childrenUsecase.streamBookHistories().listen( + (histories) { + EpicLog.debug('streamBookHistories update: ${histories.length}'); + final finished = histories.where((e) => e.isFinished == true); + historyBooks.assignAll(finished); + books.assignAll( + _allBooks.where( + (book) => finished.any((h) => h.bookId == book.bookId), + ), + ); + }, + onError: (ex, s) { + EpicLog.exception(ex, s, 'streamBookHistories error'); + }, + ); + + _flashcardHistorySub = childrenUsecase.streamFlashcardHistories().listen( + (histories) { + EpicLog.debug('streamFlashcardHistories update: ${histories.length}'); + final finished = histories.where((e) => e.isFinished == true); + historyFlashcards.assignAll(finished); + flashcards.assignAll( + _allFlashcards.where( + (fc) => finished.any((h) => h.flashcardId == fc.flashcardId), + ), + ); + sortedHistories(); + }, + onError: (ex, s) { + EpicLog.exception(ex, s, 'streamFlashcardHistories error'); + }, + ); + } + + void sortedHistories() { + historyFlashcards.sort((a, b) => + b.lastReadAt?.compareTo( + a.lastReadAt ?? DateTime.fromMillisecondsSinceEpoch(0)) ?? + 0); + } + + FlashcardHistoryEntity? historyFor(String? flashcardId) { + if (flashcardId == null) return null; + return historyFlashcards.firstWhereOrNull( + (h) => h.flashcardId == flashcardId, + ); + } + + BookHistoryEntity? historyForBook(String? bookId) { + if (bookId == null) return null; + return historyBooks.firstWhereOrNull( + (h) => h.bookId == bookId, + ); + } + + void openRewardClaim(FlashcardEntity flashcard) async { + bool result = await Get.toNamed( + EpicRoutes.flashcardReward, + arguments: flashcard, + ); + + if (result == true) { + // history update will arrive automatically via stream + } + } + + void goToFlashcardRead(FlashcardEntity flashcard) async { + await Get.toNamed( + EpicRoutes.flashCardRead, + arguments: flashcard, + ); + } + + void goToBookRead(BookEntity book) async { + await Get.toNamed( + EpicRoutes.bookRead, + arguments: book, + ); + // history update will arrive automatically via stream + } + + void openBookRewardClaim(BookEntity book) async { + await Get.toNamed( + EpicRoutes.bookReward, + arguments: book, + ); + // history update will arrive automatically via stream + } + + Future refreshActiveTab() async { + // base data (books/flashcards) can be re-fetched manually if needed; + // history is always up-to-date via stream + await _fetchBaseData(); + } +} diff --git a/lib/feature/rewards/reward_home/reward_home_page.dart b/lib/feature/rewards/reward_home/reward_home_page.dart new file mode 100644 index 0000000..2db533c --- /dev/null +++ b/lib/feature/rewards/reward_home/reward_home_page.dart @@ -0,0 +1,71 @@ +import 'package:epic_story_app/feature/rewards/reward_home/components/book_reward.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/components/flashcard_reward.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/components/reward_type_switcher.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class RewardHomePage extends StatelessWidget { + const RewardHomePage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + + return Scaffold( + backgroundColor: Color(0xFFE0CEFF), + // floatingActionButton: ElevatedButton( + // onPressed: () { + // controller.mainController.testFetchFlashcardHistories( + // refreshLocal: false, + // ); + // }, + // child: Text("Rev"), + // ), + body: Obx(() { + return Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + child: Container( + height: 170, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF7E32AB), + Color(0xFFE0CEFF), + ], + stops: [0.0, 1.0], + ), + ), + ), + ), + Positioned( + top: 60, + left: 16, + right: 16, + child: RewardTypeSwitcher(controller: controller), + ), + Padding( + padding: const EdgeInsets.only(top: 156), + child: RefreshIndicator( + onRefresh: controller.refreshActiveTab, + child: controller.isBook.value + ? BookReward( + controller: controller, + ) + : FlashcardReward( + controller: controller, + ), + ), + ), + ], + ); + }), + ); + } +} diff --git a/lib/feature/utils/navigation/epic_navigation_binding.dart b/lib/feature/utils/navigation/epic_navigation_binding.dart new file mode 100644 index 0000000..d461cd4 --- /dev/null +++ b/lib/feature/utils/navigation/epic_navigation_binding.dart @@ -0,0 +1,64 @@ +import 'package:epic_story_app/data/modules/book_module.dart'; +import 'package:epic_story_app/data/modules/children_module.dart'; +import 'package:epic_story_app/data/modules/collection_module.dart'; +import 'package:epic_story_app/data/modules/flashcard_module.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/collection_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:epic_story_app/feature/ai_tutors/presentation/ai_tutor_controller.dart'; +import 'package:epic_story_app/feature/books/presentation/book_home/book_home_controller.dart'; +import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_controller.dart'; +import 'package:epic_story_app/feature/flashcards/flashcard_home/flashcard_home_controller.dart'; +import 'package:epic_story_app/feature/home/home_controller.dart'; +import 'package:epic_story_app/feature/profiles/presentation/profile_controller.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/reward_home_controller.dart'; +import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart'; +import 'package:get/get.dart'; + +class EpicNavigationBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + CollectionModule(); + BookModule(); + FlashCardModule(); + + Get.lazyPut( + () => HomeController( + collectionUsecase: Get.find(), + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + ), + ); + + Get.lazyPut(() => AiTutorController()); + Get.lazyPut(() => BookHomeController( + childrenUsecase: Get.find(), + bookUsecase: Get.find(), + collectionUsecase: Get.find(), + )); + Get.lazyPut(() => FlashCardHomeController( + flashCardUsecase: Get.find(), + childrenUsecase: Get.find(), + )); + Get.lazyPut(() => FavoriteHomeController( + collectionUsecase: Get.find(), + )); + Get.lazyPut(() => ProfileController( + childrenUsecase: Get.find(), + )); + + Get.lazyPut( + () => RewardHomeController( + collectionUsecase: Get.find(), + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + ), + ); + + Get.lazyPut(() => EpicNavigationController()); + } +} diff --git a/lib/feature/utils/navigation/epic_navigation_controller.dart b/lib/feature/utils/navigation/epic_navigation_controller.dart new file mode 100644 index 0000000..172b1fe --- /dev/null +++ b/lib/feature/utils/navigation/epic_navigation_controller.dart @@ -0,0 +1,28 @@ +import 'package:epic_story_app/core/utils/epic_log.dart'; +import 'package:get/get.dart'; + +class EpicNavigationController extends GetxController { + EpicNavigationController(); + + var isBook = false.obs; + RxInt selectedIndex = 0.obs; + + // var refreshRewardHome = 0.obs; + var refresRewardFlashcard = 0.obs; + var refreshRewardBook = 0.obs; + var refreshCollectionHome = 0.obs; + + var selectedCategory = "".obs; + + void changePage(int index) { + selectedIndex.value = index; + } + + @override + void onInit() { + super.onInit(); + ever(selectedCategory, (value) { + EpicLog.debug("Selected category: $value"); + }); + } +} diff --git a/lib/feature/utils/navigation/epic_navigation_page.dart b/lib/feature/utils/navigation/epic_navigation_page.dart new file mode 100644 index 0000000..87b2a9c --- /dev/null +++ b/lib/feature/utils/navigation/epic_navigation_page.dart @@ -0,0 +1,141 @@ +import 'package:epic_story_app/feature/favorites/presentation/favorite_home/favorite_home_page.dart'; +import 'package:epic_story_app/feature/home/home_page.dart'; +import 'package:epic_story_app/feature/profiles/presentation/profile_page.dart'; +import 'package:epic_story_app/feature/rewards/reward_home/reward_home_page.dart'; +import 'package:epic_story_app/feature/utils/navigation/epic_navigation_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class EpicNavigationPage extends StatelessWidget { + const EpicNavigationPage({super.key}); + + static const Color _navGradientTop = Color(0xFF7E32AB); + static const Color _navGradientBottom = Color(0xFF331445); + + static const List<_NavItemData> _navItems = [ + _NavItemData( + title: 'Home', + inactiveIcon: 'assets/images/ic-nav-home-inactive.png', + activeIcon: 'assets/images/ic-nav-home-active.png', + ), + _NavItemData( + title: 'Koleksi', + inactiveIcon: 'assets/images/ic-nav-koleksi-inactive.png', + activeIcon: 'assets/images/ic-nav-koleksi-active.png', + ), + _NavItemData( + title: 'Hadiah', + inactiveIcon: 'assets/images/ic-nav-hadian-inactive.png', + activeIcon: 'assets/images/ic-nav-hadiah-active.png', + ), + _NavItemData( + title: 'Profil', + inactiveIcon: 'assets/images/ic-nav-profile-inactive.png', + activeIcon: 'assets/images/ic-nav-profile-active.png', + ), + ]; + + @override + Widget build(BuildContext context) { + final EpicNavigationController controller = Get.find(); + + return Scaffold( + body: Obx(() => IndexedStack( + index: controller.selectedIndex.value, + children: const [ + HomePage(), + FavoriteHomePage(), + RewardHomePage(), + ProfilePage(), + ], + )), + bottomNavigationBar: Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [_navGradientTop, _navGradientBottom], + stops: [0.5, 1.0], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(26), + topRight: Radius.circular(26), + ), + ), + child: SafeArea( + top: false, + child: Obx(() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(_navItems.length, (index) { + final _NavItemData item = _navItems[index]; + final bool isActive = controller.selectedIndex.value == index; + return Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 8), + child: _buildNavItem( + isActive: isActive, + activeIconPath: item.activeIcon, + inactiveIconPath: item.inactiveIcon, + title: item.title, + onTap: () => controller.changePage(index), + ), + ); + }), + ); + }), + ), + ), + ); + } + + Widget _buildNavItem({ + required bool isActive, + required String activeIconPath, + required String inactiveIconPath, + required String title, + required VoidCallback onTap, + }) { + final String iconPath = isActive ? activeIconPath : inactiveIconPath; + + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + width: isActive ? 108 : 64, + height: 64, + alignment: Alignment.center, + child: Semantics( + label: title, + button: true, + selected: isActive, + child: Image.asset( + iconPath, + key: ValueKey('${title.toLowerCase()}_$isActive'), + width: isActive ? 108 : 64, + height: isActive ? 64 : 64, + fit: BoxFit.contain, + filterQuality: FilterQuality.high, + ), + ), + ), + ), + ); + } +} + +class _NavItemData { + const _NavItemData({ + required this.title, + required this.inactiveIcon, + required this.activeIcon, + }); + + final String title; + final String inactiveIcon; + final String activeIcon; +} diff --git a/lib/feature/utils/read_pdf_test/read_pdf_test_binding.dart b/lib/feature/utils/read_pdf_test/read_pdf_test_binding.dart new file mode 100644 index 0000000..cc7a0d9 --- /dev/null +++ b/lib/feature/utils/read_pdf_test/read_pdf_test_binding.dart @@ -0,0 +1,10 @@ +import 'package:get/get.dart'; + +import 'read_pdf_test_controller.dart'; + +class ReadPdfTestBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => ReadPdfTestController()); + } +} \ No newline at end of file diff --git a/lib/feature/utils/read_pdf_test/read_pdf_test_controller.dart b/lib/feature/utils/read_pdf_test/read_pdf_test_controller.dart new file mode 100644 index 0000000..959533a --- /dev/null +++ b/lib/feature/utils/read_pdf_test/read_pdf_test_controller.dart @@ -0,0 +1,169 @@ +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:syncfusion_flutter_pdf/pdf.dart'; +import 'package:flutter_tts/flutter_tts.dart'; + +class ReadPdfTestController extends GetxController { + // Visible viewer controller + final PdfViewerController mainPdfController = PdfViewerController(); + + // Key to force rebuilding the SfPdfViewer when switching source (kept if needed) + final Rx viewerKey = UniqueKey().obs; + + // Current page (reactive) + final RxInt currentPage = 1.obs; + + // New: store extracted text for current page + final RxString currentPageText = ''.obs; + + // In-memory PDF bytes (raw file) - optional for future use + Uint8List? preloadedBytes; + final RxBool isPreloaded = false.obs; + + // Preloading state (loading raw bytes) + final RxBool isPreloading = false.obs; + + // Flutter TTS instance + final FlutterTts _flutterTts = FlutterTts(); + + // Optional: track speaking state + final RxBool isSpeaking = false.obs; + + // Helper to jump to a specific page on the main viewer + void jumpToPage(int page) { + try { + mainPdfController.jumpToPage(page); + } catch (_) {} + } + + // Helper to go to next page on the main viewer + void nextPage() { + try { + mainPdfController.nextPage(); + } catch (_) {} + } + + // Helper to go to previous page on the main viewer + void previousPage() { + try { + mainPdfController.previousPage(); + } catch (_) {} + } + + // Loads raw bytes from assets (keeps for fallback) + Future preloadRawBytes() async { + if (isPreloaded.value || isPreloading.value) return; + isPreloading.value = true; + try { + final data = await rootBundle.load('assets/books/others/transportation.pdf'); + preloadedBytes = data.buffer.asUint8List(); + isPreloaded.value = true; + } catch (e) { + isPreloaded.value = false; + } finally { + isPreloading.value = false; + } + } + + // Extract text for a 1-based page number. Returns null on failure. + Future extractTextFromPage(int pageNumber) async { + if (pageNumber < 1) return null; + if (preloadedBytes == null) { + await preloadRawBytes(); + } + if (preloadedBytes == null) return null; + PdfDocument? doc; + try { + doc = PdfDocument(inputBytes: preloadedBytes!); + // PdfTextExtractor supports extracting by page index (0-based). + final extractor = PdfTextExtractor(doc); + // Attempt to extract just that page + String text; + try { + text = extractor.extractText(startPageIndex: pageNumber - 1, endPageIndex: pageNumber - 1); + } catch (_) { + // Fallback: extract whole document and try to approximate + text = extractor.extractText(); + } + return text; + } catch (e) { + return null; + } finally { + try { + doc?.dispose(); + } catch (_) {} + } + } + + // Print current page text to console and save to variable (no dialog) + Future printCurrentPageText() async { + final page = currentPage.value; + final text = await extractTextFromPage(page); + + // Collapse all whitespace (newlines/tabs/multiple spaces) into single spaces + final cleaned = (text == null || text.trim().isEmpty) + ? '' + : text.replaceAll(RegExp(r'\s+'), ' ').trim(); + + final output = cleaned.isEmpty ? 'No text found on page $page.' : cleaned; + + // Save the whole-page text (one line) to the reactive variable + currentPageText.value = output; + // speak the cleaned page text (skip if no real text) + if (cleaned.isNotEmpty) { + await speakText(cleaned); + } + + // Print to console only + // ignore: avoid_print + print('PDF page $page text:\n$output'); + } + + @override + void onInit() { + super.onInit(); + // Initialize TTS settings; adjust language/rate/pitch as needed + () async { + try { + await _flutterTts.setLanguage('id-ID'); // or 'en-US' + await _flutterTts.setSpeechRate(0.45); + await _flutterTts.setVolume(1.0); + await _flutterTts.setPitch(1.0); + // Optional: set handlers + _flutterTts.setStartHandler(() { + isSpeaking.value = true; + }); + _flutterTts.setCompletionHandler(() { + isSpeaking.value = false; + }); + _flutterTts.setErrorHandler((msg) { + isSpeaking.value = false; + }); + } catch (_) {} + }(); + } + + // Speak given text using flutter_tts + Future speakText(String text) async { + if (text.trim().isEmpty) return; + try { + // stop any current speech, then speak + await _flutterTts.stop(); + await _flutterTts.speak(text); + } catch (_) {} + } + + @override + void onClose() { + try { + _flutterTts.stop(); + } catch (_) {} + try { + mainPdfController.dispose(); + } catch (_) {} + preloadedBytes = null; + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/feature/utils/read_pdf_test/read_pdf_test_page.dart b/lib/feature/utils/read_pdf_test/read_pdf_test_page.dart new file mode 100644 index 0000000..5ae1c83 --- /dev/null +++ b/lib/feature/utils/read_pdf_test/read_pdf_test_page.dart @@ -0,0 +1,63 @@ +import 'package:epic_story_app/feature/utils/read_pdf_test/read_pdf_test_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; + +class ReadPdfTestPage extends StatelessWidget { + const ReadPdfTestPage({super.key}); + + @override + Widget build(BuildContext context) { + final controller = Get.find(); + const assetPath = 'assets/books/others/transportation.pdf'; + + return Scaffold( + appBar: AppBar( + title: Obx(() => Text('Page ${controller.currentPage.value}')), + actions: [ + Obx(() => IconButton( + tooltip: 'Previous page', + icon: const Icon(Icons.arrow_back), + onPressed: controller.currentPage.value > 1 ? () => controller.previousPage() : null, + )), + IconButton( + tooltip: 'Next page', + icon: const Icon(Icons.arrow_forward), + onPressed: () => controller.nextPage(), + ), + IconButton( + tooltip: 'First page', + icon: const Icon(Icons.first_page), + onPressed: () => controller.jumpToPage(1), + ), + // New: print/extract text on current page + IconButton( + tooltip: 'Print page text', + icon: const Icon(Icons.print), + onPressed: () => controller.printCurrentPageText(), + ), + ], + ), + body: SafeArea( + child: Stack( + children: [ + // Visible main viewer (single page layout) + SfPdfViewer.asset( + assetPath, + key: controller.viewerKey.value, + controller: controller.mainPdfController, + pageLayoutMode: PdfPageLayoutMode.single, + pageSpacing: 0, + onPageChanged: (PdfPageChangedDetails details) { + controller.currentPage.value = details.newPageNumber; + }, + onDocumentLoaded: (PdfDocumentLoadedDetails details) { + // Tidak ada preload, hanya memuat halaman yang terlihat + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/feature/utils/splash_screen/splash_screen_binding.dart b/lib/feature/utils/splash_screen/splash_screen_binding.dart new file mode 100644 index 0000000..3432d8d --- /dev/null +++ b/lib/feature/utils/splash_screen/splash_screen_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/utils/splash_screen/splash_screen_controller.dart'; +import 'package:get/get.dart'; + +class SplashScreenBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + + Get.lazyPut( + () => SplashScreenController( + childrenUsecase: Get.find(), + ), + ); + } +} diff --git a/lib/feature/utils/splash_screen/splash_screen_controller.dart b/lib/feature/utils/splash_screen/splash_screen_controller.dart new file mode 100644 index 0000000..ec1b8dd --- /dev/null +++ b/lib/feature/utils/splash_screen/splash_screen_controller.dart @@ -0,0 +1,35 @@ +import 'package:epic_story_app/core/routes/epic_routes.dart'; +import 'package:epic_story_app/core/utils/epic_log.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:get/get.dart'; + +class SplashScreenController extends GetxController { + final ChildrenUsecase childrenUsecase; + + SplashScreenController({ + required this.childrenUsecase, + }); + + @override + void onInit() async { + super.onInit(); + await Future.delayed(const Duration(seconds: 2)); + bool isLogged = await childrenUsecase.isLogged(); + if (isLogged) { + try { + await childrenUsecase + .syncChildrenData() + .timeout(const Duration(seconds: 6)); + } catch (ex, s) { + EpicLog.exception(ex, s, this, 'onInit.syncChildrenData'); + } + if (await childrenUsecase.isEmptyProfileData() == true) { + Get.offAllNamed(EpicRoutes.navigation); + } else { + Get.offAllNamed(EpicRoutes.navigation); + } + } else { + Get.offAllNamed(EpicRoutes.welcome); + } + } +} diff --git a/lib/feature/utils/splash_screen/splash_screen_page.dart b/lib/feature/utils/splash_screen/splash_screen_page.dart new file mode 100644 index 0000000..c60a579 --- /dev/null +++ b/lib/feature/utils/splash_screen/splash_screen_page.dart @@ -0,0 +1,27 @@ +import 'package:epic_story_app/feature/utils/splash_screen/splash_screen_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class SplashScreenPage extends StatelessWidget { + const SplashScreenPage({super.key}); + + @override + Widget build(BuildContext context) { + + final SplashScreenController controller = Get.find(); + + return const Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Text( + "This is Splash Screen", + style: TextStyle( + fontSize: 26, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ); + } +} diff --git a/lib/feature/utils/test_tts/test_tts_binding.dart b/lib/feature/utils/test_tts/test_tts_binding.dart new file mode 100644 index 0000000..d2da27d --- /dev/null +++ b/lib/feature/utils/test_tts/test_tts_binding.dart @@ -0,0 +1,10 @@ +import 'package:epic_story_app/feature/utils/test_tts/test_tts_controller.dart'; +import 'package:get/get.dart'; + +class TestTtsBinding extends Bindings{ + @override + void dependencies() { + Get.lazyPut(() => TestTtsController()); + } + +} \ No newline at end of file diff --git a/lib/feature/utils/test_tts/test_tts_controller.dart b/lib/feature/utils/test_tts/test_tts_controller.dart new file mode 100644 index 0000000..b6157f8 --- /dev/null +++ b/lib/feature/utils/test_tts/test_tts_controller.dart @@ -0,0 +1,81 @@ +import 'package:get/get.dart'; +import 'package:flutter_tts/flutter_tts.dart'; +import 'package:flutter/services.dart'; +import 'package:google_speech/google_speech.dart'; + +class TestTtsController extends GetxController { + final FlutterTts flutterTts = FlutterTts(); + + var isPlaying = false.obs; + var voices = [].obs; + var selectedVoice = Rxn(); + + @override + void onInit() { + super.onInit(); + _initTts(); + } + + void _initTts() async { + flutterTts.setLanguage("id-ID"); + flutterTts.setPitch(1.0); + flutterTts.setSpeechRate(0.5); + + // Ambil semua suara → filter hanya id-ID + var v = await flutterTts.getVoices; + var filtered = List.from(v).where((e) => e["locale"] == "id-ID").toList(); + + voices.assignAll(filtered); + if (voices.isNotEmpty) { + selectedVoice.value = voices.first; + await setVoice(voices.first); + } + + // Listener event TTS + flutterTts.setStartHandler(() { + isPlaying.value = true; + }); + + flutterTts.setCompletionHandler(() { + isPlaying.value = false; + }); + + flutterTts.setCancelHandler(() { + isPlaying.value = false; + }); + } + + Future setVoice(Map voice) async { + selectedVoice.value = voice; + await flutterTts.setVoice({ + "name": voice["name"], + "locale": voice["locale"], + }); + } + + Future speak(String text) async { + if (text.isEmpty) return; + await flutterTts.speak(text); + } + + Future stop() async { + await flutterTts.stop(); + isPlaying.value = false; + } + + Future speakWithGoogleTTS(String text) async { + + // final serviceAccount = ServiceAccount.fromString( + // '${(await rootBundle.loadString('assets/test_service_account.json'))}'); + // + // final speechToText = EndlessStreamingService.viaServiceAccount(serviceAccount); + // + // final responseStream = speechToText.endlessStream; + // + // speechToText.endlessStreamingRecognize( + // StreamingRecognitionConfig(config: config, interimResults: true), + // _audioStream!); + // + // responseStream.listen((data) {...}); + } +} diff --git a/lib/feature/utils/test_tts/tets_tts_page.dart b/lib/feature/utils/test_tts/tets_tts_page.dart new file mode 100644 index 0000000..bb4ae2d --- /dev/null +++ b/lib/feature/utils/test_tts/tets_tts_page.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'test_tts_controller.dart'; + +class TetsTtsPage extends StatelessWidget { + TetsTtsPage({super.key}); + + final controller = Get.find(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text("TTS Bahasa Indonesia")), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Dropdown suara Indo + Obx(() { + if (controller.voices.isEmpty) { + return Text("Loading suara Indonesia..."); + } + return DropdownButton( + isExpanded: true, + value: controller.selectedVoice.value, + items: controller.voices.map((v) { + return DropdownMenuItem( + value: v, + child: Text("${v['name']}"), + ); + }).toList(), + onChanged: (val) { + if (val != null) controller.setVoice(val); + }, + ); + }), + SizedBox(height: 20), + + // Dummy teks Indo + Text("Contoh teks Indonesia"), + SizedBox(height: 10), + + Obx(() => ElevatedButton( + onPressed: controller.isPlaying.value + ? controller.stop + : () => controller.speak("""Semut Kecil dan Belalang yang Malas +Di padang rumput yang luas, hiduplah seekor Semut Kecil yang sangat rajin bernama Kiki dan seekor Belalang yang suka bermalas-malasan bernama Lala. + +Saat musim panas tiba, Kiki sibuk sekali. Ia setiap hari berjalan membawa butiran makanan kecil, mengumpulkannya di sarang untuk persediaan musim dingin. "Ayo, Kiki, istirahat sebentar! Kenapa kamu harus bekerja keras di hari secerah ini?" seru Lala, sambil rebahan santai dan memetik gitar daunnya. + +"Aku harus mengumpulkan makanan sekarang, Lala," jawab Kiki dengan napas terengah-engah. "Musim dingin akan segera datang, dan saat itu tidak ada makanan di luar." + +"Ah, musim dingin masih lama! Santai saja," balas Lala sambil tertawa dan kembali bernyanyi. Lala menghabiskan hari-harinya dengan bermain dan bersenang-senang, tidak peduli dengan pekerjaan Kiki. + +Beberapa minggu kemudian, udara mulai terasa dingin. Daun-daun berguguran. Musim Dingin benar-benar datang. + +Lala mulai kedinginan dan kelaparan. Ia tidak punya makanan sama sekali. Dengan tubuh menggigil, ia berjalan ke sarang Kiki. + +"Kiki, temanku," kata Lala pelan. "Aku kedinginan dan lapar. Aku tidak punya makanan. Bisakah aku meminta sedikit persediaanmu?" + +Kiki, yang sedang duduk hangat di antara tumpukan makanan, merasa iba. "Aku sudah bilang padamu, Lala, kita harus bersiap. Tapi baiklah, aku akan membaginya. Lain kali, kamu harus belajar untuk bekerja keras dan merencanakan masa depan." + +Lala sangat berterima kasih. Sejak hari itu, Lala belajar dari Kiki. Saat musim panas berikutnya datang, Lala tidak hanya bernyanyi, tetapi juga ikut membantu Kiki mengumpulkan makanan. Mereka bekerja bersama-sama, dan mereka pun menjadi sahabat baik. + +Pesan moral: Kerja keras dan persiapan itu penting. Selain itu, menolong teman yang kesusahan adalah perbuatan baik."""), + child: Text(controller.isPlaying.value ? "Stop" : "Speak"), + )), + ], + ), + ), + ); + } +} diff --git a/lib/feature/utils/welcome/welcome_binding.dart b/lib/feature/utils/welcome/welcome_binding.dart new file mode 100644 index 0000000..4a7f28b --- /dev/null +++ b/lib/feature/utils/welcome/welcome_binding.dart @@ -0,0 +1,15 @@ +import 'package:epic_story_app/data/modules/children_module.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/feature/utils/welcome/welcome_controller.dart'; +import 'package:get/get.dart'; + +class WelcomeBinding extends Bindings { + @override + void dependencies() { + ChildrenModule(); + + Get.lazyPut(() => WelcomeController( + childrenUsecase: Get.find(), + )); + } +} diff --git a/lib/feature/utils/welcome/welcome_controller.dart b/lib/feature/utils/welcome/welcome_controller.dart new file mode 100644 index 0000000..1d25877 --- /dev/null +++ b/lib/feature/utils/welcome/welcome_controller.dart @@ -0,0 +1,36 @@ +import 'package:epic_story_app/core/routes/epic_routes.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:get/get.dart'; +import '../../../core/utils/epic_log.dart'; + +class WelcomeController extends GetxController { + final ChildrenUsecase childrenUsecase; + + final mainController = Get.find(); + + WelcomeController({required this.childrenUsecase}); + + void signInWithGoogle() async { + try { + EpicLog.debug("Starting Google Sign-In process"); + final signInFire = await mainController.loginGoogle(); + if (signInFire) { + mainController.showLoadingPage(); + } + final result = await childrenUsecase.isEmptyProfileData(); + if (signInFire) { + if (result == true) { + await Get.offAllNamed(EpicRoutes.navigation); + } else { + await Get.offAllNamed(EpicRoutes.navigation); + } + // Wait until navigation completes, then hide the loading page + mainController.hideLoadingPage(); + } + } catch (ex, s) { + mainController.hideLoadingPage(); + EpicLog.exception(ex, s, this, 'signInWithGoogle'); + } + } +} diff --git a/lib/feature/utils/welcome/welcome_page.dart b/lib/feature/utils/welcome/welcome_page.dart new file mode 100644 index 0000000..0b9cbf0 --- /dev/null +++ b/lib/feature/utils/welcome/welcome_page.dart @@ -0,0 +1,188 @@ +import 'package:epic_story_app/feature/utils/welcome/welcome_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class WelcomePage extends StatelessWidget { + const WelcomePage({super.key}); + + static const Color _backgroundColor = Color(0xFFE0CEFF); + static const Color _buttonColor = Color(0xFF7A1FA0); + static const Color _blurLayerColor = Color(0xFF272052); + + @override + Widget build(BuildContext context) { + final WelcomeController controller = Get.find(); + + return Scaffold( + backgroundColor: _backgroundColor, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final double width = constraints.maxWidth; + final double height = constraints.maxHeight; + + return Stack( + children: [ + Positioned( + left: -width * 0.35, + top: height * 0.1, + child: _buildBlurCircle( + size: width * 0.95, + opacity: 0.22, + ), + ), + Positioned( + right: -width * 0.42, + top: height * 0.2, + child: _buildBlurCircle( + size: width * 1.1, + opacity: 0.18, + ), + ), + Positioned( + top: height * 0.02, + left: width * 0.06, + child: Image.asset( + 'assets/images/book-stack.png', + width: width * 0.26, + ), + ), + Positioned( + top: height * 0.02, + right: width * 0.06, + child: Image.asset( + 'assets/images/coin-right.png', + width: width * 0.2, + ), + ), + Positioned( + top: height * 0.46, + right: width * 0.08, + child: Image.asset( + 'assets/images/coin-left.png', + width: width * 0.09, + ), + ), + Positioned( + top: height * 0.61, + left: width * 0.08, + child: Image.asset( + 'assets/images/coin-left.png', + width: width * 0.11, + ), + ), + Align( + alignment: const Alignment(0, -0.24), + child: Image.asset( + 'assets/images/book-and-world.png', + width: width * 0.82, + ), + ), + Positioned( + right: width * 0.14, + bottom: height * 0.18, + child: Image.asset( + 'assets/images/book-stack.png', + width: width * 0.12, + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.fromLTRB( + width * 0.1, + 0, + width * 0.1, + height * 0.08, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome\nto Epic Story Mobile!', + style: TextStyle( + color: Colors.white, + fontSize: width * 0.07, + fontWeight: FontWeight.w700, + height: 1.05, + ), + ), + const SizedBox(height: 10), + Text( + 'Play, Learn, and Explore with Exciting\nQuizzes!', + style: TextStyle( + color: Colors.white.withOpacity(0.95), + fontSize: width * 0.045, + fontWeight: FontWeight.w500, + height: 1.35, + ), + ), + const SizedBox(height: 28), + SizedBox( + width: double.infinity, + height: 76, + child: ElevatedButton( + onPressed: controller.signInWithGoogle, + style: ElevatedButton.styleFrom( + backgroundColor: _buttonColor, + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(40), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + 'assets/images/logo-google.png', + width: 38, + height: 38, + ), + const SizedBox(width: 12), + Text( + 'Login Google', + style: TextStyle( + fontSize: width * 0.072, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildBlurCircle({ + required double size, + required double opacity, + }) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: _blurLayerColor.withOpacity(opacity), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: _blurLayerColor.withOpacity(opacity + 0.22), + blurRadius: size * 0.45, + spreadRadius: size * 0.05, + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..5eb5db2 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,84 @@ +import 'dart:ui'; + +import 'package:epic_story_app/core/constants/size/epic_size.dart'; +import 'package:epic_story_app/core/services/firebase_service.dart'; +import 'package:epic_story_app/core/services/google_auth_service.dart'; +import 'package:epic_story_app/core/services/isar_service.dart'; +import 'package:epic_story_app/core/styles/epic_app_size.dart'; +import 'package:epic_story_app/data/modules/book_module.dart'; +import 'package:epic_story_app/data/modules/children_module.dart'; +import 'package:epic_story_app/data/modules/flashcard_module.dart'; +import 'package:epic_story_app/domain/usecases/book_usecase.dart'; +import 'package:epic_story_app/domain/usecases/children_usecase.dart'; +import 'package:epic_story_app/domain/usecases/flashcard_usecase.dart'; +import 'package:epic_story_app/feature/others/main_controller/main_controller.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:get/get.dart'; + +import 'core/routes/epic_routes.dart'; +// import 'animation_demo.dart'; +import 'firebase_options.dart'; +import 'package:firebase_crashlytics/firebase_crashlytics.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Menangkap error dari framework Flutter + await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(true); + FlutterError.onError = FirebaseCrashlytics.instance.recordFlutterError; + + // Menangkap error yang terjadi di luar Flutter (misal, dalam isolate) + PlatformDispatcher.instance.onError = (error, stack) { + FirebaseCrashlytics.instance.recordError(error, stack, fatal: true); + return true; + }; + + await Future.wait([ + Get.putAsync(() async => GoogleAuthService()), + ]); + + var firebaseApp = Firebase.app(); + await Get.putAsync( + () async => await IsarService().init().timeout(const Duration(seconds: 20)), + ); + await Get.putAsync( + () async => await FirebaseService().init(firebaseApp: firebaseApp), + ); + + ChildrenModule(); + FlashCardModule(); + BookModule(); + + Get.put(MainController( + childrenUsecase: Get.find(), + flashCardUsecase: Get.find(), + bookUsecase: Get.find(), + )); + + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + EpicSize.initialize(context); + EpicAppSize.initialize(context); + + return ScreenUtilInit( + designSize: const Size(375, 812), + minTextAdapt: true, + splitScreenMode: true, + child: GetMaterialApp( + debugShowCheckedModeBanner: false, + title: 'Epic Story', + initialRoute: EpicRoutes.initialRoute, + getPages: EpicRoutes.routes, + themeMode: ThemeMode.dark, + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..727c223 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1674 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: dd3d2ad434b9510001d089e8de7556d50c834481b9abc2891a0184a8493a19dc + url: "https://pub.dev" + source: hosted + version: "89.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: ff0a84a2734d9e1089f8aedd5c0af0061b82fb94e95260d943404e0ef2134b11 + url: "https://pub.dev" + source: hosted + version: "1.3.59" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: c22b6e7726d1f9e5db58c7251606076a71ca0dbcf76116675edfadbec0c9e875 + url: "https://pub.dev" + source: hosted + version: "8.2.0" + archive: + dependency: transitive + description: + name: archive + sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d + url: "https://pub.dev" + source: hosted + version: "3.6.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: a72dd459d1a48f61a6fb9c0134dba26597c9236af40639ff0eb70eb4e0baab70 + url: "https://pub.dev" + source: hosted + version: "6.6.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: c994b3bb3a921e4904ac40e013fbc94488e824fd7c1de6326f549943b0b44a91 + url: "https://pub.dev" + source: hosted + version: "6.4.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.dev" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.dev" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: faa8fa6587f996a6f604433b53af44c57a1407d4fe8dff5766cf63d6875e8de9 + url: "https://pub.dev" + source: hosted + version: "5.2.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: bafff2b38b6f6d331887558ba6e0a01c9c208d9dbb3ad0005234db065122a734 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: ce76b1d48875e3233fde17717c23d1f60a91cc631597e49a400c89b475395b1d + url: "https://pub.dev" + source: hosted + version: "3.1.0" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: d1d57f7807debd7349b4726a19fd32ec8bc177c71ad0febf91a20f84cd2d4b46 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: b24597fceb695969d47025c958f3837f9f0122e237c6a22cb082a5ac66c3ca30 + url: "https://pub.dev" + source: hosted + version: "2.7.1" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "066dda7f73d8eb48ba630a55acb50c4a84a2e6b453b1cb4567f581729e794f7b" + url: "https://pub.dev" + source: hosted + version: "9.3.1" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + url: "https://pub.dev" + source: hosted + version: "8.12.4" + cached_network_image: + dependency: "direct main" + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + sha256: "2d33da4465bdb81b6685c41b535895065adcb16261beb398f5f3bbc623979e9c" + url: "https://pub.dev" + source: hosted + version: "5.6.12" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + sha256: "413c4e01895cf9cb3de36fa5c219479e06cd4722876274ace5dfc9f13ab2e39b" + url: "https://pub.dev" + source: hosted + version: "6.6.12" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + sha256: c1e30fc4a0fcedb08723fb4b1f12ee4e56d937cbf9deae1bda43cbb6367bb4cf + url: "https://pub.dev" + source: hosted + version: "4.4.12" + cloud_text_to_speech: + dependency: "direct main" + description: + name: cloud_text_to_speech + sha256: d057264b46bada671076ce4f73e7c83146e4380a6eb630dcc01e74d479744b05 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + confetti: + dependency: "direct main" + description: + name: confetti + sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + url: "https://pub.dev" + source: hosted + version: "6.1.5" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + url: "https://pub.dev" + source: hosted + version: "3.1.3" + dartx: + dependency: transitive + description: + name: dartx + sha256: "8b25435617027257d43e6508b5fe061012880ddfdaa75a71d607c3de2a13d244" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://pub.dev" + source: hosted + version: "0.7.12" + device_info_plus: + dependency: transitive + description: + name: device_info_plus + sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a" + url: "https://pub.dev" + source: hosted + version: "11.5.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" + firebase_auth: + dependency: "direct main" + description: + name: firebase_auth + sha256: "0fed2133bee1369ee1118c1fef27b2ce0d84c54b7819a2b17dada5cfec3b03ff" + url: "https://pub.dev" + source: hosted + version: "5.7.0" + firebase_auth_platform_interface: + dependency: transitive + description: + name: firebase_auth_platform_interface + sha256: "871c9df4ec9a754d1a793f7eb42fa3b94249d464cfb19152ba93e14a5966b386" + url: "https://pub.dev" + source: hosted + version: "7.7.3" + firebase_auth_web: + dependency: transitive + description: + name: firebase_auth_web + sha256: d9ada769c43261fd1b18decf113186e915c921a811bd5014f5ea08f4cf4bc57e + url: "https://pub.dev" + source: hosted + version: "5.15.3" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "7be63a3f841fc9663342f7f3a011a42aef6a61066943c90b1c434d79d5c995c5" + url: "https://pub.dev" + source: hosted + version: "3.15.2" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "0ed0dc292e8f9ac50992e2394e9d336a0275b6ae400d64163fdf0a8a8b556c37" + url: "https://pub.dev" + source: hosted + version: "2.24.1" + firebase_crashlytics: + dependency: "direct main" + description: + name: firebase_crashlytics + sha256: "662ae6443da91bca1fb0be8aeeac026fa2975e8b7ddfca36e4d90ebafa35dde1" + url: "https://pub.dev" + source: hosted + version: "4.3.10" + firebase_crashlytics_platform_interface: + dependency: transitive + description: + name: firebase_crashlytics_platform_interface + sha256: "7222a8a40077c79f6b8b3f3439241c9f2b34e9ddfde8381ffc512f7b2e61f7eb" + url: "https://pub.dev" + source: hosted + version: "3.8.10" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "60be38574f8b5658e2f22b7e311ff2064bea835c248424a383783464e8e02fcc" + url: "https://pub.dev" + source: hosted + version: "15.2.10" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "685e1771b3d1f9c8502771ccc9f91485b376ffe16d553533f335b9183ea99754" + url: "https://pub.dev" + source: hosted + version: "4.6.10" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: "0d1be17bc89ed3ff5001789c92df678b2e963a51b6fa2bdb467532cc9dbed390" + url: "https://pub.dev" + source: hosted + version: "3.10.10" + firebase_storage: + dependency: "direct main" + description: + name: firebase_storage + sha256: "958fc88a7ef0b103e694d30beed515c8f9472dde7e8459b029d0e32b8ff03463" + url: "https://pub.dev" + source: hosted + version: "12.4.10" + firebase_storage_platform_interface: + dependency: transitive + description: + name: firebase_storage_platform_interface + sha256: d2661c05293c2a940c8ea4bc0444e1b5566c79dd3202c2271140c082c8cd8dd4 + url: "https://pub.dev" + source: hosted + version: "5.2.10" + firebase_storage_web: + dependency: transitive + description: + name: firebase_storage_web + sha256: "629a557c5e1ddb97a3666cbf225e97daa0a66335dbbfdfdce113ef9f881e833f" + url: "https://pub.dev" + source: hosted + version: "3.10.17" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_pdfview: + dependency: "direct main" + description: + name: flutter_pdfview + sha256: "5b80e89f3ba6e478d1e897543c9634508284ad73476807febc188378986b69ee" + url: "https://pub.dev" + source: hosted + version: "1.4.4" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" + flutter_screenutil: + dependency: "direct main" + description: + name: flutter_screenutil + sha256: "8239210dd68bee6b0577aa4a090890342d04a136ce1c81f98ee513fc0ce891de" + url: "https://pub.dev" + source: hosted + version: "5.9.3" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + url: "https://pub.dev" + source: hosted + version: "2.2.4" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_tts: + dependency: "direct main" + description: + name: flutter_tts + sha256: aed2a00c48c43af043ed81145fd8503ddd793dafa7088ab137dbef81a703e53d + url: "https://pub.dev" + source: hosted + version: "4.0.2" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "90778fe0497fe3a09166e8cf2e0867310ff434b794526589e77ec03cf08ba8e8" + url: "https://pub.dev" + source: hosted + version: "8.2.14" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + get: + dependency: "direct main" + description: + name: get + sha256: "5ed34a7925b85336e15d472cc4cfe7d9ebf4ab8e8b9f688585bf6b50f4c3d79a" + url: "https://pub.dev" + source: hosted + version: "4.7.3" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + google_fonts: + dependency: "direct main" + description: + name: google_fonts + sha256: e20ff62b158b96f392bfc8afe29dee1503c94fbea2cbe8186fd59b756b8ae982 + url: "https://pub.dev" + source: hosted + version: "5.1.0" + google_identity_services_web: + dependency: transitive + description: + name: google_identity_services_web + sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454" + url: "https://pub.dev" + source: hosted + version: "0.3.3+1" + google_nav_bar: + dependency: "direct main" + description: + name: google_nav_bar + sha256: bb12dd21514ee1b041ab3127673e2fd85e693337df308f7f2b75cd1e8e92eaf4 + url: "https://pub.dev" + source: hosted + version: "5.0.7" + google_sign_in: + dependency: "direct main" + description: + name: google_sign_in + sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + google_sign_in_android: + dependency: transitive + description: + name: google_sign_in_android + sha256: f353140580797e01c1f35748810326f326664c52040b6f62d88e7d6d1cd30917 + url: "https://pub.dev" + source: hosted + version: "7.2.9" + google_sign_in_ios: + dependency: transitive + description: + name: google_sign_in_ios + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 + url: "https://pub.dev" + source: hosted + version: "6.3.0" + google_sign_in_platform_interface: + dependency: transitive + description: + name: google_sign_in_platform_interface + sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + google_sign_in_web: + dependency: transitive + description: + name: google_sign_in_web + sha256: dac0676af14b96b11691cc3c3e152415a896a38f1224269241d7cc294bdb9102 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + google_speech: + dependency: "direct main" + description: + name: google_speech + sha256: "4df9ffc46a3fa042e2e3b9466d50461b778dfbdb7e47d673efe421ce28da72bb" + url: "https://pub.dev" + source: hosted + version: "5.3.0" + googleapis_auth: + dependency: transitive + description: + name: googleapis_auth + sha256: befd71383a955535060acde8792e7efc11d2fccd03dd1d3ec434e85b68775938 + url: "https://pub.dev" + source: hosted + version: "1.6.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + grpc: + dependency: transitive + description: + name: grpc + sha256: e93ee3bce45c134bf44e9728119102358c7cd69de7832d9a874e2e74eb8cab40 + url: "https://pub.dev" + source: hosted + version: "3.2.4" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http2: + dependency: transitive + description: + name: http2 + sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + isar_community: + dependency: "direct main" + description: + name: isar_community + sha256: d92315e1862448f236489c2b2b1f9a7ad5ba2405f42d87216234be4fb2e1dd2d + url: "https://pub.dev" + source: hosted + version: "3.3.0" + isar_community_flutter_libs: + dependency: "direct main" + description: + name: isar_community_flutter_libs + sha256: "3c072d8d77e820196fa23839b88481c50d903c73b3c0db6e647b29509d04fe3b" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + isar_community_generator: + dependency: "direct dev" + description: + name: isar_community_generator + sha256: "6ca1487b7551850f7896443aa8079a12b23cdf71a99dafb1f567c83d6e031042" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + locale_names: + dependency: transitive + description: + name: locale_names + sha256: "7a89ca54072f4f13d0f5df5a9ba69337554bf2fd057d1dd2a238898f3f159374" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + logger: + dependency: "direct main" + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + lottie: + dependency: "direct main" + description: + name: lottie + sha256: "377d87b8dcef640c04717e93afb86a510f0e1117a399ab94dc4b3f39c85eaa87" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + url: "https://pub.dev" + source: hosted + version: "0.12.19" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "92b2ca62c8bd2b8d2f267cdfccf9bfbdb7322f778f8f91b3ce5b5cda23a3899f" + url: "https://pub.dev" + source: hosted + version: "0.17.5" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.dev" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "7b19d6ba131c6eb98bfcbf8d56c1a7002eba438af2e7ae6f8398b2b0f4f381e3" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + syncfusion_flutter_core: + dependency: transitive + description: + name: syncfusion_flutter_core + sha256: bee87cdfe527b31705190162e3bd27bf468d591ae7c68182244bdfd94e21f737 + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_flutter_pdf: + dependency: transitive + description: + name: syncfusion_flutter_pdf + sha256: "08fc998934dab953e980ab3b98482e994beda2e736b88c9fec99cdcf04036509" + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_flutter_pdfviewer: + dependency: "direct main" + description: + name: syncfusion_flutter_pdfviewer + sha256: "240c3c50e5a9a0ed2ca9d0449414f83ddcb09dafc798362bf8d8db11dabffdfe" + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_flutter_signaturepad: + dependency: transitive + description: + name: syncfusion_flutter_signaturepad + sha256: "903dcd8a6afcd94488be296f9e45c28e2ea6634849eccb70066e310db4198edc" + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_pdfviewer_macos: + dependency: transitive + description: + name: syncfusion_pdfviewer_macos + sha256: "529befb03610bc5f647ddba84150355bea18a25f5ce8329f3065971a498b89d1" + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_pdfviewer_platform_interface: + dependency: transitive + description: + name: syncfusion_pdfviewer_platform_interface + sha256: e27a16b987b7b0d241a153ac96119c2bf753075cae93301e0143ebf7c139503b + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_pdfviewer_web: + dependency: transitive + description: + name: syncfusion_pdfviewer_web + sha256: "1cf1d8f8871fd421e454e7e38f1044b6b170bb0201a5bee8634210d27aa79e2e" + url: "https://pub.dev" + source: hosted + version: "29.2.11" + syncfusion_pdfviewer_windows: + dependency: transitive + description: + name: syncfusion_pdfviewer_windows + sha256: cc6826cdae73657d010bd4480b5c6f06a78589e86185eeec9f6ec931c4c4276a + url: "https://pub.dev" + source: hosted + version: "29.2.11" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + url: "https://pub.dev" + source: hosted + version: "0.7.10" + time: + dependency: transitive + description: + name: time + sha256: "46187cf30bffdab28c56be9a63861b36e4ab7347bf403297595d6a97e10c789f" + url: "https://pub.dev" + source: hosted + version: "2.1.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + url_launcher: + dependency: transitive + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + xxh3: + dependency: transitive + description: + name: xxh3 + sha256: "399a0438f5d426785723c99da6b16e136f4953fb1e9db0bf270bd41dd4619916" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" + zoom_tap_animation: + dependency: "direct main" + description: + name: zoom_tap_animation + sha256: d9f7a73cab65aa1546ba6886b5e21d3c8ccccb34e4e5f770301c306d4868bee0 + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b443d4a --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,82 @@ +name: epic_story_app +description: "A new Flutter project." +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: ">=3.6.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + + # UI & Animations + flutter_screenutil: ^5.9.3 + flutter_svg: ^2.0.10+1 + google_fonts: ^5.1.0 + lottie: ^3.1.2 + zoom_tap_animation: ^1.1.0 + cupertino_icons: ^1.0.8 + google_nav_bar: ^5.0.7 + + # State Management + get: ^4.7.2 + + # Firebase Services + cloud_firestore: ^5.2.1 + firebase_core: ^3.15.2 + firebase_auth: ^5.1.3 + firebase_storage: ^12.3.2 + firebase_messaging: ^15.1.3 + firebase_crashlytics: ^4.3.5 + google_sign_in: ^7.2.0 + + # Networks + dio: ^5.7.0 + connectivity_plus: ^6.1.3 + + # Storage & Caching + flutter_secure_storage: ^9.2.2 + cached_network_image: ^3.4.1 + isar_community: ^3.3.0 + isar_community_flutter_libs: ^3.3.0 + path_provider: ^2.1.4 + + # Multimedia & Interactivity + audioplayers: ^6.1.0 + confetti: ^0.8.0 + + # Image Processing + image_picker: ^1.1.2 + + # Utilities + intl: ^0.19.0 + fluttertoast: ^8.2.8 + logger: ^2.4.0 + flutter_pdfview: ^1.4.0 + syncfusion_flutter_pdfviewer: ^29.1.35 + flutter_tts: 4.0.2 + google_speech: ^5.3.0 + cloud_text_to_speech: ^1.1.3 + gal: ^2.3.2 + +# helper_animation: +# git: +# url: https://github.com/Greek-Cp/helper_animation.git +# ref: main + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + build_runner: ^2.4.11 + isar_community_generator: ^3.3.0 + +flutter: + uses-material-design: true + assets: + - assets/ + - assets/books/others/ + - assets/images/ \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..4794087 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:epic_story_app/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}